diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0dc6d10a8..5ca1021a0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,6 +6,15 @@ body: attributes: value: | Thanks for taking the time to fill out this bug report! + - type: checkboxes + id: confirm-troubleshooting + attributes: + label: Have you enabled troubleshooting mode? + description: | + To ensure the bug is not caused by custom modifications or plugins make sure to enable troubleshooting mode before filing the report. In Stash go to Settings and click **Troubleshooting mode** and then retest to see if the bug still occurs. + options: + - label: I confirm that the troubleshooting mode is enabled. + required: true - type: textarea id: description attributes: @@ -61,4 +70,4 @@ body: attributes: label: Relevant log output description: Please copy and paste any relevant log output from Settings > Logs. This will be automatically formatted into code, so no need for backticks. - render: shell \ No newline at end of file + render: shell diff --git a/.github/workflows/build-compiler.yml b/.github/workflows/build-compiler.yml new file mode 100644 index 000000000..42562c95c --- /dev/null +++ b/.github/workflows/build-compiler.yml @@ -0,0 +1,28 @@ +name: Compiler Build + +on: + workflow_dispatch: + +env: + COMPILER_IMAGE: ghcr.io/stashapp/compiler:14 + +jobs: + build-compiler: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + push: true + context: "{{defaultContext}}:docker/compiler" + tags: | + ${{ env.COMPILER_IMAGE }} + ghcr.io/stashapp/compiler:latest + cache-from: type=gha,scope=all,mode=max + cache-to: type=gha,scope=all,mode=max \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e46ecd69..7f6f5696d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: Build on: push: - branches: + branches: - develop - master - 'releases/**' @@ -15,50 +15,165 @@ concurrency: cancel-in-progress: true env: - COMPILER_IMAGE: stashapp/compiler:12 + COMPILER_IMAGE: ghcr.io/stashapp/compiler:14 jobs: - build: - runs-on: ubuntu-22.04 + # Job 1: Generate code and build UI + # Runs natively (no Docker) — go generate/gqlgen and node don't need cross-compilers. + # Produces artifacts (generated Go files + UI build) consumed by test and build jobs. + generate: + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v2 - - - name: Checkout - run: git fetch --prune --unshallow --tags - + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: 'go.mod' - - name: Pull compiler image - run: docker pull $COMPILER_IMAGE - - - name: Cache node modules - uses: actions/cache@v3 - env: - cache-name: cache-node_modules + # pnpm version is read from the packageManager field in package.json + # very broken (4.3, 4.4) + - name: Install pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 with: - path: ui/v2.5/node_modules - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/pnpm-lock.yaml') }} + package_json_file: ui/v2.5/package.json + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: ui/v2.5/pnpm-lock.yaml + + - name: Install UI dependencies + run: cd ui/v2.5 && pnpm install --frozen-lockfile + + - name: Generate + run: make generate - name: Cache UI build - uses: actions/cache@v3 + uses: actions/cache@v5 id: cache-ui - env: - cache-name: cache-ui with: path: ui/v2.5/build - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/pnpm-lock.yaml', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }} + key: ${{ runner.os }}-ui-build-${{ hashFiles('ui/v2.5/pnpm-lock.yaml', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }} - - name: Cache go build - uses: actions/cache@v3 - env: - # increment the number suffix to bump the cache - cache-name: cache-go-cache-1 + - name: Validate UI + # skip UI validation for pull requests if UI is unchanged + if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} + run: make validate-ui + + - name: Build UI + # skip UI build for pull requests if UI is unchanged (UI was cached) + if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} + run: make ui + + # Bundle generated Go files + UI build for downstream jobs (test + build) + - name: Upload generated artifacts + uses: actions/upload-artifact@v7 + with: + name: generated + retention-days: 1 + path: | + internal/api/generated_exec.go + internal/api/generated_models.go + ui/v2.5/build/ + ui/login/locales/ + + # Job 2: Integration tests + # Runs natively (no Docker) — only needs Go + GCC (for CGO/SQLite), both on ubuntu-22.04. + # Runs in parallel with the build matrix jobs. + test: + needs: generate + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + + # Places generated Go files + UI build into the working tree so the build compiles + - name: Download generated artifacts + uses: actions/download-artifact@v8 + with: + name: generated + + - name: Test Backend + run: make it + + # Job 3: Cross-compile for all platforms + # Each platform gets its own runner and Docker container (ghcr.io/stashapp/compiler:13). + # Each build-cc-* make target is self-contained (sets its own GOOS/GOARCH/CC), + # so running them in separate containers is functionally identical to one container. + # Runs in parallel with the test job. + build: + needs: generate + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - platform: windows + make-target: build-cc-windows + artifact-paths: | + dist/stash-win.exe + tag: win + - platform: macos + make-target: build-cc-macos + artifact-paths: | + dist/stash-macos + dist/Stash.app.zip + tag: osx + - platform: linux + make-target: build-cc-linux + artifact-paths: | + dist/stash-linux + tag: linux + - platform: linux-arm64v8 + make-target: build-cc-linux-arm64v8 + artifact-paths: | + dist/stash-linux-arm64v8 + tag: arm + - platform: linux-arm32v7 + make-target: build-cc-linux-arm32v7 + artifact-paths: | + dist/stash-linux-arm32v7 + tag: arm + - platform: linux-arm32v6 + make-target: build-cc-linux-arm32v6 + artifact-paths: | + dist/stash-linux-arm32v6 + tag: arm + - platform: freebsd + make-target: build-cc-freebsd + artifact-paths: | + dist/stash-freebsd + tag: freebsd + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Download generated artifacts + uses: actions/download-artifact@v8 + with: + name: generated + + - name: Cache Go build + uses: actions/cache@v5 with: path: .go-cache - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('go.mod', '**/go.sum') }} + key: ${{ runner.os }}-go-cache-${{ matrix.platform }}-${{ hashFiles('go.mod', '**/go.sum') }} + + # kept seperate to test timings + - name: pull compiler image + run: docker pull $COMPILER_IMAGE - name: Start build container env: @@ -67,45 +182,50 @@ jobs: mkdir -p .go-cache docker run -d --name build --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated --mount type=bind,source="$(pwd)/.go-cache",target=/root/.cache/go-build,consistency=delegated --env OFFICIAL_BUILD=${{ env.official-build }} -w /stash $COMPILER_IMAGE tail -f /dev/null - - name: Pre-install - run: docker exec -t build /bin/bash -c "make CI=1 pre-ui" - - - name: Generate - run: docker exec -t build /bin/bash -c "make generate" - - - name: Validate UI - # skip UI validation for pull requests if UI is unchanged - if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} - run: docker exec -t build /bin/bash -c "make validate-ui" - - # Static validation happens in the linter workflow in parallel to this workflow - # Run Dynamic validation here, to make sure we pass all the projects integration tests - - name: Test Backend - run: docker exec -t build /bin/bash -c "make it" - - - name: Build UI - # skip UI build for pull requests if UI is unchanged (UI was cached) - # this means that the build version/time may be incorrect if the UI is - # not changed in a pull request - if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }} - run: docker exec -t build /bin/bash -c "make ui" - - - name: Compile for all supported platforms - run: | - docker exec -t build /bin/bash -c "make build-cc-windows" - docker exec -t build /bin/bash -c "make build-cc-macos" - docker exec -t build /bin/bash -c "make build-cc-linux" - docker exec -t build /bin/bash -c "make build-cc-linux-arm64v8" - docker exec -t build /bin/bash -c "make build-cc-linux-arm32v7" - docker exec -t build /bin/bash -c "make build-cc-linux-arm32v6" - docker exec -t build /bin/bash -c "make build-cc-freebsd" - - - name: Zip UI - run: docker exec -t build /bin/bash -c "make zip-ui" + - name: Build (${{ matrix.platform }}) + run: docker exec -t build /bin/bash -c "make ${{ matrix.make-target }}" - name: Cleanup build container run: docker rm -f -v build + - name: Upload build artifact + uses: actions/upload-artifact@v7 + with: + name: build-${{ matrix.platform }} + retention-days: 1 + path: ${{ matrix.artifact-paths }} + + # Job 4: Release + # Waits for both test and build to pass, then collects all platform artifacts + # into dist/ for checksums, GitHub releases, and multi-arch Docker push. + release: + needs: [test, build] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + # Downloads all artifacts (generated + 7 platform builds) into artifacts/ subdirectories + - name: Download all build artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts + + # Reassemble platform binaries from matrix job artifacts into a single dist/ directory + # make sure that artifacts have executable bit set + # upload-artifact@v4 strips the common path prefix (dist/), so files are at the artifact root + - name: Collect binaries + run: | + mkdir -p dist + cp artifacts/build-*/* dist/ + chmod +x dist/* + + - name: Zip UI + run: | + cd artifacts/generated/ui/v2.5/build && zip -r ../../../../../dist/stash-ui.zip . + - name: Generate checksums run: | git describe --tags --exclude latest_develop | tee CHECKSUMS_SHA1 @@ -116,7 +236,7 @@ jobs: - name: Upload Windows binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: stash-win.exe path: dist/stash-win.exe @@ -124,15 +244,23 @@ jobs: - name: Upload macOS binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: stash-macos path: dist/stash-macos + - name: Upload macOS bundle + # only upload binaries for pull requests + if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} + uses: actions/upload-artifact@v7 + with: + name: Stash.app.zip + path: dist/Stash.app.zip + - name: Upload Linux binary # only upload binaries for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: stash-linux path: dist/stash-linux @@ -140,14 +268,14 @@ jobs: - name: Upload UI # only upload for pull requests if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: stash-ui.zip path: dist/stash-ui.zip - name: Update latest_develop tag if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} - run : git tag -f latest_develop; git push -f --tags + run: git tag -f latest_develop; git push -f --tags - name: Development Release if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} @@ -197,7 +325,7 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: | - docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64 + docker run --rm --privileged tonistiigi/binfmt docker info docker buildx create --name builder --use docker buildx inspect --bootstrap @@ -213,7 +341,7 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: | - docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64 + docker run --rm --privileged tonistiigi/binfmt docker info docker buildx create --name builder --use docker buildx inspect --bootstrap diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 71c743ced..d2d54b207 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -9,65 +9,24 @@ on: - 'releases/**' pull_request: -env: - COMPILER_IMAGE: stashapp/compiler:12 - jobs: golangci: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - - name: Checkout - run: git fetch --prune --unshallow --tags - - - name: Setup Go - uses: actions/setup-go@v5 + # no tags or depth needed for lint + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: 'go.mod' - - name: Pull compiler image - run: docker pull $COMPILER_IMAGE - - - name: Start build container - run: | - mkdir -p .go-cache - docker run -d --name build --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated --mount type=bind,source="$(pwd)/.go-cache",target=/root/.cache/go-build,consistency=delegated -w /stash $COMPILER_IMAGE tail -f /dev/null - + # generate-backend runs natively (just go generate + touch-ui) — no Docker needed - name: Generate Backend - run: docker exec -t build /bin/bash -c "make generate-backend" + run: make generate-backend + ## WARN + ## using v1, update in a later PR - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v8 with: - # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version - version: latest - - # Optional: working directory, useful for monorepos - # working-directory: somedir - - # Optional: golangci-lint command line arguments. - # - # Note: By default, the `.golangci.yml` file should be at the root of the repository. - # The location of the configuration file can be changed by using `--config=` - args: --timeout=5m - - # Optional: show only new issues if it's a pull request. The default value is `false`. - # only-new-issues: true - - # Optional: if set to true, then all caching functionality will be completely disabled, - # takes precedence over all other caching options. - # skip-cache: true - - # Optional: if set to true, then the action won't cache or restore ~/go/pkg. - # skip-pkg-cache: true - - # Optional: if set to true, then the action won't cache or restore ~/.cache/go-build. - # skip-build-cache: true - - # Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'. - # install-mode: "goinstall" - - - name: Cleanup build container - run: docker rm -f -v build + version: v2.11.4 \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 5ed4d715c..2521ebfc2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,87 +1,100 @@ -# options for analysis running -run: - timeout: 5m - +version: "2" linters: - disable-all: true + default: none enable: - # Default set of linters from golangci-lint - - errcheck - - gosimple - - govet - - ineffassign - - staticcheck - - typecheck - - unused - # Linters added by the stash project. - # - contextcheck - copyloopvar - dogsled + - errcheck - errchkjson - errorlint - # - exhaustive - gocritic - # - goerr113 - - gofmt - # - gomnd - # - ifshort + - govet + - ineffassign - misspell - # - nakedret - - noctx + + # TODO - fix these in a later PR + # - noctx + - revive - rowserrcheck - sqlclosecheck - -# Project-specific linter overrides -linters-settings: - gofmt: - simplify: false - - errorlint: - # Disable errorf because there are false positives, where you don't want to wrap - # an error. - errorf: false - asserts: true - comparison: true - - revive: - ignore-generated-header: true - severity: error - confidence: 0.8 - rules: - - name: blank-imports - disabled: true - - name: context-as-argument - - name: context-keys-type - - name: dot-imports - - name: error-return - - name: error-strings - - name: error-naming - - name: exported - disabled: true - - name: if-return - disabled: true - - name: increment-decrement - - name: var-naming - disabled: true - - name: var-declaration - - name: package-comments - - name: range - - name: receiver-naming - - name: time-naming - - name: unexported-return - disabled: true - - name: indent-error-flow - disabled: true - - name: errorf - - name: empty-block - disabled: true - - name: superfluous-else - - name: unused-parameter - disabled: true - - name: unreachable-code - - name: redefines-builtin-id - - rowserrcheck: - packages: - - github.com/jmoiron/sqlx + - staticcheck + - unused + + settings: + staticcheck: + checks: + - all + + # we specify (unnecessary) embedded fields for clarity in many places + - -QF1008 + + # there's lots of misnamed (eg intId instead of intID) fields in the code. + # it's not exactly world-ending, so I'm deferring fixing these for now + - -ST1003 + errorlint: + errorf: false + asserts: true + comparison: true + revive: + confidence: 0.8 + severity: error + rules: + - name: blank-imports + disabled: true + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + disabled: true + - name: if-return + disabled: true + - name: increment-decrement + - name: var-naming + disabled: true + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + disabled: true + - name: indent-error-flow + disabled: true + - name: errorf + - name: empty-block + disabled: true + - name: superfluous-else + - name: unused-parameter + disabled: true + - name: unreachable-code + - name: redefines-builtin-id + rowserrcheck: + packages: + - github.com/jmoiron/sqlx + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + settings: + gofmt: + simplify: false + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/Makefile b/Makefile index 7e19063a3..d9caf0ee5 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ export CGO_ENABLED := 1 # define COMPILER_IMAGE for cross-compilation docker container ifndef COMPILER_IMAGE - COMPILER_IMAGE := stashapp/compiler:latest + COMPILER_IMAGE := ghcr.io/stashapp/compiler:latest endif .PHONY: release @@ -129,7 +129,7 @@ phasher: build-flags # builds dynamically-linked debug binaries .PHONY: build -build: stash phasher +build: stash # builds dynamically-linked PIE release binaries .PHONY: build-release @@ -187,8 +187,6 @@ build-cc-macos: # Combine into universal binaries lipo -create -output dist/stash-macos dist/stash-macos-intel dist/stash-macos-arm rm dist/stash-macos-intel dist/stash-macos-arm - lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm - rm dist/phasher-macos-intel dist/phasher-macos-arm # Place into bundle and zip up rm -rf dist/Stash.app @@ -198,6 +196,16 @@ build-cc-macos: cd dist && rm -f Stash.app.zip && zip -r Stash.app.zip Stash.app rm -rf dist/Stash.app +.PHONY: build-cc-macos-phasher +build-cc-macos-phasher: + make build-cc-macos-arm + make build-cc-macos-intel + + # Combine into universal binaries + lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm + rm dist/phasher-macos-intel dist/phasher-macos-arm + # do not bundle phasher + .PHONY: build-cc-freebsd build-cc-freebsd: export GOOS := freebsd build-cc-freebsd: export GOARCH := amd64 diff --git a/README.md b/README.md index 5ccefe4bc..781eb5fcb 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,14 @@ [![GitHub release (latest by date)](https://img.shields.io/github/v/release/stashapp/stash?logo=github)](https://github.com/stashapp/stash/releases/latest) [![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty) -### **Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.** +

Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.

![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png) -* Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites. -* Stash supports a wide variety of both video and image formats. -* You can tag videos and find them later. -* Stash provides statistics about performers, tags, studios and more. +- Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites. +- Stash supports a wide variety of both video and image formats. +- You can tag videos and find them later. +- Stash provides statistics about performers, tags, studios and more. You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action. @@ -24,17 +24,20 @@ For further information you can consult the [documentation](https://docs.stashap # Installing Stash +> [!tip] Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/). -#### Windows Users: - -As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._ -At least Windows 10 or Server 2016 is required. - -#### Mac Users: - -As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later. -Stash can still be run through docker on older versions of macOS. +> [!important] +> **Windows Users** +> +> As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._ +> At least Windows 10 or Server 2016 is required. +> +> **macOS Users** +> +> As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later. +> As of version 0.32.0, Stash requires _macOS 12 Monterey_ or later. +> Stash can still be run through Docker on older versions of macOS. Windows | macOS | Linux | Docker :---:|:---:|:---:|:---: @@ -85,23 +88,36 @@ The badge below shows the current translation status of Stash across all support Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance. -- Documentation - - Official docs: https://docs.stashapp.cc - official guides guides and troubleshooting. - - In-app manual: press Shift + ? in the app or view the manual online: https://docs.stashapp.cc/in-app-manual. - - FAQ: https://discourse.stashapp.cc/c/support/faq/28 - common questions and answers. - - Community wiki: https://discourse.stashapp.cc/tags/c/community-wiki/22/stash - guides, how-to’s and tips. +### Documentation +- [Official documentation](https://docs.stashapp.cc) - official guides guides and troubleshooting. +- [In-app manual](https://docs.stashapp.cc/in-app-manual) press Shift + ? in the app or view the manual online. +- [FAQ](https://discourse.stashapp.cc/c/support/faq/28) - common questions and answers. +- [Community wiki](https://discourse.stashapp.cc/tags/c/community-wiki/22/stash) - guides, how-to’s and tips. -- Community & discussion - - Community forum: https://discourse.stashapp.cc - community support, feature requests and discussions. - - Discord: https://discord.gg/2TsNFKt - real-time chat and community support. - - GitHub discussions: https://github.com/stashapp/stash/discussions - community support and feature discussions. - - Lemmy community: https://discuss.online/c/stashapp - Reddit-style community space. +### Community & discussion +- [Community forum](https://discourse.stashapp.cc) - community support, feature requests and discussions. +- [Discord](https://discord.gg/2TsNFKt) - real-time chat and community support. +- [GitHub discussions](https://github.com/stashapp/stash/discussions) - community support and feature discussions. +- [Lemmy community](https://discuss.online/c/stashapp) - board-style community space. -- Community scrapers & plugins - - Metadata sources: https://docs.stashapp.cc/metadata-sources/ - - Plugins: https://docs.stashapp.cc/plugins/ - - Themes: https://docs.stashapp.cc/themes/ - - Other projects: https://docs.stashapp.cc/other-projects/ +### Community scrapers & plugins +- [Metadata sources](https://docs.stashapp.cc/metadata-sources/) +- [Plugins](https://docs.stashapp.cc/plugins/) +- [Themes](https://docs.stashapp.cc/themes/) +- [Other projects](https://docs.stashapp.cc/other-projects/) + +# Architecture + +## Backend + +- Go +- GraphQL API +- SQLite + +## Frontend + +- React +- TypeScript # For Developers diff --git a/cmd/stash/main.go b/cmd/stash/main.go index 57fedd0e2..def4f3368 100644 --- a/cmd/stash/main.go +++ b/cmd/stash/main.go @@ -148,7 +148,7 @@ func recoverPanic() { exitCode = 1 logger.Errorf("panic: %v\n%s", err, debug.Stack()) if desktop.IsDesktop() { - desktop.FatalError(fmt.Errorf("Panic: %v", err)) + desktop.FatalError(fmt.Errorf("panic: %v", err)) } } } diff --git a/docker/ci/x86_64/Dockerfile b/docker/ci/x86_64/Dockerfile index 6a9c6b76d..2161cb6af 100644 --- a/docker/ci/x86_64/Dockerfile +++ b/docker/ci/x86_64/Dockerfile @@ -12,7 +12,7 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \ FROM --platform=$TARGETPLATFORM alpine:latest AS app COPY --from=binary /stash /usr/bin/ -RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools \ +RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools vips-heif \ && pip install --break-system-packages mechanicalsoup cloudscraper stashapp-tools ENV STASH_CONFIG_FILE=/root/.stash/config.yml diff --git a/docker/compiler/.gitignore b/docker/compiler/.gitignore deleted file mode 100644 index 7012bfd63..000000000 --- a/docker/compiler/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.sdk.tar.* \ No newline at end of file diff --git a/docker/compiler/Dockerfile b/docker/compiler/Dockerfile index 0154d7e61..d41be11a3 100644 --- a/docker/compiler/Dockerfile +++ b/docker/compiler/Dockerfile @@ -1,82 +1,86 @@ -FROM golang:1.24.3 +### OSXCROSS +FROM debian:bookworm AS osxcross +# add osxcross +WORKDIR /tmp/osxcross +ARG OSXCROSS_REVISION=5e1b71fcceb23952f3229995edca1b6231525b5b +ADD --checksum=sha256:d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647 https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION} /tmp/osxcross.tar.gz -LABEL maintainer="https://discord.gg/2TsNFKt" +ARG OSX_SDK_VERSION=12.3 +ARG OSX_SDK_DOWNLOAD_FILE=MacOSX${OSX_SDK_VERSION}.sdk.tar.xz +ARG OSX_SDK_DOWNLOAD_URL=https://github.com/joseluisq/macosx-sdks/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE} +ADD --checksum=sha256:3abd261ceb483c44295a6623fdffe5d44fc4ac2c872526576ec5ab5ad0f6e26c ${OSX_SDK_DOWNLOAD_URL} /tmp/osxcross/tarballs/${OSX_SDK_DOWNLOAD_FILE} -RUN apt-get update && apt-get install -y apt-transport-https ca-certificates gnupg +ENV UNATTENDED=yes \ + SDK_VERSION=${OSX_SDK_VERSION} \ + OSX_VERSION_MIN=12.0 +RUN apt update && \ + apt install -y --no-install-recommends \ + bash ca-certificates clang cmake git patch libssl-dev bzip2 cpio libbz2-dev libxml2-dev make python3 xz-utils zlib1g-dev +# lzma-dev libxml2-dev xz +RUN tar --strip=1 -C /tmp/osxcross -xf /tmp/osxcross.tar.gz +RUN ./build.sh -RUN mkdir -p /etc/apt/keyrings +### FREEBSD cross-compilation stage +# use alpine for cacheable image since apt is notorous for not caching +FROM alpine:3 AS freebsd +# match golang latest +# https://go.dev/wiki/FreeBSD +ARG FREEBSD_VERSION=12.4 +ADD --checksum=sha256:581c7edacfd2fca2bdf5791f667402d22fccd8a5e184635e0cac075564d57aa8 \ + http://ftp-archive.freebsd.org/mirror/FreeBSD-Archive/old-releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz \ + /tmp/base.txz -ADD https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key nodesource.gpg.key -RUN cat nodesource.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && rm nodesource.gpg.key -RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list +WORKDIR /opt/cross-freebsd +RUN apk add --no-cache tar xz +RUN tar -xf /tmp/base.txz --strip-components=1 ./usr/lib ./usr/include ./lib +RUN cd /opt/cross-freebsd/usr/lib && \ + find . -type l -exec sh -c ' \ + for link; do \ + target=$(readlink "$link"); \ + case "$target" in \ + /lib/*) ln -sf "/opt/cross-freebsd$target" "$link";; \ + esac; \ + done \ + ' sh {} + && \ + ln -s libc++.a libstdc++.a && \ + ln -s libc++.so libstdc++.so -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - git make tar bash nodejs zip \ - clang llvm-dev cmake patch libxml2-dev uuid-dev libssl-dev xz-utils \ - bzip2 gzip sed cpio libbz2-dev zlib1g-dev \ - gcc-mingw-w64 \ - gcc-arm-linux-gnueabi libc-dev-armel-cross linux-libc-dev-armel-cross \ - gcc-aarch64-linux-gnu libc-dev-arm64-cross && \ - rm -rf /var/lib/apt/lists/*; +### BUILDER +FROM golang:1.25.9 AS builder +ENV PATH=/opt/osx-ndk-x86/bin:$PATH + +# copy in nodejs instead of using nodesource :thumbsup: +COPY --from=docker.io/library/node:24-bookworm /usr/local /usr/local +# copy in osxcross +COPY --from=osxcross /tmp/osxcross/target/lib /usr/lib +COPY --from=osxcross /tmp/osxcross/target /opt/osx-ndk-x86 +# copy in cross-freebsd +COPY --from=freebsd /opt/cross-freebsd /opt/cross-freebsd # pnpm install with npm RUN npm install -g pnpm -# FreeBSD cross-compilation setup -# https://github.com/smartmontools/docker-build/blob/6b8c92560d17d325310ba02d9f5a4b250cb0764a/Dockerfile#L66 -ENV FREEBSD_VERSION 13.4 -ENV FREEBSD_DOWNLOAD_URL http://ftp.plusline.de/FreeBSD/releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz -ENV FREEBSD_SHA 8e13b0a93daba349b8d28ad246d7beb327659b2ef4fe44d89f447392daec5a7c +# git for getting hash +# make and bash for building -RUN cd /tmp && \ - curl -o base.txz $FREEBSD_DOWNLOAD_URL && \ - echo "$FREEBSD_SHA base.txz" | sha256sum -c - && \ - mkdir -p /opt/cross-freebsd && \ - cd /opt/cross-freebsd && \ - tar -xf /tmp/base.txz ./lib/ ./usr/lib/ ./usr/include/ && \ - rm -f /tmp/base.txz && \ - cd /opt/cross-freebsd/usr/lib && \ - find . -xtype l | xargs ls -l | grep ' /lib/' | awk '{print "ln -sf /opt/cross-freebsd"$11 " " $9}' | /bin/sh && \ - ln -s libc++.a libstdc++.a && \ - ln -s libc++.so libstdc++.so - -# macOS cross-compilation setup -ENV OSX_SDK_VERSION 11.3 -ENV OSX_SDK_DOWNLOAD_FILE MacOSX${OSX_SDK_VERSION}.sdk.tar.xz -ENV OSX_SDK_DOWNLOAD_URL https://github.com/phracker/MacOSX-SDKs/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE} -ENV OSX_SDK_SHA cd4f08a75577145b8f05245a2975f7c81401d75e9535dcffbb879ee1deefcbf4 -ENV OSXCROSS_REVISION 5e1b71fcceb23952f3229995edca1b6231525b5b -ENV OSXCROSS_DOWNLOAD_URL https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION} -ENV OSXCROSS_SHA d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647 - -RUN cd /tmp && \ - curl -o osxcross.tar.gz $OSXCROSS_DOWNLOAD_URL && \ - echo "$OSXCROSS_SHA osxcross.tar.gz" | sha256sum -c - && \ - mkdir osxcross && \ - tar --strip=1 -C osxcross -xf osxcross.tar.gz && \ - rm -f osxcross.tar.gz && \ - curl -Lo $OSX_SDK_DOWNLOAD_FILE $OSX_SDK_DOWNLOAD_URL && \ - echo "$OSX_SDK_SHA $OSX_SDK_DOWNLOAD_FILE" | sha256sum -c - && \ - mv $OSX_SDK_DOWNLOAD_FILE osxcross/tarballs/ && \ - UNATTENDED=yes SDK_VERSION=$OSX_SDK_VERSION OSX_VERSION_MIN=10.10 osxcross/build.sh && \ - cp osxcross/target/lib/* /usr/lib/ && \ - mv osxcross/target /opt/osx-ndk-x86 && \ - rm -rf /tmp/osxcross - -ENV PATH /opt/osx-ndk-x86/bin:$PATH - -RUN mkdir -p /root/.ssh && \ - chmod 0700 /root/.ssh && \ - ssh-keyscan github.com > /root/.ssh/known_hosts - -# ignore "dubious ownership" errors +# clang for macos +# zip for stashapp.zip +# gcc-extensions for cross-arch build +# we still target arm soft float? +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + git make bash \ + clang zip \ + gcc-mingw-w64 \ + gcc-arm-linux-gnueabi \ + libc-dev-armel-cross linux-libc-dev-armel-cross \ + gcc-aarch64-linux-gnu libc-dev-arm64-cross && \ + rm -rf /var/lib/apt/lists/*; RUN git config --global safe.directory '*' - # To test locally: # make generate # make ui # cd docker/compiler -# make build -# docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t stashapp/compiler:latest make build-cc-all -# # binaries will show up in /dist +# docker build . -t ghcr.io/stashapp/compiler:latest +# docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t ghcr.io/stashapp/compiler:latest make build-cc-all +# # binaries will show up in /dist \ No newline at end of file diff --git a/docker/compiler/Makefile b/docker/compiler/Makefile index ed6a9a285..2a81222a0 100644 --- a/docker/compiler/Makefile +++ b/docker/compiler/Makefile @@ -1,16 +1,22 @@ +host=ghcr.io user=stashapp repo=compiler -version=12 +version=14 + +VERSION_IMAGE = ${host}/${user}/${repo}:${version} +LATEST_IMAGE = ${host}/${user}/${repo}:latest latest: - docker build -t ${user}/${repo}:latest . + docker build -t ${LATEST_IMAGE} . build: - docker build -t ${user}/${repo}:${version} -t ${user}/${repo}:latest . + docker build -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} . build-no-cache: - docker build --no-cache -t ${user}/${repo}:${version} -t ${user}/${repo}:latest . + docker build --no-cache -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} . -install: build - docker push ${user}/${repo}:${version} - docker push ${user}/${repo}:latest +# requires docker login ghcr.io +# echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin +push: + docker push ${VERSION_IMAGE} + docker push ${LATEST_IMAGE} \ No newline at end of file diff --git a/docker/compiler/README.md b/docker/compiler/README.md index 6bb7d8d99..c7b4840f9 100644 --- a/docker/compiler/README.md +++ b/docker/compiler/README.md @@ -1,3 +1,3 @@ Modified from https://github.com/bep/dockerfiles/tree/master/ci-goreleaser -When the Dockerfile is changed, the version number should be incremented in the Makefile and the new version tag should be pushed to Docker Hub. The GitHub workflow files also need to be updated to pull the correct image tag. +When the Dockerfile is changed, the version number should be incremented in [.github/workflows/build-compiler.yml](../../.github/workflows/build-compiler.yml) and the workflow [manually ran](). `env: COMPILER_IMAGE` in [.github/workflows/build.yml](../../.github/workflows/build.yml) also needs to be updated to pull the correct image tag. \ No newline at end of file diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 85c2f6f23..a26ce6817 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -118,8 +118,8 @@ This project uses a modification of the [CI-GoReleaser](https://github.com/bep/d To cross-compile the app yourself: 1. Run `make pre-ui`, `make generate` and `make ui` outside the container, to generate files and build the UI. -2. Pull the latest compiler image from Docker Hub: `docker pull stashapp/compiler` -3. Run `docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash -it stashapp/compiler /bin/bash` to open a shell inside the container. +2. Pull the latest compiler image from GHCR: `docker pull ghcr.io/stashapp/compiler` +3. Run `docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash -it ghcr.io/stashapp/compiler /bin/bash` to open a shell inside the container. 4. From inside the container, run `make build-cc-all` to build for all platforms, or run `make build-cc-{platform}` to build for a specific platform (have a look at the `Makefile` for the list of targets). 5. You will find the compiled binaries in `dist/`. diff --git a/go.mod b/go.mod index db0d6fe34..48495d738 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/stashapp/stash -go 1.24.3 +go 1.25.0 require ( github.com/99designs/gqlgen v0.17.73 @@ -15,6 +15,7 @@ require ( github.com/disintegration/imaging v1.6.2 github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d github.com/doug-martin/goqu/v9 v9.18.0 + github.com/feederbox826/gosx-notifier v0.2.2 github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/cors v1.2.1 github.com/go-chi/httplog v0.3.1 @@ -30,7 +31,6 @@ require ( github.com/jinzhu/copier v0.4.0 github.com/jmoiron/sqlx v1.4.0 github.com/json-iterator/go v1.1.12 - github.com/kermieisinthehouse/gosx-notifier v0.1.2 github.com/kermieisinthehouse/systray v1.2.4 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 @@ -44,6 +44,7 @@ require ( github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/remeh/sizedwaitgroup v1.0.0 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cast v1.6.0 github.com/spf13/pflag v1.0.6 @@ -55,12 +56,12 @@ require ( github.com/vektra/mockery/v2 v2.10.0 github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e github.com/zencoder/go-dash/v3 v3.0.2 - golang.org/x/crypto v0.45.0 - golang.org/x/image v0.18.0 - golang.org/x/net v0.47.0 - golang.org/x/sys v0.38.0 - golang.org/x/term v0.37.0 - golang.org/x/text v0.31.0 + golang.org/x/crypto v0.48.0 + golang.org/x/image v0.38.0 + golang.org/x/net v0.50.0 + golang.org/x/sys v0.41.0 + golang.org/x/term v0.40.0 + golang.org/x/text v0.35.0 golang.org/x/time v0.10.0 gopkg.in/guregu/null.v4 v4.0.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -69,7 +70,7 @@ require ( require ( github.com/agnivade/levenshtein v1.2.1 // indirect - github.com/antchfx/xpath v1.3.5 // indirect + github.com/antchfx/xpath v1.3.6 // indirect github.com/asticode/go-astikit v0.20.0 // indirect github.com/asticode/go-astits v1.8.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect @@ -120,9 +121,9 @@ require ( github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.3 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/tools v0.42.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index dbe82cf99..25a5fd02a 100644 --- a/go.sum +++ b/go.sum @@ -87,8 +87,9 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0= github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA= -github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ= github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI= +github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= @@ -187,6 +188,8 @@ github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/feederbox826/gosx-notifier v0.2.2 h1:26NkaJZ8Wzptx82R46c9pkVAcFwGSU7kxWrOKmRWlC0= +github.com/feederbox826/gosx-notifier v0.2.2/go.mod h1:R6rqw7VuwuiCuvsr7EOONmWq++CRA5Ijmkmx75/C3Fs= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= @@ -389,8 +392,6 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kermieisinthehouse/gosx-notifier v0.1.2 h1:KV0KBeKK2B24kIHY7iK0jgS64Q05f4oB+hUZmsPodxQ= -github.com/kermieisinthehouse/gosx-notifier v0.1.2/go.mod h1:xyWT07azFtUOcHl96qMVvKhvKzsMcS7rKTHQyv8WTho= github.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s= github.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -537,6 +538,8 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= @@ -665,8 +668,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -680,8 +683,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= -golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -712,8 +715,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -768,8 +771,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -804,8 +807,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -892,8 +895,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -903,8 +906,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -921,8 +924,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -990,8 +993,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 7fda85b24..7f07e4579 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -426,6 +426,10 @@ type Mutation { destroyFiles(ids: [ID!]!): Boolean! fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean! + "Reveal the file in the system file manager" + revealFileInFileManager(id: ID!): Boolean! + "Reveal the folder in the system file manager" + revealFolderInFileManager(id: ID!): Boolean! # Saved filters saveFilter(input: SaveFilterInput!): SavedFilter! @@ -579,6 +583,8 @@ type Mutation { stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String! "Run batch studio tag task. Returns the job ID." stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String! + "Run batch tag tag task. Returns the job ID." + stashBoxBatchTagTag(input: StashBoxBatchTagInput!): String! "Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default" enableDLNA(input: EnableDLNAInput!): Boolean! diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index 835479fad..fcc2a58c8 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -6,13 +6,19 @@ type Fingerprint { type Folder { id: ID! path: String! + basename: String! parent_folder_id: ID @deprecated(reason: "Use parent_folder instead") zip_file_id: ID @deprecated(reason: "Use zip_file instead") parent_folder: Folder + "Returns all parent folders in order from immediate parent to top-level" + parent_folders: [Folder!]! zip_file: BasicFile + "Returns direct sub-folders" + sub_folders: [Folder!]! + mod_time: Time! created_at: Time! @@ -153,7 +159,7 @@ input MoveFilesInput { input SetFingerprintsInput { type: String! - "an null value will remove the fingerprint" + "a null value will remove the fingerprint" value: String } diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 075e40372..c7d880266 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -152,15 +152,15 @@ input PerformerFilterType { fake_tits: StringCriterionInput "Filter by penis length value" penis_length: FloatCriterionInput - "Filter by ciricumcision" + "Filter by circumcision" circumcised: CircumcisionCriterionInput "Deprecated: use career_start and career_end. This filter is non-functional." career_length: StringCriterionInput @deprecated(reason: "Use career_start and career_end") - "Filter by career start year" - career_start: IntCriterionInput - "Filter by career end year" - career_end: IntCriterionInput + "Filter by career start" + career_start: DateCriterionInput + "Filter by career end" + career_end: DateCriterionInput "Filter by tattoos" tattoos: StringCriterionInput "Filter by piercings" @@ -177,6 +177,8 @@ input PerformerFilterType { tag_count: IntCriterionInput "Filter by scene count" scene_count: IntCriterionInput + "Filter by marker count (via scene)" + marker_count: IntCriterionInput "Filter by image count" image_count: IntCriterionInput "Filter by gallery count" @@ -220,6 +222,8 @@ input PerformerFilterType { galleries_filter: GalleryFilterType "Filter by related tags that meet this criteria" tags_filter: TagFilterType + "Filter by related scene markers (via scene) that meet this criteria" + markers_filter: SceneMarkerFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" @@ -245,9 +249,9 @@ input SceneMarkerFilterType { updated_at: TimestampCriterionInput "Filter by scene date" scene_date: DateCriterionInput - "Filter by cscene reation time" + "Filter by scene creation time" scene_created_at: TimestampCriterionInput - "Filter by lscene ast update time" + "Filter by scene last update time" scene_updated_at: TimestampCriterionInput "Filter by related scenes that meet this criteria" scene_filter: SceneFilterType @@ -462,6 +466,9 @@ input GroupFilterType { scenes_filter: SceneFilterType "Filter by related studios that meet this criteria" studios_filter: StudioFilterType + + "Filter by custom fields" + custom_fields: [CustomFieldCriterionInput!] } input StudioFilterType { @@ -596,6 +603,10 @@ input GalleryFilterType { files_filter: FileFilterType "Filter by related folders that meet this criteria" folders_filter: FolderFilterType + "Filter by parent folder of the zip or folder the gallery is in" + parent_folder: HierarchicalMultiCriterionInput + + custom_fields: [CustomFieldCriterionInput!] } input TagFilterType { @@ -654,7 +665,7 @@ input TagFilterType { "Filter by number of parent tags the tag has" parent_count: IntCriterionInput - "Filter by number f child tags the tag has" + "Filter by number of child tags the tag has" child_count: IntCriterionInput "Filter by autotag ignore value" @@ -679,6 +690,8 @@ input TagFilterType { performers_filter: PerformerFilterType "Filter by related studios that meet this criteria" studios_filter: StudioFilterType + "Filter by related scene markers that meet this criteria" + markers_filter: SceneMarkerFilterType "Filter by creation time" created_at: TimestampCriterionInput @@ -760,6 +773,8 @@ input ImageFilterType { tags_filter: TagFilterType "Filter by related files that meet this criteria" files_filter: FileFilterType + "Filter by custom fields" + custom_fields: [CustomFieldCriterionInput!] } input FileFilterType { @@ -809,6 +824,7 @@ input FolderFilterType { NOT: FolderFilterType path: StringCriterionInput + basename: StringCriterionInput parent_folder: HierarchicalMultiCriterionInput zip_file: MultiCriterionInput @@ -917,7 +933,7 @@ input GenderCriterionInput { } input CircumcisionCriterionInput { - value: [CircumisedEnum!] + value: [CircumcisedEnum!] modifier: CriterionModifier! } diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index f456157a7..e28c3802b 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -32,6 +32,7 @@ type Gallery { cover: Image paths: GalleryPathsType! # Resolver + custom_fields: Map! image(index: Int!): Image! } @@ -50,6 +51,8 @@ input GalleryCreateInput { studio_id: ID tag_ids: [ID!] performer_ids: [ID!] + + custom_fields: Map } input GalleryUpdateInput { @@ -71,6 +74,8 @@ input GalleryUpdateInput { performer_ids: [ID!] primary_file_id: ID + + custom_fields: CustomFieldsInput } input BulkGalleryUpdateInput { @@ -89,6 +94,8 @@ input BulkGalleryUpdateInput { studio_id: ID tag_ids: BulkUpdateIds performer_ids: BulkUpdateIds + + custom_fields: CustomFieldsInput } input GalleryDestroyInput { diff --git a/graphql/schema/types/group.graphql b/graphql/schema/types/group.graphql index a46932054..8610f39dc 100644 --- a/graphql/schema/types/group.graphql +++ b/graphql/schema/types/group.graphql @@ -31,6 +31,7 @@ type Group { sub_group_count(depth: Int): Int! # Resolver scenes: [Scene!]! o_counter: Int # Resolver + custom_fields: Map! } input GroupDescriptionInput { @@ -59,6 +60,8 @@ input GroupCreateInput { front_image: String "This should be a URL or a base64 encoded data URL" back_image: String + + custom_fields: Map } input GroupUpdateInput { @@ -82,6 +85,8 @@ input GroupUpdateInput { front_image: String "This should be a URL or a base64 encoded data URL" back_image: String + + custom_fields: CustomFieldsInput } input BulkUpdateGroupDescriptionsInput { @@ -94,6 +99,8 @@ input BulkGroupUpdateInput { ids: [ID!] # rating expressed as 1-100 rating100: Int + date: String + synopsis: String studio_id: ID director: String urls: BulkUpdateStrings @@ -101,6 +108,8 @@ input BulkGroupUpdateInput { containing_groups: BulkUpdateGroupDescriptionsInput sub_groups: BulkUpdateGroupDescriptionsInput + + custom_fields: CustomFieldsInput } input GroupDestroyInput { diff --git a/graphql/schema/types/image.graphql b/graphql/schema/types/image.graphql index b7ec1a9f5..ccc414542 100644 --- a/graphql/schema/types/image.graphql +++ b/graphql/schema/types/image.graphql @@ -21,6 +21,7 @@ type Image { studio: Studio tags: [Tag!]! performers: [Performer!]! + custom_fields: Map! } type ImageFileType { @@ -56,6 +57,7 @@ input ImageUpdateInput { gallery_ids: [ID!] primary_file_id: ID + custom_fields: CustomFieldsInput } input BulkImageUpdateInput { @@ -76,6 +78,7 @@ input BulkImageUpdateInput { performer_ids: BulkUpdateIds tag_ids: BulkUpdateIds gallery_ids: BulkUpdateIds + custom_fields: CustomFieldsInput } input ImageDestroyInput { diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 27cbb86fb..6ad620dbe 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -26,6 +26,8 @@ input GenerateMetadataInput { imageIDs: [ID!] "gallery ids to generate for" galleryIDs: [ID!] + "paths to run generate on, in addition to the other ID lists" + paths: [String!] "overwrite existing media" overwrite: Boolean @@ -129,6 +131,14 @@ type ScanMetadataOptions { input CleanMetadataInput { paths: [String!] + """ + Don't check zip file contents when determining whether to clean a file. + This can significantly speed up the clean process, but will potentially miss removed files within zip files. + Where users do not modify zip files contents directly, this should be safe to use. + Defaults to false. + """ + ignoreZipFileContents: Boolean + "Do a dry run. Don't delete any files" dryRun: Boolean! } diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 97a80b94f..bf17298da 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -7,7 +7,7 @@ enum GenderEnum { NON_BINARY } -enum CircumisedEnum { +enum CircumcisedEnum { CUT UNCUT } @@ -29,10 +29,10 @@ type Performer { measurements: String fake_tits: String penis_length: Float - circumcised: CircumisedEnum + circumcised: CircumcisedEnum career_length: String @deprecated(reason: "Use career_start and career_end") - career_start: Int - career_end: Int + career_start: String + career_end: String tattoos: String piercings: String alias_list: [String!]! @@ -78,10 +78,10 @@ input PerformerCreateInput { measurements: String fake_tits: String penis_length: Float - circumcised: CircumisedEnum + circumcised: CircumcisedEnum career_length: String @deprecated(reason: "Use career_start and career_end") - career_start: Int - career_end: Int + career_start: String + career_end: String tattoos: String piercings: String "Duplicate aliases and those equal to name will be ignored (case-insensitive)" @@ -119,10 +119,10 @@ input PerformerUpdateInput { measurements: String fake_tits: String penis_length: Float - circumcised: CircumisedEnum + circumcised: CircumcisedEnum career_length: String @deprecated(reason: "Use career_start and career_end") - career_start: Int - career_end: Int + career_start: String + career_end: String tattoos: String piercings: String "Duplicate aliases and those equal to name will be ignored (case-insensitive)" @@ -165,10 +165,10 @@ input BulkPerformerUpdateInput { measurements: String fake_tits: String penis_length: Float - circumcised: CircumisedEnum + circumcised: CircumcisedEnum career_length: String @deprecated(reason: "Use career_start and career_end") - career_start: Int - career_end: Int + career_start: String + career_end: String tattoos: String piercings: String "Duplicate aliases and those equal to name will result in an error (case-insensitive)" diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index 0818e61c2..799b5cd6e 100644 --- a/graphql/schema/types/scraped-performer.graphql +++ b/graphql/schema/types/scraped-performer.graphql @@ -19,8 +19,8 @@ type ScrapedPerformer { penis_length: String circumcised: String career_length: String @deprecated(reason: "Use career_start and career_end") - career_start: Int - career_end: Int + career_start: String + career_end: String tattoos: String piercings: String # aliases must be comma-delimited to be parsed correctly @@ -57,8 +57,8 @@ input ScrapedPerformerInput { penis_length: String circumcised: String career_length: String @deprecated(reason: "Use career_start and career_end") - career_start: Int - career_end: Int + career_start: String + career_end: String tattoos: String piercings: String aliases: String diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 9c0e33fdf..fafd928f7 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -71,6 +71,9 @@ type ScrapedTag { "Set if tag matched" stored_id: ID name: String! + description: String + alias_list: [String!] + parent: ScrapedTag "Remote site ID, if applicable" remote_site_id: String } diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index e2686ac4d..ebaf05648 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -29,6 +29,13 @@ fragment StudioFragment on Studio { fragment TagFragment on Tag { name id + description + aliases + category { + id + name + description + } } fragment MeasurementsFragment on Measurements { diff --git a/internal/api/authentication.go b/internal/api/authentication.go index 6ad7117a1..be399d222 100644 --- a/internal/api/authentication.go +++ b/internal/api/authentication.go @@ -40,6 +40,8 @@ func authenticateHandler() func(http.Handler) http.Handler { return } + r = session.SetLocalRequest(r) + userID, err := manager.GetInstance().SessionStore.Authenticate(w, r) if err != nil { if !errors.Is(err, session.ErrUnauthorized) { diff --git a/internal/api/check_version.go b/internal/api/check_version.go index f4c2950f1..10cb2b47a 100644 --- a/internal/api/check_version.go +++ b/internal/api/check_version.go @@ -148,12 +148,12 @@ func makeGithubRequest(ctx context.Context, url string, output interface{}) erro response, err := client.Do(req) if err != nil { - //lint:ignore ST1005 Github is a proper capitalized noun + //nolint:staticcheck // ST1005 Github is a proper capitalized noun return fmt.Errorf("Github API request failed: %w", err) } if response.StatusCode != http.StatusOK { - //lint:ignore ST1005 Github is a proper capitalized noun + //nolint:staticcheck // ST1005 Github is a proper capitalized noun return fmt.Errorf("Github API request failed: %s", response.Status) } @@ -161,7 +161,7 @@ func makeGithubRequest(ctx context.Context, url string, output interface{}) erro data, err := io.ReadAll(response.Body) if err != nil { - //lint:ignore ST1005 Github is a proper capitalized noun + //nolint:staticcheck // ST1005 Github is a proper capitalized noun return fmt.Errorf("Github API read response failed: %w", err) } @@ -295,10 +295,10 @@ func printLatestVersion(ctx context.Context) { logger.Errorf("Couldn't retrieve latest version: %v", err) } else { _, githash, _ := build.Version() - switch { - case githash == "": + switch githash { + case "": logger.Infof("Latest version: %s (%s)", latestRelease.Version, latestRelease.ShortHash) - case githash == latestRelease.ShortHash: + case latestRelease.ShortHash: logger.Infof("Version %s (%s) is already the latest released", latestRelease.Version, latestRelease.ShortHash) default: logger.Infof("New version available: %s (%s)", latestRelease.Version, latestRelease.ShortHash) diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index 520714432..c1faf61ed 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -11,6 +11,7 @@ //go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group //go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File //go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder +//go:generate go run github.com/vektah/dataloaden FolderRelatedFolderIDsLoader github.com/stashapp/stash/pkg/models.FolderID []github.com/stashapp/stash/pkg/models.FolderID //go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID @@ -54,8 +55,10 @@ type Loaders struct { ImageFiles *ImageFileIDsLoader GalleryFiles *GalleryFileIDsLoader - GalleryByID *GalleryLoader - ImageByID *ImageLoader + GalleryByID *GalleryLoader + GalleryCustomFields *CustomFieldsLoader + ImageByID *ImageLoader + ImageCustomFields *CustomFieldsLoader PerformerByID *PerformerLoader PerformerCustomFields *CustomFieldsLoader @@ -65,9 +68,15 @@ type Loaders struct { TagByID *TagLoader TagCustomFields *CustomFieldsLoader - GroupByID *GroupLoader - FileByID *FileLoader - FolderByID *FolderLoader + + GroupByID *GroupLoader + GroupCustomFields *CustomFieldsLoader + + FileByID *FileLoader + + FolderByID *FolderLoader + FolderParentFolderIDs *FolderRelatedFolderIDsLoader + FolderSubFolderIDs *FolderRelatedFolderIDsLoader } type Middleware struct { @@ -88,11 +97,21 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchGalleries(ctx), }, + GalleryCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchGalleryCustomFields(ctx), + }, ImageByID: &ImageLoader{ wait: wait, maxBatch: maxBatch, fetch: m.fetchImages(ctx), }, + ImageCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchImageCustomFields(ctx), + }, PerformerByID: &PerformerLoader{ wait: wait, maxBatch: maxBatch, @@ -133,6 +152,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchGroups(ctx), }, + GroupCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchGroupCustomFields(ctx), + }, FileByID: &FileLoader{ wait: wait, maxBatch: maxBatch, @@ -143,6 +167,16 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchFolders(ctx), }, + FolderParentFolderIDs: &FolderRelatedFolderIDsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchFoldersParentFolderIDs(ctx), + }, + FolderSubFolderIDs: &FolderRelatedFolderIDsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchFoldersSubFolderIDs(ctx), + }, SceneFiles: &SceneFileIDsLoader{ wait: wait, maxBatch: maxBatch, @@ -237,6 +271,18 @@ func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models } } +func (m Middleware) fetchImageCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Image.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchGalleries(ctx context.Context) func(keys []int) ([]*models.Gallery, []error) { return func(keys []int) (ret []*models.Gallery, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { @@ -319,6 +365,30 @@ func (m Middleware) fetchTagCustomFields(ctx context.Context) func(keys []int) ( } } +func (m Middleware) fetchGroupCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Group.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + +func (m Middleware) fetchGalleryCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Gallery.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) { return func(keys []int) (ret []*models.Group, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { @@ -352,6 +422,28 @@ func (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderI } } +func (m Middleware) fetchFoldersParentFolderIDs(ctx context.Context) func(keys []models.FolderID) ([][]models.FolderID, []error) { + return func(keys []models.FolderID) (ret [][]models.FolderID, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Folder.GetManyParentFolderIDs(ctx, keys) + return err + }) + return ret, toErrorSlice(err) + } +} + +func (m Middleware) fetchFoldersSubFolderIDs(ctx context.Context) func(keys []models.FolderID) ([][]models.FolderID, []error) { + return func(keys []models.FolderID) (ret [][]models.FolderID, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Folder.GetManySubFolderIDs(ctx, keys) + return err + }) + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) { return func(keys []int) (ret [][]models.FileID, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/loaders/folderrelatedfolderidsloader_gen.go b/internal/api/loaders/folderrelatedfolderidsloader_gen.go new file mode 100644 index 000000000..d0edb92f4 --- /dev/null +++ b/internal/api/loaders/folderrelatedfolderidsloader_gen.go @@ -0,0 +1,225 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package loaders + +import ( + "sync" + "time" + + "github.com/stashapp/stash/pkg/models" +) + +// FolderParentFolderIDsLoaderConfig captures the config to create a new FolderParentFolderIDsLoader +type FolderParentFolderIDsLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []models.FolderID) ([][]models.FolderID, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// NewFolderParentFolderIDsLoader creates a new FolderParentFolderIDsLoader given a fetch, wait, and maxBatch +func NewFolderParentFolderIDsLoader(config FolderParentFolderIDsLoaderConfig) *FolderRelatedFolderIDsLoader { + return &FolderRelatedFolderIDsLoader{ + fetch: config.Fetch, + wait: config.Wait, + maxBatch: config.MaxBatch, + } +} + +// FolderRelatedFolderIDsLoader batches and caches requests +type FolderRelatedFolderIDsLoader struct { + // this method provides the data for the loader + fetch func(keys []models.FolderID) ([][]models.FolderID, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[models.FolderID][]models.FolderID + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *folderParentFolderIDsLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type folderParentFolderIDsLoaderBatch struct { + keys []models.FolderID + data [][]models.FolderID + error []error + closing bool + done chan struct{} +} + +// Load a FolderID by key, batching and caching will be applied automatically +func (l *FolderRelatedFolderIDsLoader) Load(key models.FolderID) ([]models.FolderID, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a FolderID. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *FolderRelatedFolderIDsLoader) LoadThunk(key models.FolderID) func() ([]models.FolderID, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() ([]models.FolderID, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &folderParentFolderIDsLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() ([]models.FolderID, error) { + <-batch.done + + var data []models.FolderID + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *FolderRelatedFolderIDsLoader) LoadAll(keys []models.FolderID) ([][]models.FolderID, []error) { + results := make([]func() ([]models.FolderID, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + folderIDs := make([][]models.FolderID, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + folderIDs[i], errors[i] = thunk() + } + return folderIDs, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a FolderIDs. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *FolderRelatedFolderIDsLoader) LoadAllThunk(keys []models.FolderID) func() ([][]models.FolderID, []error) { + results := make([]func() ([]models.FolderID, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([][]models.FolderID, []error) { + folderIDs := make([][]models.FolderID, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + folderIDs[i], errors[i] = thunk() + } + return folderIDs, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *FolderRelatedFolderIDsLoader) Prime(key models.FolderID, value []models.FolderID) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + // make a copy when writing to the cache, its easy to pass a pointer in from a loop var + // and end up with the whole cache pointing to the same value. + cpy := make([]models.FolderID, len(value)) + copy(cpy, value) + l.unsafeSet(key, cpy) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *FolderRelatedFolderIDsLoader) Clear(key models.FolderID) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *FolderRelatedFolderIDsLoader) unsafeSet(key models.FolderID, value []models.FolderID) { + if l.cache == nil { + l.cache = map[models.FolderID][]models.FolderID{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *folderParentFolderIDsLoaderBatch) keyIndex(l *FolderRelatedFolderIDsLoader, key models.FolderID) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *folderParentFolderIDsLoaderBatch) startTimer(l *FolderRelatedFolderIDsLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *folderParentFolderIDsLoaderBatch) end(l *FolderRelatedFolderIDsLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/internal/api/resolver.go b/internal/api/resolver.go index 061d0e1a9..b1cec1c9d 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" + "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/internal/build" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/logger" @@ -145,6 +146,13 @@ func (r *Resolver) withReadTxn(ctx context.Context, fn func(ctx context.Context) return r.repository.WithReadTxn(ctx, fn) } +// idOnly returns true if the query is only asking for the id field. +// This can be used to optimize certain queries where we don't need to load the full object if we're only getting the id. +func (r *Resolver) idOnly(ctx context.Context) bool { + fields := graphql.CollectAllFields(ctx) + return len(fields) == 1 && fields[0] == "id" +} + func (r *queryResolver) MarkerWall(ctx context.Context, q *string) (ret []*models.SceneMarker, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SceneMarker.Wall(ctx, q) diff --git a/internal/api/resolver_model_folder.go b/internal/api/resolver_model_folder.go index ee6bbfd05..725ca34f8 100644 --- a/internal/api/resolver_model_folder.go +++ b/internal/api/resolver_model_folder.go @@ -2,19 +2,77 @@ package api import ( "context" + "path/filepath" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/pkg/models" ) +func (r *folderResolver) Basename(ctx context.Context, obj *models.Folder) (string, error) { + return filepath.Base(obj.Path), nil +} + func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (*models.Folder, error) { if obj.ParentFolderID == nil { return nil, nil } + if r.idOnly(ctx) { + return &models.Folder{ID: *obj.ParentFolderID}, nil + } + return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID) } +func foldersFromIDs(ids []models.FolderID) []*models.Folder { + ret := make([]*models.Folder, len(ids)) + for i, id := range ids { + ret[i] = &models.Folder{ID: id} + } + return ret +} + +func (r *folderResolver) ParentFolders(ctx context.Context, obj *models.Folder) ([]*models.Folder, error) { + ids, err := loaders.From(ctx).FolderParentFolderIDs.Load(obj.ID) + if err != nil { + return nil, err + } + + if r.idOnly(ctx) { + return foldersFromIDs(ids), nil + } + + var errs []error + ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids) + return ret, firstError(errs) +} + +func (r *folderResolver) SubFolders(ctx context.Context, obj *models.Folder) ([]*models.Folder, error) { + ids, err := loaders.From(ctx).FolderSubFolderIDs.Load(obj.ID) + if err != nil { + return nil, err + } + + if r.idOnly(ctx) { + return foldersFromIDs(ids), nil + } + + var errs []error + ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids) + return ret, firstError(errs) +} + func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) { + // shortcut for id only queries + if r.idOnly(ctx) { + if obj.ZipFileID == nil { + return nil, nil + } + + return &BasicFile{ + BaseFile: &models.BaseFile{ID: *obj.ZipFileID}, + }, nil + } + return zipFileResolver(ctx, obj.ZipFileID) } diff --git a/internal/api/resolver_model_gallery.go b/internal/api/resolver_model_gallery.go index 9dc68b4c4..773a831d8 100644 --- a/internal/api/resolver_model_gallery.go +++ b/internal/api/resolver_model_gallery.go @@ -216,3 +216,16 @@ func (r *galleryResolver) Image(ctx context.Context, obj *models.Gallery, index return } + +func (r *galleryResolver) CustomFields(ctx context.Context, obj *models.Gallery) (map[string]interface{}, error) { + m, err := loaders.From(ctx).GalleryCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + if m == nil { + return make(map[string]interface{}), nil + } + + return m, nil +} diff --git a/internal/api/resolver_model_image.go b/internal/api/resolver_model_image.go index 0886bea40..4a95ae1f4 100644 --- a/internal/api/resolver_model_image.go +++ b/internal/api/resolver_model_image.go @@ -161,3 +161,12 @@ func (r *imageResolver) Urls(ctx context.Context, obj *models.Image) ([]string, return obj.URLs.List(), nil } + +func (r *imageResolver) CustomFields(ctx context.Context, obj *models.Image) (map[string]interface{}, error) { + customFields, err := loaders.From(ctx).ImageCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + return customFields, nil +} diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index 317123c6e..287d5d51a 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -215,3 +215,16 @@ func (r *groupResolver) OCounter(ctx context.Context, obj *models.Group) (ret *i } return &count, nil } + +func (r *groupResolver) CustomFields(ctx context.Context, obj *models.Group) (map[string]interface{}, error) { + m, err := loaders.From(ctx).GroupCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + if m == nil { + return make(map[string]interface{}), nil + } + + return m, nil +} diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index b770f5801..261a98ff3 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -10,7 +10,6 @@ import ( "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/performer" - "github.com/stashapp/stash/pkg/utils" ) func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer) ([]string, error) { @@ -110,12 +109,28 @@ func (r *performerResolver) HeightCm(ctx context.Context, obj *models.Performer) return obj.Height, nil } +func (r *performerResolver) CareerStart(ctx context.Context, obj *models.Performer) (*string, error) { + if obj.CareerStart != nil { + ret := obj.CareerStart.String() + return &ret, nil + } + return nil, nil +} + +func (r *performerResolver) CareerEnd(ctx context.Context, obj *models.Performer) (*string, error) { + if obj.CareerEnd != nil { + ret := obj.CareerEnd.String() + return &ret, nil + } + return nil, nil +} + func (r *performerResolver) CareerLength(ctx context.Context, obj *models.Performer) (*string, error) { if obj.CareerStart == nil && obj.CareerEnd == nil { return nil, nil } - ret := utils.FormatYearRange(obj.CareerStart, obj.CareerEnd) + ret := models.FormatYearRange(obj.CareerStart, obj.CareerEnd) return &ret, nil } diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index 81113d858..ecb163765 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -114,7 +114,7 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePat objHash := obj.GetHash(config.GetVideoFileNamingAlgorithm()) vttPath := builder.GetSpriteVTTURL(objHash) spritePath := builder.GetSpriteURL(objHash) - funscriptPath := builder.GetFunscriptURL() + funscriptPath := builder.GetFunscriptURL(config.GetAPIKey()).String() captionBasePath := builder.GetCaptionURL() interactiveHeatmap := builder.GetInteractiveHeatmapURL() diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 718d24998..3df1c9114 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "path/filepath" "regexp" "strconv" @@ -85,6 +86,8 @@ func (r *mutationResolver) setConfigFloat(key string, value *float64) { func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGeneralInput) (*ConfigGeneralResult, error) { c := config.GetInstance() + // #4709 - allow stash paths even if they do not exist, so that users may configure stash + // for disconnected drives or network storage. existingPaths := c.GetStashPaths() if input.Stashes != nil { for _, s := range input.Stashes { @@ -97,8 +100,12 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen } } if isNew { + s.Path = filepath.Clean(s.Path) + + // if it exists, it must be directory exists, err := fsutil.DirExists(s.Path) - if !exists { + // allow it to not exist but if it does exist it must be a directory + if !exists && !errors.Is(err, fs.ErrNotExist) { return makeConfigGeneralResult(), err } } diff --git a/internal/api/resolver_mutation_file.go b/internal/api/resolver_mutation_file.go index afbefe554..b9e36aa76 100644 --- a/internal/api/resolver_mutation_file.go +++ b/internal/api/resolver_mutation_file.go @@ -5,10 +5,14 @@ import ( "fmt" "strconv" + "github.com/stashapp/stash/internal/desktop" "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) @@ -16,7 +20,7 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput) if err := r.withTxn(ctx, func(ctx context.Context) error { fileStore := r.repository.File folderStore := r.repository.Folder - mover := file.NewMover(fileStore, folderStore) + mover := file.NewMover(fileStore, folderStore, manager.GetInstance().Config.GetStashPaths().Paths()) mover.RegisterHooks(ctx) var ( @@ -54,13 +58,14 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput) folderPath := *input.DestinationFolder // ensure folder path is within the library - if err := r.validateFolderPath(folderPath); err != nil { + stashPaths := manager.GetInstance().Config.GetStashPaths() + if err := r.validateFolderPath(stashPaths, folderPath); err != nil { return err } // get or create folder hierarchy var err error - folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath) + folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath, stashPaths.Paths()) if err != nil { return fmt.Errorf("getting or creating folder hierarchy: %w", err) } @@ -109,8 +114,7 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput) return true, nil } -func (r *mutationResolver) validateFolderPath(folderPath string) error { - paths := manager.GetInstance().Config.GetStashPaths() +func (r *mutationResolver) validateFolderPath(paths config.StashConfigs, folderPath string) error { if l := paths.GetStashFromDirPath(folderPath); l == nil { return fmt.Errorf("folder path %s must be within a stash library path", folderPath) } @@ -326,3 +330,71 @@ func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSe return true, nil } + +func (r *mutationResolver) RevealFileInFileManager(ctx context.Context, id string) (bool, error) { + // disallow if request did not come from localhost + if !session.IsLocalRequest(ctx) { + logger.Warnf("Attempt to reveal file in file manager from non-local request") + return false, fmt.Errorf("access denied") + } + + fileIDInt, err := strconv.Atoi(id) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + var filePath string + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + files, err := r.repository.File.Find(ctx, models.FileID(fileIDInt)) + if err != nil { + return fmt.Errorf("finding file: %w", err) + } + if len(files) == 0 { + return fmt.Errorf("file with id %d not found", fileIDInt) + } + filePath = files[0].Base().Path + return nil + }); err != nil { + return false, err + } + + if err := desktop.RevealInFileManager(filePath); err != nil { + return false, err + } + + return true, nil +} + +func (r *mutationResolver) RevealFolderInFileManager(ctx context.Context, id string) (bool, error) { + // disallow if request did not come from localhost + if !session.IsLocalRequest(ctx) { + logger.Warnf("Attempt to reveal folder in file manager from non-local request") + return false, fmt.Errorf("access denied") + } + + folderIDInt, err := strconv.Atoi(id) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + var folderPath string + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + folder, err := r.repository.Folder.Find(ctx, models.FolderID(folderIDInt)) + if err != nil { + return fmt.Errorf("finding folder: %w", err) + } + if folder == nil { + return fmt.Errorf("folder with id %d not found", folderIDInt) + } + folderPath = folder.Path + return nil + }); err != nil { + return false, err + } + + if err := desktop.RevealInFileManager(folderPath); err != nil { + return false, err + } + + return true, nil +} diff --git a/internal/api/resolver_mutation_gallery.go b/internal/api/resolver_mutation_gallery.go index e7f853922..2cd80b1ff 100644 --- a/internal/api/resolver_mutation_gallery.go +++ b/internal/api/resolver_mutation_gallery.go @@ -42,7 +42,10 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat } // Populate a new gallery from the input - newGallery := models.NewGallery() + newGallery := models.CreateGalleryInput{ + Gallery: &models.Gallery{}, + } + *newGallery.Gallery = models.NewGallery() newGallery.Title = strings.TrimSpace(input.Title) newGallery.Code = translator.string(input.Code) @@ -81,10 +84,12 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat newGallery.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)}) } + newGallery.CustomFields = convertMapJSONNumbers(input.CustomFields) + // Start the transaction and save the gallery if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Gallery - if err := qb.Create(ctx, &newGallery, nil); err != nil { + if err := qb.Create(ctx, &newGallery); err != nil { return err } @@ -241,6 +246,10 @@ func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.Galle return nil, fmt.Errorf("converting scene ids: %w", err) } + if input.CustomFields != nil { + updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields) + } + // gallery scene is set from the scene only gallery, err := qb.UpdatePartial(ctx, galleryID, updatedGallery) @@ -293,6 +302,10 @@ func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input BulkGall return nil, fmt.Errorf("converting scene ids: %w", err) } + if input.CustomFields != nil { + updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields) + } + ret := []*models.Gallery{} // Start the transaction and save the galleries diff --git a/internal/api/resolver_mutation_group.go b/internal/api/resolver_mutation_group.go index 14dc817b9..6c986c4da 100644 --- a/internal/api/resolver_mutation_group.go +++ b/internal/api/resolver_mutation_group.go @@ -14,13 +14,17 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.Group, error) { +func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.CreateGroupInput, error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } // Populate a new group from the input - newGroup := models.NewGroup() + newGroupInput := &models.CreateGroupInput{ + Group: &models.Group{}, + } + *newGroupInput.Group = models.NewGroup() + newGroup := newGroupInput.Group newGroup.Name = strings.TrimSpace(input.Name) newGroup.Aliases = translator.string(input.Aliases) @@ -59,28 +63,19 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls)) } - return &newGroup, nil -} - -func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Group, error) { - newGroup, err := groupFromGroupCreateInput(ctx, input) - if err != nil { - return nil, err - } + newGroupInput.CustomFields = convertMapJSONNumbers(input.CustomFields) // Process the base 64 encoded image string - var frontimageData []byte if input.FrontImage != nil { - frontimageData, err = utils.ProcessImageInput(ctx, *input.FrontImage) + newGroupInput.FrontImageData, err = utils.ProcessImageInput(ctx, *input.FrontImage) if err != nil { return nil, fmt.Errorf("processing front image: %w", err) } } // Process the base 64 encoded image string - var backimageData []byte if input.BackImage != nil { - backimageData, err = utils.ProcessImageInput(ctx, *input.BackImage) + newGroupInput.BackImageData, err = utils.ProcessImageInput(ctx, *input.BackImage) if err != nil { return nil, fmt.Errorf("processing back image: %w", err) } @@ -88,13 +83,22 @@ func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInp // HACK: if back image is being set, set the front image to the default. // This is because we can't have a null front image with a non-null back image. - if len(frontimageData) == 0 && len(backimageData) != 0 { - frontimageData = static.ReadAll(static.DefaultGroupImage) + if len(newGroupInput.FrontImageData) == 0 && len(newGroupInput.BackImageData) != 0 { + newGroupInput.FrontImageData = static.ReadAll(static.DefaultGroupImage) + } + + return newGroupInput, nil +} + +func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Group, error) { + createGroupInput, err := groupFromGroupCreateInput(ctx, input) + if err != nil { + return nil, err } // Start the transaction and save the group if err := r.withTxn(ctx, func(ctx context.Context) error { - if err = r.groupService.Create(ctx, newGroup, frontimageData, backimageData); err != nil { + if err = r.groupService.Create(ctx, createGroupInput); err != nil { return err } @@ -104,9 +108,9 @@ func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInp } // for backwards compatibility - run both movie and group hooks - r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.GroupCreatePost, input, nil) - r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.MovieCreatePost, input, nil) - return r.getGroup(ctx, newGroup.ID) + r.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.GroupCreatePost, input, nil) + r.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.MovieCreatePost, input, nil) + return r.getGroup(ctx, createGroupInput.Group.ID) } func groupPartialFromGroupUpdateInput(translator changesetTranslator, input GroupUpdateInput) (ret models.GroupPartial, err error) { @@ -150,6 +154,12 @@ func groupPartialFromGroupUpdateInput(translator changesetTranslator, input Grou } updatedGroup.URLs = translator.updateStrings(input.Urls, "urls") + if input.CustomFields != nil { + updatedGroup.CustomFields = *input.CustomFields + // convert json.Numbers to int/float + updatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full) + updatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial) + } return updatedGroup, nil } @@ -217,6 +227,12 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) { updatedGroup := models.NewGroupPartial() + updatedGroup.Date, err = translator.optionalDate(input.Date, "date") + if err != nil { + err = fmt.Errorf("converting date: %w", err) + return + } + updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis") updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100") updatedGroup.Director = translator.optionalString(input.Director, "director") @@ -246,6 +262,13 @@ func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil) + if input.CustomFields != nil { + updatedGroup.CustomFields = *input.CustomFields + // convert json.Numbers to int/float + updatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full) + updatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial) + } + return updatedGroup, nil } diff --git a/internal/api/resolver_mutation_image.go b/internal/api/resolver_mutation_image.go index 230d48358..cc03c5286 100644 --- a/internal/api/resolver_mutation_image.go +++ b/internal/api/resolver_mutation_image.go @@ -177,6 +177,13 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input models.ImageUp return nil, fmt.Errorf("converting tag ids: %w", err) } + if input.CustomFields != nil { + updatedImage.CustomFields = *input.CustomFields + // convert json.Numbers to int/float + updatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full) + updatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial) + } + qb := r.repository.Image image, err := qb.UpdatePartial(ctx, imageID, updatedImage) if err != nil { @@ -237,6 +244,13 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU return nil, fmt.Errorf("converting tag ids: %w", err) } + if input.CustomFields != nil { + updatedImage.CustomFields = *input.CustomFields + // convert json.Numbers to int/float + updatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full) + updatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial) + } + // Start the transaction and save the images if err := r.withTxn(ctx, func(ctx context.Context) error { var updatedGalleryIDs []int diff --git a/internal/api/resolver_mutation_migrate.go b/internal/api/resolver_mutation_migrate.go index 083d307e9..b739be1e0 100644 --- a/internal/api/resolver_mutation_migrate.go +++ b/internal/api/resolver_mutation_migrate.go @@ -47,6 +47,10 @@ func (r *mutationResolver) Migrate(ctx context.Context, input manager.MigrateInp Database: mgr.Database, } + if err := t.PreExecute(); err != nil { + return "", err + } + jobID := mgr.JobManager.Add(ctx, "Migrating database...", t) return strconv.Itoa(jobID), nil diff --git a/internal/api/resolver_mutation_package.go b/internal/api/resolver_mutation_package.go index 8e36e6719..e4a24ba37 100644 --- a/internal/api/resolver_mutation_package.go +++ b/internal/api/resolver_mutation_package.go @@ -12,9 +12,10 @@ import ( func refreshPackageType(typeArg PackageType) { mgr := manager.GetInstance() - if typeArg == PackageTypePlugin { + switch typeArg { + case PackageTypePlugin: mgr.RefreshPluginCache() - } else if typeArg == PackageTypeScraper { + case PackageTypeScraper: mgr.RefreshScraperCache() } } diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 653348304..59e518675 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -52,17 +52,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per newPerformer.FakeTits = translator.string(input.FakeTits) newPerformer.PenisLength = input.PenisLength newPerformer.Circumcised = input.Circumcised - newPerformer.CareerStart = input.CareerStart - newPerformer.CareerEnd = input.CareerEnd - // if career_start/career_end not provided, parse deprecated career_length - if newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil { - start, end, err := utils.ParseYearRangeString(*input.CareerLength) - if err != nil { - return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err) - } - newPerformer.CareerStart = start - newPerformer.CareerEnd = end - } newPerformer.Tattoos = translator.string(input.Tattoos) newPerformer.Piercings = translator.string(input.Piercings) newPerformer.Favorite = translator.bool(input.Favorite) @@ -100,6 +89,25 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per return nil, fmt.Errorf("converting death date: %w", err) } + newPerformer.CareerStart, err = translator.datePtr(input.CareerStart) + if err != nil { + return nil, fmt.Errorf("converting career start: %w", err) + } + newPerformer.CareerEnd, err = translator.datePtr(input.CareerEnd) + if err != nil { + return nil, fmt.Errorf("converting career end: %w", err) + } + + // if career_start/career_end not provided, parse deprecated career_length + if newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil { + start, end, err := models.ParseYearRangeString(*input.CareerLength) + if err != nil { + return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err) + } + newPerformer.CareerStart = start + newPerformer.CareerEnd = end + } + newPerformer.TagIDs, err = translator.relatedIds(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) @@ -273,18 +281,25 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") // prefer career_start/career_end over deprecated career_length if translator.hasField("career_start") || translator.hasField("career_end") { - updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start") - updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end") + var err error + updatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, "career_start") + if err != nil { + return nil, fmt.Errorf("converting career start: %w", err) + } + updatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, "career_end") + if err != nil { + return nil, fmt.Errorf("converting career end: %w", err) + } } else if translator.hasField("career_length") && input.CareerLength != nil { - start, end, err := utils.ParseYearRangeString(*input.CareerLength) + start, end, err := models.ParseYearRangeString(*input.CareerLength) if err != nil { return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err) } if start != nil { - updatedPerformer.CareerStart = models.NewOptionalInt(*start) + updatedPerformer.CareerStart = models.NewOptionalDate(*start) } if end != nil { - updatedPerformer.CareerEnd = models.NewOptionalInt(*end) + updatedPerformer.CareerEnd = models.NewOptionalDate(*end) } } updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") @@ -444,18 +459,24 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") // prefer career_start/career_end over deprecated career_length if translator.hasField("career_start") || translator.hasField("career_end") { - updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start") - updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end") + updatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, "career_start") + if err != nil { + return nil, fmt.Errorf("converting career start: %w", err) + } + updatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, "career_end") + if err != nil { + return nil, fmt.Errorf("converting career end: %w", err) + } } else if translator.hasField("career_length") && input.CareerLength != nil { - start, end, err := utils.ParseYearRangeString(*input.CareerLength) + start, end, err := models.ParseYearRangeString(*input.CareerLength) if err != nil { return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err) } if start != nil { - updatedPerformer.CareerStart = models.NewOptionalInt(*start) + updatedPerformer.CareerStart = models.NewOptionalDate(*start) } if end != nil { - updatedPerformer.CareerEnd = models.NewOptionalInt(*end) + updatedPerformer.CareerEnd = models.NewOptionalDate(*end) } } updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") @@ -633,7 +654,7 @@ func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMe } legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator) if legacyURLs.AnySet() { - return nil, errors.New("Merging legacy performer URLs is not supported") + return nil, errors.New("merging legacy performer URLs is not supported") } if input.Values.Image != nil { diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index 436937511..6d2ab84fd 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -58,6 +58,16 @@ func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input man return strconv.Itoa(jobID), nil } +func (r *mutationResolver) StashBoxBatchTagTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { + b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck + if err != nil { + return "", err + } + + jobID := manager.GetInstance().StashBoxBatchTagTag(ctx, b, input) + return strconv.Itoa(jobID), nil +} + func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) { b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) if err != nil { diff --git a/internal/api/resolver_query_job.go b/internal/api/resolver_query_job.go index 0e1222445..44b6b15c4 100644 --- a/internal/api/resolver_query_job.go +++ b/internal/api/resolver_query_job.go @@ -33,15 +33,26 @@ func (r *queryResolver) FindJob(ctx context.Context, input FindJobInput) (*Job, } func jobToJobModel(j job.Job) *Job { + subTasks := make([]string, len(j.Details)) + for i, t := range j.Details { + subTasks[i] = sanitiseWebsocketString(t) + } + + var jobError *string + if j.Error != nil { + s := sanitiseWebsocketString(*j.Error) + jobError = &s + } + ret := &Job{ ID: strconv.Itoa(j.ID), Status: JobStatus(j.Status), - Description: j.Description, - SubTasks: j.Details, + Description: sanitiseWebsocketString(j.Description), + SubTasks: subTasks, StartTime: j.StartTime, EndTime: j.EndTime, AddTime: j.AddTime, - Error: j.Error, + Error: jobError, } if j.Progress != -1 { diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 86d449921..353bb1a32 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -6,6 +6,7 @@ import ( "fmt" "slices" "strconv" + "strings" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" @@ -363,7 +364,8 @@ func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Sour client := r.newStashBoxClient(*b) var ret []*models.ScrapedTag - out, err := client.QueryTag(ctx, *input.Query) + query := *input.Query + out, err := client.QueryTag(ctx, query) if err != nil { return nil, err @@ -383,6 +385,22 @@ func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Sour }); err != nil { return nil, err } + + // tag name query returns results that may not match the query exactly. + // if there is an exact match, it should be first + if query != "" { + for i, result := range ret { + if strings.EqualFold(result.Name, query) { + // prepend exact match to the front of the slice + if i != 0 { + ret = append([]*models.ScrapedTag{result}, append(ret[:i], ret[i+1:]...)...) + } + + break + } + } + } + return ret, nil } diff --git a/internal/api/resolver_subscription_logging.go b/internal/api/resolver_subscription_logging.go index 423fa88af..b4acb534c 100644 --- a/internal/api/resolver_subscription_logging.go +++ b/internal/api/resolver_subscription_logging.go @@ -2,11 +2,19 @@ package api import ( "context" + "strings" "github.com/stashapp/stash/internal/log" "github.com/stashapp/stash/internal/manager" ) +// sanitiseWebsocketString is used to ensure that any strings sent over the websocket are valid UTF-8. +// Any invalid UTF-8 sequences will be replaced with the Unicode replacement character (U+FFFD). +// Invalid UTF-8 sequences can cause the websocket connection to be closed. +func sanitiseWebsocketString(s string) string { + return strings.ToValidUTF8(s, "\uFFFD") +} + func getLogLevel(logType string) LogLevel { switch logType { case "progress": @@ -33,7 +41,7 @@ func logEntriesFromLogItems(logItems []log.LogItem) []*LogEntry { ret[i] = &LogEntry{ Time: entry.Time, Level: getLogLevel(entry.Type), - Message: entry.Message, + Message: sanitiseWebsocketString(entry.Message), } } diff --git a/internal/api/urlbuilders/scene.go b/internal/api/urlbuilders/scene.go index 10c4f347c..72a461519 100644 --- a/internal/api/urlbuilders/scene.go +++ b/internal/api/urlbuilders/scene.go @@ -57,8 +57,20 @@ func (b SceneURLBuilder) GetScreenshotURL() string { return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?t=" + b.UpdatedAt } -func (b SceneURLBuilder) GetFunscriptURL() string { - return b.BaseURL + "/scene/" + b.SceneID + "/funscript" +func (b SceneURLBuilder) GetFunscriptURL(apiKey string) *url.URL { + u, err := url.Parse(fmt.Sprintf("%s/scene/%s/funscript", b.BaseURL, b.SceneID)) + if err != nil { + // shouldn't happen + panic(err) + } + + if apiKey != "" { + v := u.Query() + v.Set("apikey", apiKey) + u.RawQuery = v.Encode() + } + + return u } func (b SceneURLBuilder) GetCaptionURL() string { diff --git a/internal/autotag/integration_test.go b/internal/autotag/integration_test.go index 27cce014e..f537ecfe7 100644 --- a/internal/autotag/integration_test.go +++ b/internal/autotag/integration_test.go @@ -365,7 +365,10 @@ func makeImage(expectedResult bool) *models.Image { } func createImage(ctx context.Context, w models.ImageWriter, o *models.Image, f *models.ImageFile) error { - err := w.Create(ctx, o, []models.FileID{f.ID}) + err := w.Create(ctx, &models.CreateImageInput{ + Image: o, + FileIDs: []models.FileID{f.ID}, + }) if err != nil { return fmt.Errorf("Failed to create image with path '%s': %s", f.Path, err.Error()) @@ -468,7 +471,10 @@ func makeGallery(expectedResult bool) *models.Gallery { } func createGallery(ctx context.Context, w models.GalleryWriter, o *models.Gallery, f *models.BaseFile) error { - err := w.Create(ctx, o, []models.FileID{f.ID}) + err := w.Create(ctx, &models.CreateGalleryInput{ + Gallery: o, + FileIDs: []models.FileID{f.ID}, + }) if err != nil { return fmt.Errorf("Failed to create gallery with path '%s': %s", f.Path, err.Error()) } diff --git a/internal/desktop/desktop.go b/internal/desktop/desktop.go index 06d400793..f1ca9bc92 100644 --- a/internal/desktop/desktop.go +++ b/internal/desktop/desktop.go @@ -2,6 +2,7 @@ package desktop import ( + "fmt" "os" "path" "path/filepath" @@ -155,15 +156,17 @@ func getIconPath() string { return path.Join(config.GetInstance().GetConfigPath(), "icon.png") } -func RevealInFileManager(path string) { - exists, err := fsutil.FileExists(path) +func RevealInFileManager(path string) error { + info, err := os.Stat(path) if err != nil { - logger.Errorf("Error checking file: %s", err) - return + return fmt.Errorf("error checking path: %w", err) } - if exists && IsDesktop() { - revealInFileManager(path) + + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("error getting absolute path: %w", err) } + return revealInFileManager(absPath, info) } func getServerURL(path string) string { diff --git a/internal/desktop/desktop_platform_darwin.go b/internal/desktop/desktop_platform_darwin.go index 593e9516f..560cc1893 100644 --- a/internal/desktop/desktop_platform_darwin.go +++ b/internal/desktop/desktop_platform_darwin.go @@ -4,9 +4,11 @@ package desktop import ( + "fmt" + "os" "os/exec" - "github.com/kermieisinthehouse/gosx-notifier" + gosxnotifier "github.com/feederbox826/gosx-notifier" "github.com/stashapp/stash/pkg/logger" ) @@ -32,8 +34,11 @@ func sendNotification(notificationTitle string, notificationText string) { } } -func revealInFileManager(path string) { - exec.Command(`open`, `-R`, path) +func revealInFileManager(path string, _ os.FileInfo) error { + if err := exec.Command(`open`, `-R`, path).Run(); err != nil { + return fmt.Errorf("error revealing path in Finder: %w", err) + } + return nil } func isDoubleClickLaunched() bool { diff --git a/internal/desktop/desktop_platform_nixes.go b/internal/desktop/desktop_platform_nixes.go index 69c780d3c..f5ab13384 100644 --- a/internal/desktop/desktop_platform_nixes.go +++ b/internal/desktop/desktop_platform_nixes.go @@ -4,8 +4,10 @@ package desktop import ( + "fmt" "os" "os/exec" + "path/filepath" "strings" "github.com/stashapp/stash/pkg/logger" @@ -33,8 +35,15 @@ func sendNotification(notificationTitle string, notificationText string) { } } -func revealInFileManager(path string) { - +func revealInFileManager(path string, info os.FileInfo) error { + dir := path + if !info.IsDir() { + dir = filepath.Dir(path) + } + if err := exec.Command("xdg-open", dir).Run(); err != nil { + return fmt.Errorf("error opening directory in file manager: %w", err) + } + return nil } func isDoubleClickLaunched() bool { diff --git a/internal/desktop/desktop_platform_windows.go b/internal/desktop/desktop_platform_windows.go index ecb4060e6..48feabed5 100644 --- a/internal/desktop/desktop_platform_windows.go +++ b/internal/desktop/desktop_platform_windows.go @@ -4,6 +4,7 @@ package desktop import ( + "os" "os/exec" "syscall" "unsafe" @@ -83,6 +84,10 @@ func sendNotification(notificationTitle string, notificationText string) { } } -func revealInFileManager(path string) { - exec.Command(`explorer`, `\select`, path) +func revealInFileManager(path string, _ os.FileInfo) error { + c := exec.Command(`explorer`, `/select,`, path) + logger.Debugf("Running: %s", c.String()) + // explorer seems to return an error code even when it works, so ignore the error + _ = c.Run() + return nil } diff --git a/internal/identify/options.go b/internal/identify/options.go index 9e27a3e39..181bf4612 100644 --- a/internal/identify/options.go +++ b/internal/identify/options.go @@ -33,8 +33,10 @@ type MetadataOptions struct { SetCoverImage *bool `json:"setCoverImage"` SetOrganized *bool `json:"setOrganized"` // defaults to true if not provided + // Deprecated: use PerformerGenders instead IncludeMalePerformers *bool `json:"includeMalePerformers"` + // Filter to only include performers with these genders. If not provided, all genders are included. PerformerGenders []models.GenderEnum `json:"performerGenders"` // defaults to true if not provided diff --git a/internal/manager/checksum.go b/internal/manager/checksum.go index cbe9d85d8..86f1b8708 100644 --- a/internal/manager/checksum.go +++ b/internal/manager/checksum.go @@ -22,7 +22,8 @@ type SceneMissingHashCounter interface { // will ensure that all oshash values are set on all scenes. func ValidateVideoFileNamingAlgorithm(ctx context.Context, qb SceneMissingHashCounter, newValue models.HashAlgorithm) error { // if algorithm is being set to MD5, then all checksums must be present - if newValue == models.HashAlgorithmMd5 { + switch newValue { + case models.HashAlgorithmMd5: missingMD5, err := qb.CountMissingChecksum(ctx) if err != nil { return err @@ -31,7 +32,7 @@ func ValidateVideoFileNamingAlgorithm(ctx context.Context, qb SceneMissingHashCo if missingMD5 > 0 { return errors.New("some checksums are missing on scenes. Run Scan with calculateMD5 set to true") } - } else if newValue == models.HashAlgorithmOshash { + case models.HashAlgorithmOshash: missingOSHash, err := qb.CountMissingOSHash(ctx) if err != nil { return err diff --git a/internal/manager/config/stash_config.go b/internal/manager/config/stash_config.go index 4a2cc7d60..7a103631c 100644 --- a/internal/manager/config/stash_config.go +++ b/internal/manager/config/stash_config.go @@ -38,3 +38,12 @@ func (s StashConfigs) GetStashFromDirPath(dirPath string) *StashConfig { } return nil } + +func (s StashConfigs) Paths() []string { + paths := make([]string, len(s)) + for i, c := range s { + // #6618 - clean the path to ensure comparison works correctly + paths[i] = filepath.Clean(c.Path) + } + return paths +} diff --git a/internal/manager/generator_interactive_heatmap_speed.go b/internal/manager/generator_interactive_heatmap_speed.go index d10ce5b19..aa0ee0e38 100644 --- a/internal/manager/generator_interactive_heatmap_speed.go +++ b/internal/manager/generator_interactive_heatmap_speed.go @@ -408,7 +408,7 @@ func ConvertFunscriptToCSV(funscriptPath string) ([]byte, error) { } // I don't know whether the csv format requires int or float, so for now we'll use int - buffer.WriteString(fmt.Sprintf("%d,%d\r\n", int(math.Round(action.At)), pos)) + fmt.Fprintf(&buffer, "%d,%d\r\n", int(math.Round(action.At)), pos) } return buffer.Bytes(), nil } diff --git a/internal/manager/import.go b/internal/manager/import.go index f9fb57c8f..5168ad99c 100644 --- a/internal/manager/import.go +++ b/internal/manager/import.go @@ -76,9 +76,10 @@ func performImport(ctx context.Context, i importer, duplicateBehaviour ImportDup var id int if existing != nil { - if duplicateBehaviour == ImportDuplicateEnumFail { + switch duplicateBehaviour { + case ImportDuplicateEnumFail: return fmt.Errorf("existing object with name '%s'", name) - } else if duplicateBehaviour == ImportDuplicateEnumIgnore { + case ImportDuplicateEnumIgnore: logger.Infof("Skipping existing object %q", name) return nil } diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index bac726c1b..76938e9ff 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -74,6 +74,28 @@ func getScanPaths(inputPaths []string) []*config.StashConfig { return ret } +// Filters the input array for paths that are within the paths managed by stash +func filterStashPaths(inputPaths []string) []string { + if len(inputPaths) == 0 { + return inputPaths + } + + stashPaths := config.GetInstance().GetStashPaths() + + var ret []string + for _, p := range inputPaths { + s := stashPaths.GetStashFromDirPath(p) + if s == nil { + logger.Warnf("%s is not in the configured stash paths", p) + continue + } + + ret = append(ret, p) + } + + return ret +} + // ScanSubscribe subscribes to a notification that is triggered when a // scan or clean is complete. func (s *Manager) ScanSubscribe(ctx context.Context) <-chan bool { @@ -123,7 +145,8 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error ZipFileExtensions: cfg.GetGalleryExtensions(), // ScanFilters is set in ScanJob.Execute // HandlerRequiredFilters is set in ScanJob.Execute - Rescan: input.Rescan, + RootPaths: cfg.GetStashPaths().Paths(), + Rescan: input.Rescan, } scanJob := ScanJob{ @@ -291,6 +314,8 @@ type CleanMetadataInput struct { Paths []string `json:"paths"` // Do a dry run. Don't delete any files DryRun bool `json:"dryRun"` + + IgnoreZipFileContents bool `json:"ignoreZipFileContents"` } func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int { @@ -408,7 +433,7 @@ type StashBoxBatchTagInput struct { ExcludeFields []string `json:"exclude_fields"` // Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false Refresh bool `json:"refresh"` - // If batch adding studios, should their parent studios also be created? + // If batch adding studios or tags, should their parent entities also be created? CreateParent bool `json:"createParent"` // IDs in stash of the items to update. // If set, names and stash_ids fields will be ignored. @@ -704,3 +729,137 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashB return s.JobManager.Add(ctx, "Batch stash-box studio tag...", j) } + +func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) { + var tasks []Task + + err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { + tagQuery := s.Repository.Tag + + for _, tagID := range input.Ids { + if id, err := strconv.Atoi(tagID); err == nil { + t, err := tagQuery.Find(ctx, id) + if err != nil { + return err + } + + if err := t.LoadStashIDs(ctx, tagQuery); err != nil { + return fmt.Errorf("loading tag stash ids: %w", err) + } + + hasStashID := t.StashIDs.ForEndpoint(box.Endpoint) != nil + if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) { + tasks = append(tasks, &stashBoxBatchTagTagTask{ + tag: t, + createParent: input.CreateParent, + box: box, + excludedFields: input.ExcludeFields, + }) + } + } + } + return nil + }) + + return tasks, err +} + +func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task { + var tasks []Task + + for i := range input.StashIDs { + stashID := input.StashIDs[i] + if len(stashID) > 0 { + tasks = append(tasks, &stashBoxBatchTagTagTask{ + stashID: &stashID, + createParent: input.CreateParent, + box: box, + excludedFields: input.ExcludeFields, + }) + } + } + + for i := range input.Names { + name := input.Names[i] + if len(name) > 0 { + tasks = append(tasks, &stashBoxBatchTagTagTask{ + name: &name, + createParent: input.CreateParent, + box: box, + excludedFields: input.ExcludeFields, + }) + } + } + + return tasks +} + +func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) { + var tasks []Task + + err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { + tagQuery := s.Repository.Tag + var tags []*models.Tag + var err error + + tags, err = tagQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint) + + if err != nil { + return fmt.Errorf("error querying tags: %v", err) + } + + for _, t := range tags { + tasks = append(tasks, &stashBoxBatchTagTagTask{ + tag: t, + createParent: input.CreateParent, + box: box, + excludedFields: input.ExcludeFields, + }) + } + return nil + }) + + return tasks, err +} + +func (s *Manager) StashBoxBatchTagTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int { + j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { + logger.Infof("Initiating stash-box batch tag tag") + + var tasks []Task + var err error + + switch input.getBatchTagType(false) { + case batchTagByIds: + tasks, err = s.batchTagTagsByIds(ctx, input, box) + case batchTagByNamesOrStashIds: + tasks = s.batchTagTagsByNamesOrStashIds(input, box) + case batchTagAll: + tasks, err = s.batchTagAllTags(ctx, input, box) + } + + if err != nil { + return err + } + + if len(tasks) == 0 { + return nil + } + + progress.SetTotal(len(tasks)) + + logger.Infof("Starting stash-box batch operation for %d tags", len(tasks)) + + for _, task := range tasks { + progress.ExecuteTask(task.GetDescription(), func() { + task.Start(ctx) + }) + + progress.Increment() + } + + return nil + }) + + return s.JobManager.Add(ctx, "Batch stash-box tag tag...", j) +} diff --git a/internal/manager/repository.go b/internal/manager/repository.go index afbf0b963..65514ed1d 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -39,7 +39,7 @@ type GalleryService interface { } type GroupService interface { - Create(ctx context.Context, group *models.Group, frontimageData []byte, backimageData []byte) error + Create(ctx context.Context, input *models.CreateGroupInput) error UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial, frontImage group.ImageInput, backImage group.ImageInput) (*models.Group, error) AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error diff --git a/internal/manager/scan_stashignore_test.go b/internal/manager/scan_stashignore_test.go new file mode 100644 index 000000000..2745ff970 --- /dev/null +++ b/internal/manager/scan_stashignore_test.go @@ -0,0 +1,268 @@ +//go:build integration +// +build integration + +package manager + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stashapp/stash/pkg/file" + + // Necessary to register custom migrations. + _ "github.com/stashapp/stash/pkg/sqlite/migrations" +) + +// stashIgnorePathFilter wraps StashIgnoreFilter to implement PathFilter for testing. +// It provides a fixed library root for the filter. +type stashIgnorePathFilter struct { + filter *file.StashIgnoreFilter + libraryRoot string +} + +func (f *stashIgnorePathFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool { + return f.filter.Accept(ctx, path, info, f.libraryRoot, zipFilePath) +} + +// createTestFileOnDisk creates a file with some content. +func createTestFileOnDisk(t *testing.T, dir, name string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create directory for %s: %v", path, err) + } + // Write some content so the file has a non-zero size. + if err := os.WriteFile(path, []byte("test content for "+name), 0644); err != nil { + t.Fatalf("failed to create file %s: %v", path, err) + } + return path +} + +// createStashIgnoreFile creates a .stashignore file with the given content. +func createStashIgnoreFile(t *testing.T, dir, content string) { + t.Helper() + path := filepath.Join(dir, ".stashignore") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to create .stashignore: %v", err) + } +} + +func TestScannerWithStashIgnore(t *testing.T) { + // Create temp directory structure. + tmpDir := t.TempDir() + + // Create test files. + createTestFileOnDisk(t, tmpDir, "video1.mp4") + createTestFileOnDisk(t, tmpDir, "video2.mp4") + createTestFileOnDisk(t, tmpDir, "ignore_me.mp4") + createTestFileOnDisk(t, tmpDir, "subdir/video3.mp4") + createTestFileOnDisk(t, tmpDir, "subdir/skip_this.mp4") + createTestFileOnDisk(t, tmpDir, "excluded_dir/video4.mp4") + createTestFileOnDisk(t, tmpDir, "temp/processing.mp4") + + // Create .stashignore file. + stashignore := `# Ignore specific files +ignore_me.mp4 +subdir/skip_this.mp4 + +# Ignore directories +excluded_dir/ +temp/ +` + createStashIgnoreFile(t, tmpDir, stashignore) + + // Create stashignore filter with library root. + stashIgnoreFilter := &stashIgnorePathFilter{ + filter: file.NewStashIgnoreFilter(), + libraryRoot: tmpDir, + } + + // Create scanner. + scanner := &file.Scanner{ + ScanFilters: []file.PathFilter{stashIgnoreFilter}, + } + + testScenarios := []struct { + path string + accepted bool + }{ + {filepath.Join(tmpDir, "video1.mp4"), true}, + {filepath.Join(tmpDir, "video2.mp4"), true}, + {filepath.Join(tmpDir, "ignore_me.mp4"), false}, + {filepath.Join(tmpDir, "subdir/video3.mp4"), true}, + {filepath.Join(tmpDir, "subdir/skip_this.mp4"), false}, + {filepath.Join(tmpDir, "excluded_dir/video4.mp4"), false}, + {filepath.Join(tmpDir, "temp/processing.mp4"), false}, + } + + ctx := context.Background() + + for _, scenario := range testScenarios { + info, err := os.Stat(scenario.path) + if err != nil { + t.Fatalf("failed to stat file %s: %v", scenario.path, err) + } + accepted := scanner.AcceptEntry(ctx, scenario.path, info, "") + + if accepted != scenario.accepted { + t.Errorf("unexpected accept result for %s: expected %v, got %v", + scenario.path, scenario.accepted, accepted) + } + } +} + +func TestScannerWithNestedStashIgnore(t *testing.T) { + // Create temp directory structure. + tmpDir := t.TempDir() + + // Create test files. + createTestFileOnDisk(t, tmpDir, "root.mp4") + createTestFileOnDisk(t, tmpDir, "root.tmp") + createTestFileOnDisk(t, tmpDir, "subdir/sub.mp4") + createTestFileOnDisk(t, tmpDir, "subdir/sub.log") + createTestFileOnDisk(t, tmpDir, "subdir/sub.tmp") + + // Root .stashignore excludes *.tmp. + createStashIgnoreFile(t, tmpDir, "*.tmp\n") + + // Subdir .stashignore excludes *.log. + createStashIgnoreFile(t, filepath.Join(tmpDir, "subdir"), "*.log\n") + + // Create stashignore filter with library root. + stashIgnoreFilter := &stashIgnorePathFilter{ + filter: file.NewStashIgnoreFilter(), + libraryRoot: tmpDir, + } + + // Create scanner. + scanner := &file.Scanner{ + ScanFilters: []file.PathFilter{stashIgnoreFilter}, + } + + testScenarios := []struct { + path string + accepted bool + }{ + {filepath.Join(tmpDir, "root.mp4"), true}, + {filepath.Join(tmpDir, "root.tmp"), false}, + {filepath.Join(tmpDir, "subdir/sub.mp4"), true}, + {filepath.Join(tmpDir, "subdir/sub.log"), false}, + {filepath.Join(tmpDir, "subdir/sub.tmp"), false}, + } + + ctx := context.Background() + + for _, scenario := range testScenarios { + info, err := os.Stat(scenario.path) + if err != nil { + t.Fatalf("failed to stat file %s: %v", scenario.path, err) + } + accepted := scanner.AcceptEntry(ctx, scenario.path, info, "") + + if accepted != scenario.accepted { + t.Errorf("unexpected accept result for %s: expected %v, got %v", + scenario.path, scenario.accepted, accepted) + } + } +} + +func TestScannerWithoutStashIgnore(t *testing.T) { + // Create temp directory structure (no .stashignore). + tmpDir := t.TempDir() + + // Create test files. + createTestFileOnDisk(t, tmpDir, "video1.mp4") + createTestFileOnDisk(t, tmpDir, "video2.mp4") + createTestFileOnDisk(t, tmpDir, "subdir/video3.mp4") + + // Create stashignore filter with library root (but no .stashignore file exists). + stashIgnoreFilter := &stashIgnorePathFilter{ + filter: file.NewStashIgnoreFilter(), + libraryRoot: tmpDir, + } + + // Create scanner. + scanner := &file.Scanner{ + ScanFilters: []file.PathFilter{stashIgnoreFilter}, + } + + testScenarios := []struct { + path string + accepted bool + }{ + {filepath.Join(tmpDir, "video1.mp4"), true}, + {filepath.Join(tmpDir, "video2.mp4"), true}, + {filepath.Join(tmpDir, "subdir/video3.mp4"), true}, + } + + ctx := context.Background() + + for _, scenario := range testScenarios { + info, err := os.Stat(scenario.path) + if err != nil { + t.Fatalf("failed to stat file %s: %v", scenario.path, err) + } + accepted := scanner.AcceptEntry(ctx, scenario.path, info, "") + + if accepted != scenario.accepted { + t.Errorf("unexpected accept result for %s: expected %v, got %v", + scenario.path, scenario.accepted, accepted) + } + } +} + +func TestScannerWithNegationPattern(t *testing.T) { + // Create temp directory structure. + tmpDir := t.TempDir() + + // Create test files. + createTestFileOnDisk(t, tmpDir, "file1.tmp") + createTestFileOnDisk(t, tmpDir, "file2.tmp") + createTestFileOnDisk(t, tmpDir, "keep_this.tmp") + createTestFileOnDisk(t, tmpDir, "video.mp4") + + // Create .stashignore with negation. + stashignore := `*.tmp +!keep_this.tmp +` + createStashIgnoreFile(t, tmpDir, stashignore) + + // Create stashignore filter with library root. + stashIgnoreFilter := &stashIgnorePathFilter{ + filter: file.NewStashIgnoreFilter(), + libraryRoot: tmpDir, + } + + // Create scanner. + scanner := &file.Scanner{ + ScanFilters: []file.PathFilter{stashIgnoreFilter}, + } + + testScenarios := []struct { + path string + accepted bool + }{ + {filepath.Join(tmpDir, "file1.tmp"), false}, + {filepath.Join(tmpDir, "file2.tmp"), false}, + {filepath.Join(tmpDir, "keep_this.tmp"), true}, + {filepath.Join(tmpDir, "video.mp4"), true}, + } + + ctx := context.Background() + + for _, scenario := range testScenarios { + info, err := os.Stat(scenario.path) + if err != nil { + t.Fatalf("failed to stat file %s: %v", scenario.path, err) + } + accepted := scanner.AcceptEntry(ctx, scenario.path, info, "") + + if accepted != scenario.accepted { + t.Errorf("unexpected accept result for %s: expected %v, got %v", + scenario.path, scenario.accepted, accepted) + } + } +} diff --git a/internal/manager/task/clean_generated.go b/internal/manager/task/clean_generated.go index a59bda6d1..378268a17 100644 --- a/internal/manager/task/clean_generated.go +++ b/internal/manager/task/clean_generated.go @@ -313,9 +313,36 @@ func (j *CleanGeneratedJob) cleanBlobFiles(ctx context.Context, progress *job.Pr return err } + // remove empty hash prefix subdirectories + j.removeEmptyDirs(j.Paths.Blobs) + return nil } +func (j *CleanGeneratedJob) removeEmptyDirs(root string) { + entries, err := os.ReadDir(root) + if err != nil { + return + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + dirPath := filepath.Join(root, entry.Name()) + subEntries, err := os.ReadDir(dirPath) + if err != nil { + continue + } + + if len(subEntries) == 0 { + j.logDelete("removing empty directory: %s", entry.Name()) + j.deleteDir(dirPath) + } + } +} + func (j *CleanGeneratedJob) getScenesWithHash(ctx context.Context, hash string) ([]*models.Scene, error) { fp := models.Fingerprint{ Fingerprint: hash, @@ -637,6 +664,8 @@ func (j *CleanGeneratedJob) cleanMarkerFiles(ctx context.Context, progress *job. return err } + j.removeEmptyDirs(j.Paths.Generated.Markers) + return nil } @@ -730,5 +759,7 @@ func (j *CleanGeneratedJob) cleanThumbnailFiles(ctx context.Context, progress *j return err } + j.removeEmptyDirs(j.Paths.Generated.Thumbnails) + return nil } diff --git a/internal/manager/task/migrate.go b/internal/manager/task/migrate.go index 95798d301..dd320a83b 100644 --- a/internal/manager/task/migrate.go +++ b/internal/manager/task/migrate.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/sqlite" @@ -29,6 +31,21 @@ type databaseSchemaInfo struct { StepsRequired uint } +// PreExecute validates the environment before executing the migration. +// It returns an error if the migration cannot be performed. +func (s *MigrateJob) PreExecute() error { + // ensure backup directory exists and is writable + backupDir := s.Config.GetBackupDirectoryPathOrDefault() + if backupDir != "" { + if err := fsutil.EnsureDir(backupDir); err != nil { + logger.Errorf("error ensuring backup directory exists: %s", err) + logger.Warnf("Backup directory (%s) must be modified to a valid directory or removed from the config file", config.BackupDirectoryPath) + return fmt.Errorf("error creating backup directory: %w", err) + } + } + return nil +} + func (s *MigrateJob) Execute(ctx context.Context, progress *job.Progress) error { schemaInfo, err := s.required() if err != nil { diff --git a/internal/manager/task_clean.go b/internal/manager/task_clean.go index ddd86e2f2..67b7038b6 100644 --- a/internal/manager/task_clean.go +++ b/internal/manager/task_clean.go @@ -40,9 +40,10 @@ func (j *cleanJob) Execute(ctx context.Context, progress *job.Progress) error { } j.cleaner.Clean(ctx, file.CleanOptions{ - Paths: j.input.Paths, - DryRun: j.input.DryRun, - PathFilter: newCleanFilter(instance.Config), + Paths: j.input.Paths, + DryRun: j.input.DryRun, + IgnoreZipFileContents: j.input.IgnoreZipFileContents, + PathFilter: newCleanFilter(instance.Config), }, progress) if job.IsCancelled(ctx) { @@ -154,11 +155,12 @@ func newCleanFilter(c *config.Config) *cleanFilter { generatedPath: c.GetGeneratedPath(), videoExcludeRegex: generateRegexps(c.GetExcludes()), imageExcludeRegex: generateRegexps(c.GetImageExcludes()), + stashIgnoreFilter: file.NewStashIgnoreFilter(), }, } } -func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool { +func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool { // #1102 - clean anything in generated path generatedPath := f.generatedPath @@ -173,12 +175,18 @@ func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) } if stash == nil { - logger.Infof("%s not in any stash library directories. Marking to clean: \"%s\"", fileOrFolder, path) + logger.Infof("%s not in any stash library directories. Marking to clean: %q", fileOrFolder, path) return false } if fsutil.IsPathInDir(generatedPath, path) { - logger.Infof("%s is in generated path. Marking to clean: \"%s\"", fileOrFolder, path) + logger.Infof("%s is in generated path. Marking to clean: %q", fileOrFolder, path) + return false + } + + // Check .stashignore files, bounded to the library root. + if !f.stashIgnoreFilter.Accept(ctx, path, info, stash.Path, zipFilePath) { + logger.Infof("%s is excluded due to .stashignore. Marking to clean: %q", fileOrFolder, path) return false } diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 5f2897670..01bab9430 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -651,6 +651,7 @@ func (t *ExportTask) exportImage(ctx context.Context, wg *sync.WaitGroup, jobCha galleryReader := r.Gallery performerReader := r.Performer tagReader := r.Tag + imageReader := r.Image for s := range jobChan { imageHash := s.Checksum @@ -665,14 +666,17 @@ func (t *ExportTask) exportImage(ctx context.Context, wg *sync.WaitGroup, jobCha continue } - newImageJSON := image.ToBasicJSON(s) + newImageJSON, err := image.ToBasicJSON(ctx, imageReader, s) + if err != nil { + logger.Errorf("[images] <%s> error converting image to JSON: %v", imageHash, err) + continue + } // export files for _, f := range s.Files.List() { t.exportFile(f) } - var err error newImageJSON.Studio, err = image.GetStudioName(ctx, studioReader, s) if err != nil { logger.Errorf("[images] <%s> error getting image studio name: %v", imageHash, err) @@ -779,6 +783,7 @@ func (t *ExportTask) exportGallery(ctx context.Context, wg *sync.WaitGroup, jobC studioReader := r.Studio performerReader := r.Performer tagReader := r.Tag + galleryReader := r.Gallery galleryChapterReader := r.GalleryChapter for g := range jobChan { @@ -847,6 +852,12 @@ func (t *ExportTask) exportGallery(ctx context.Context, wg *sync.WaitGroup, jobC newGalleryJSON.Tags = tag.GetNames(tags) + newGalleryJSON.CustomFields, err = galleryReader.GetCustomFields(ctx, g.ID) + if err != nil { + logger.Errorf("[galleries] <%s> error getting gallery custom fields: %v", g.DisplayName(), err) + continue + } + if t.includeDependencies { if g.StudioID != nil { t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *g.StudioID) diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index cc991d5d6..6f8ac8178 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -43,6 +43,8 @@ type GenerateMetadataInput struct { GalleryIDs []string `json:"galleryIDs"` // overwrite existing media Overwrite bool `json:"overwrite"` + // paths to run generate on, in addition to the other ID lists + Paths []string `json:"paths"` } type GeneratePreviewOptionsInput struct { @@ -133,8 +135,13 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error r := j.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { qb := r.Scene - if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 && len(j.input.ImageIDs) == 0 && len(j.input.GalleryIDs) == 0 { - j.queueTasks(ctx, g, queue) + if len(j.input.SceneIDs) == 0 && + len(j.input.MarkerIDs) == 0 && + len(j.input.ImageIDs) == 0 && + len(j.input.GalleryIDs) == 0 && + len(j.input.Paths) == 0 { + + j.queueTasks(ctx, g, nil, queue) } else { if len(j.input.SceneIDs) > 0 { scenes, err = qb.FindMany(ctx, sceneIDs) @@ -183,6 +190,11 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error } } } + + if len(j.input.Paths) > 0 { + paths := filterStashPaths(j.input.Paths) + j.queueTasks(ctx, g, paths, queue) + } } return nil @@ -250,7 +262,9 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error for f := range queue { if job.IsCancelled(ctx) { - break + // keep draining the queue so the producer goroutine can finish + // and release its read transaction, otherwise the DB stays locked + continue } wg.Add() @@ -276,17 +290,18 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error return nil } -func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { +func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) { j.totals = totalsGenerate{} - j.queueScenesTasks(ctx, g, queue) - j.queueImagesTasks(ctx, g, queue) + j.queueScenesTasks(ctx, g, paths, queue) + j.queueImagesTasks(ctx, g, paths, queue) } -func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { +func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) { const batchSize = 1000 findFilter := models.BatchFindFilter(batchSize) + sceneFilter := scene.FilterFromPaths(paths) r := j.repository @@ -295,7 +310,7 @@ func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generato return } - scenes, err := scene.Query(ctx, r.Scene, nil, findFilter) + scenes, err := scene.Query(ctx, r.Scene, sceneFilter, findFilter) if err != nil { logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) return @@ -322,10 +337,11 @@ func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generato } } -func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { +func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) { const batchSize = 1000 findFilter := models.BatchFindFilter(batchSize) + imageFilter := image.FilterFromPaths(paths) r := j.repository @@ -334,7 +350,7 @@ func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generato return } - images, err := image.Query(ctx, r.Image, nil, findFilter) + images, err := image.Query(ctx, r.Image, imageFilter, findFilter) if err != nil { logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) return diff --git a/internal/manager/task_optimise.go b/internal/manager/task_optimise.go index 9f85e961c..7b14acebf 100644 --- a/internal/manager/task_optimise.go +++ b/internal/manager/task_optimise.go @@ -35,7 +35,7 @@ func (j *OptimiseDatabaseJob) Execute(ctx context.Context, progress *job.Progres return nil } if err != nil { - return fmt.Errorf("Error analyzing database: %w", err) + return fmt.Errorf("error analyzing database: %w", err) } progress.ExecuteTask("Vacuuming database", func() { diff --git a/internal/manager/task_plugin.go b/internal/manager/task_plugin.go index 80f38598c..fb8cea0cb 100644 --- a/internal/manager/task_plugin.go +++ b/internal/manager/task_plugin.go @@ -20,12 +20,12 @@ func (s *Manager) RunPluginTask( pluginProgress := make(chan float64) task, err := s.PluginCache.CreateTask(ctx, pluginID, taskName, args, pluginProgress) if err != nil { - return fmt.Errorf("Error creating plugin task: %w", err) + return fmt.Errorf("error creating plugin task: %w", err) } err = task.Start() if err != nil { - return fmt.Errorf("Error running plugin task: %w", err) + return fmt.Errorf("error running plugin task: %w", err) } done := make(chan bool) diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index d09765577..155090cd2 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -26,6 +26,7 @@ import ( "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scene/generate" "github.com/stashapp/stash/pkg/txn" + "github.com/stashapp/stash/pkg/utils" ) type ScanJob struct { @@ -35,6 +36,8 @@ type ScanJob struct { fileQueue chan file.ScannedFile count int + + unmatchedCaptionFiles utils.MutexField[[]string] } func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { @@ -73,6 +76,8 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { j.scanner.ScanFilters = []file.PathFilter{newScanFilter(c, repo, minModTime)} j.scanner.HandlerRequiredFilters = []file.Filter{newHandlerRequiredFilter(cfg, repo)} + logger.Infof("Starting scan of %d paths with %d parallel tasks", len(paths), nTasks) + j.runJob(ctx, paths, nTasks, progress) taskQueue.Close() @@ -83,7 +88,7 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { } elapsed := time.Since(start) - logger.Info(fmt.Sprintf("Scan finished (%s)", elapsed)) + logger.Infof("Scan finished (%s)", elapsed) j.subscriptions.notify() return nil @@ -166,12 +171,33 @@ func (j *ScanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *file. return nil } - if !j.scanner.AcceptEntry(ctx, path, info) { + zipFilePath := "" + if zipFile != nil { + zipFilePath = zipFile.Path + } + + if !j.scanner.AcceptEntry(ctx, path, info, zipFilePath) { if info.IsDir() { logger.Debugf("Skipping directory %s", path) return fs.SkipDir } + // we don't include caption files in the file scan, but we do need + // to handle them + if fsutil.MatchExtension(path, video.CaptionExts) { + fileRepo := j.scanner.Repository.File + matched := video.AssociateCaptions(ctx, path, j.scanner.Repository.TxnManager, fileRepo, fileRepo) + + if !matched { + logger.Debugf("No matching video file found for caption file %s", path) + j.unmatchedCaptionFiles.SetFunc(func(files []string) []string { + return append(files, path) + }) + } + + return nil + } + logger.Debugf("Skipping file %s", path) return nil } @@ -257,8 +283,10 @@ func (j *ScanJob) processQueue(ctx context.Context, parallelTasks int, progress for f := range j.fileQueue { logger.Tracef("Processing queued file %s", f.Path) - if err := ctx.Err(); err != nil { - return + if ctx.Err() != nil { + // Keep receiving until queueFiles closes the channel; otherwise + // the walker can block on send (full buffer) and never finish. + continue } wg.Add() @@ -309,10 +337,53 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress * return err } - // handle rename should have already handled the contents of the zip file - // so shouldn't need to scan it again + // if this is a new video file, match it with any unmatched caption files + if r.New && len(j.unmatchedCaptionFiles.Get()) > 0 { + videoFile, _ := r.File.(*models.VideoFile) - if (r.New || r.Updated) && j.scanner.IsZipFile(f.Info.Name()) { + if videoFile != nil { + // try to match any unmatched caption files to this video file + for _, captionPath := range j.unmatchedCaptionFiles.Get() { + if video.MatchesCaption(videoFile.Path, captionPath) { + video.AssociateCaptions(ctx, captionPath, j.scanner.Repository.TxnManager, j.scanner.Repository.File, j.scanner.Repository.File) + + // remove from the unmatched list + j.unmatchedCaptionFiles.SetFunc(func(files []string) []string { + newFiles := make([]string, 0, len(files)-1) + for _, f := range files { + if f != captionPath { + newFiles = append(newFiles, f) + } + } + return newFiles + }) + } + } + } + } + + // clean captions - scene handler handles this as well, but + // unchanged files aren't processed by the scene handler + if r.IsUnchanged() { + videoFile, _ := r.File.(*models.VideoFile) + + if videoFile != nil { + txnMgr := j.scanner.Repository.TxnManager + fileRepo := j.scanner.Repository.File + if err := txn.WithDatabase(ctx, txnMgr, func(ctx context.Context) error { + return video.CleanCaptions(ctx, videoFile, txnMgr, fileRepo) + }); err != nil { + logger.Errorf("Error cleaning captions: %v", err) + } + } + } + + // handle rename should have already handled the contents of the zip file + // so shouldn't need to scan it again. + // Only scan zip contents if the file is new, the fingerprint changed, + // or if a force rescan was requested. + + if j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan) { ff := r.File f.BaseFile = ff.Base() @@ -324,6 +395,8 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress * if err := j.scanZipFile(zipCtx, f, progress); err != nil { logger.Errorf("Error scanning zip file %q: %v", f.Path, err) } + } else if r.Updated && j.scanner.IsZipFile(f.Info.Name()) { + logger.Debugf("Skipping zip file scan for %q: fingerprint unchanged", f.Path) } return nil @@ -378,11 +451,10 @@ type sceneFinder interface { // handlerRequiredFilter returns true if a File's handler needs to be executed despite the file not being updated. type handlerRequiredFilter struct { extensionConfig - txnManager txn.Manager - SceneFinder sceneFinder - ImageFinder fileCounter - GalleryFinder galleryFinder - CaptionUpdater video.CaptionUpdater + txnManager txn.Manager + SceneFinder sceneFinder + ImageFinder fileCounter + GalleryFinder galleryFinder FolderCache *lru.LRU[bool] @@ -398,7 +470,6 @@ func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handler SceneFinder: repo.Scene, ImageFinder: repo.Image, GalleryFinder: repo.Gallery, - CaptionUpdater: repo.File, FolderCache: lru.New[bool](processes * 2), videoFileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(), } @@ -473,65 +544,35 @@ func (f *handlerRequiredFilter) Accept(ctx context.Context, ff models.File) bool } } - if isVideoFile { - // TODO - check if the cover exists - // hash := scene.GetHash(ff, f.videoFileNamingAlgorithm) - // ssPath := instance.Paths.Scene.GetScreenshotPath(hash) - // if exists, _ := fsutil.FileExists(ssPath); !exists { - // // if not, check if the file is a primary file for a scene - // scenes, err := f.SceneFinder.FindByPrimaryFileID(ctx, ff.Base().ID) - // if err != nil { - // // just ignore - // return false - // } - - // if len(scenes) > 0 { - // // if it is, then it needs to be re-generated - // return true - // } - // } - - // clean captions - scene handler handles this as well, but - // unchanged files aren't processed by the scene handler - videoFile, _ := ff.(*models.VideoFile) - if videoFile != nil { - if err := video.CleanCaptions(ctx, videoFile, f.txnManager, f.CaptionUpdater); err != nil { - logger.Errorf("Error cleaning captions: %v", err) - } - } - } - return false } type scanFilter struct { extensionConfig - txnManager txn.Manager - FileFinder models.FileFinder - CaptionUpdater video.CaptionUpdater + txnManager txn.Manager stashPaths config.StashConfigs generatedPath string videoExcludeRegex []*regexp.Regexp imageExcludeRegex []*regexp.Regexp minModTime time.Time + stashIgnoreFilter *file.StashIgnoreFilter } func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Time) *scanFilter { return &scanFilter{ extensionConfig: newExtensionConfig(c), txnManager: repo.TxnManager, - FileFinder: repo.File, - CaptionUpdater: repo.File, stashPaths: c.GetStashPaths(), generatedPath: c.GetGeneratedPath(), videoExcludeRegex: generateRegexps(c.GetExcludes()), imageExcludeRegex: generateRegexps(c.GetImageExcludes()), minModTime: minModTime, + stashIgnoreFilter: file.NewStashIgnoreFilter(), } } -func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool { +func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool { if fsutil.IsPathInDir(f.generatedPath, path) { logger.Warnf("Skipping %q as it overlaps with the generated folder", path) return false @@ -548,19 +589,16 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) return false } + // Check .stashignore files, bounded to the library root. + if !f.stashIgnoreFilter.Accept(ctx, path, info, s.Path, zipFilePath) { + logger.Debugf("Skipping %s due to .stashignore", path) + return false + } + isVideoFile := useAsVideo(path) isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) - // handle caption files - if fsutil.MatchExtension(path, video.CaptionExts) { - // we don't include caption files in the file scan, but we do need - // to handle them - video.AssociateCaptions(ctx, path, f.txnManager, f.FileFinder, f.CaptionUpdater) - - return false - } - if !info.IsDir() && !isVideoFile && !isImageFile && !isZipFile { logger.Debugf("Skipping %s as it does not match any known file extensions", path) return false @@ -624,8 +662,9 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre &file.FilteredHandler{ Filter: file.FilterFunc(imageFileFilter), Handler: &image.ScanHandler{ - CreatorUpdater: r.Image, - GalleryFinder: r.Gallery, + CreatorUpdater: r.Image, + GalleryFinder: r.Gallery, + SceneFinderUpdater: r.Scene, ScanGenerator: &imageGenerators{ input: options, taskQueue: taskQueue, @@ -654,9 +693,10 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre &file.FilteredHandler{ Filter: file.FilterFunc(videoFileFilter), Handler: &scene.ScanHandler{ - CreatorUpdater: r.Scene, - CaptionUpdater: r.File, - PluginCache: pluginCache, + CreatorUpdater: r.Scene, + GalleryFinderUpdater: r.Gallery, + CaptionUpdater: r.File, + PluginCache: pluginCache, ScanGenerator: &sceneGenerators{ input: options, taskQueue: taskQueue, diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 4848b46ad..264e7e96c 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" @@ -12,6 +13,7 @@ import ( "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/stashbox" "github.com/stashapp/stash/pkg/studio" + "github.com/stashapp/stash/pkg/tag" ) // stashBoxBatchPerformerTagTask is used to tag or create performers from stash-box. @@ -529,3 +531,235 @@ func (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, pa return err } } + +// stashBoxBatchTagTagTask is used to tag or create tags from stash-box. +// +// Two modes of operation: +// - Update existing tag: set tag to update from stash-box data +// - Create new tag: set name or stashID to search stash-box and create locally +type stashBoxBatchTagTagTask struct { + box *models.StashBox + name *string + stashID *string + tag *models.Tag + createParent bool + excludedFields []string +} + +func (t *stashBoxBatchTagTagTask) getName() string { + switch { + case t.name != nil: + return *t.name + case t.stashID != nil: + return *t.stashID + case t.tag != nil: + return t.tag.Name + default: + return "" + } +} + +func (t *stashBoxBatchTagTagTask) Start(ctx context.Context) { + scrapedTag, err := t.findStashBoxTag(ctx) + if err != nil { + logger.Errorf("Error fetching tag data from stash-box: %v", err) + return + } + + excluded := map[string]bool{} + for _, field := range t.excludedFields { + excluded[field] = true + } + + if scrapedTag != nil { + t.processMatchedTag(ctx, scrapedTag, excluded) + } else { + logger.Infof("No match found for %s", t.getName()) + } +} + +func (t *stashBoxBatchTagTagTask) GetDescription() string { + return fmt.Sprintf("Tagging tag %s from stash-box", t.getName()) +} + +func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.ScrapedTag, error) { + var results []*models.ScrapedTag + var err error + + r := instance.Repository + + client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns())) + + nameQuery := "" + + switch { + case t.name != nil: + nameQuery = *t.name + results, err = client.QueryTag(ctx, *t.name) + case t.stashID != nil: + results, err = client.QueryTag(ctx, *t.stashID) + case t.tag != nil: + var remoteID string + if err := r.WithReadTxn(ctx, func(ctx context.Context) error { + if !t.tag.StashIDs.Loaded() { + err = t.tag.LoadStashIDs(ctx, r.Tag) + if err != nil { + return err + } + } + for _, id := range t.tag.StashIDs.List() { + if id.Endpoint == t.box.Endpoint { + remoteID = id.StashID + } + } + return nil + }); err != nil { + return nil, err + } + + if remoteID != "" { + results, err = client.QueryTag(ctx, remoteID) + } else { + nameQuery = t.tag.Name + results, err = client.QueryTag(ctx, t.tag.Name) + } + } + + if err != nil { + return nil, err + } + + if len(results) == 0 { + return nil, nil + } + + var result *models.ScrapedTag + + // QueryTag returns tags that partially match the name, so find the exact match if searching by name + if nameQuery != "" { + for _, r := range results { + if strings.EqualFold(r.Name, nameQuery) { + result = r + break + } + } + } else { + result = results[0] + } + + if result == nil { + return nil, nil + } + + if err := r.WithReadTxn(ctx, func(ctx context.Context) error { + return match.ScrapedTagHierarchy(ctx, r.Tag, result, t.box.Endpoint) + }); err != nil { + return nil, err + } + + return result, nil +} + +func (t *stashBoxBatchTagTagTask) processParentTag(ctx context.Context, parent *models.ScrapedTag, excluded map[string]bool) error { + if parent.StoredID == nil { + // Create new parent tag + newParentTag := parent.ToTag(t.box.Endpoint, excluded) + + r := instance.Repository + err := r.WithTxn(ctx, func(ctx context.Context) error { + qb := r.Tag + + if err := tag.ValidateCreate(ctx, *newParentTag, qb); err != nil { + return err + } + + if err := qb.Create(ctx, &models.CreateTagInput{Tag: newParentTag}); err != nil { + return err + } + + storedID := strconv.Itoa(newParentTag.ID) + parent.StoredID = &storedID + return nil + }) + if err != nil { + logger.Errorf("Failed to create parent tag %s: %v", parent.Name, err) + } else { + logger.Infof("Created parent tag %s", parent.Name) + } + return err + } + + // Parent already exists — nothing to update for categories + return nil +} + +func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *models.ScrapedTag, excluded map[string]bool) { + // Determine the tag ID to update — either from the task's tag or from the + // StoredID set by match.ScrapedTag (when batch adding by name and the tag + // already exists locally). + tagID := 0 + if t.tag != nil { + tagID = t.tag.ID + } else if s.StoredID != nil { + tagID, _ = strconv.Atoi(*s.StoredID) + } + + if s.Parent != nil && t.createParent { + if err := t.processParentTag(ctx, s.Parent, excluded); err != nil { + return + } + } + + if tagID > 0 { + r := instance.Repository + err := r.WithTxn(ctx, func(ctx context.Context) error { + qb := r.Tag + + existingStashIDs, err := qb.GetStashIDs(ctx, tagID) + if err != nil { + return err + } + + storedID := strconv.Itoa(tagID) + partial := s.ToPartial(storedID, t.box.Endpoint, excluded, existingStashIDs) + + if err := tag.ValidateUpdate(ctx, tagID, partial, qb); err != nil { + return err + } + + if _, err := qb.UpdatePartial(ctx, tagID, partial); err != nil { + return err + } + + return nil + }) + if err != nil { + logger.Errorf("Failed to update tag %s: %v", s.Name, err) + } else { + logger.Infof("Updated tag %s", s.Name) + } + } else if s.Name != "" { + // no existing tag, create a new one + newTag := s.ToTag(t.box.Endpoint, excluded) + + r := instance.Repository + err := r.WithTxn(ctx, func(ctx context.Context) error { + qb := r.Tag + + if err := tag.ValidateCreate(ctx, *newTag, qb); err != nil { + return err + } + + if err := qb.Create(ctx, &models.CreateTagInput{Tag: newTag}); err != nil { + return err + } + + return nil + }) + if err != nil { + logger.Errorf("Failed to create tag %s: %v", s.Name, err) + } else { + logger.Infof("Created tag %s", s.Name) + } + } +} diff --git a/pkg/ffmpeg/codec_hardware.go b/pkg/ffmpeg/codec_hardware.go index aa8c75dcc..a83830c52 100644 --- a/pkg/ffmpeg/codec_hardware.go +++ b/pkg/ffmpeg/codec_hardware.go @@ -45,13 +45,13 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) { // log if the initialization takes too long const hwInitLogTimeoutSecondsDefault = 5 - hwInitLogTimeoutSeconds := hwInitLogTimeoutSecondsDefault * time.Second - timer := time.NewTimer(hwInitLogTimeoutSeconds) + hwInitLogTimeout := hwInitLogTimeoutSecondsDefault * time.Second + timer := time.NewTimer(hwInitLogTimeout) go func() { select { case <-timer.C: - logger.Warnf("[InitHWSupport] Hardware codec initialization is taking longer than %s...", hwInitLogTimeoutSeconds) + logger.Warnf("[InitHWSupport] Hardware codec initialization is taking longer than %s...", hwInitLogTimeout) logger.Info("[InitHWSupport] Hardware encoding will not be available until initialization is complete.") case <-done: if !timer.Stop() { @@ -96,16 +96,16 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) { // #6064 - add timeout to context to prevent hangs const hwTestTimeoutSecondsDefault = 10 - hwTestTimeoutSeconds := hwTestTimeoutSecondsDefault * time.Second + hwTestTimeout := hwTestTimeoutSecondsDefault * time.Second // allow timeout to be overridden with environment variable if timeout := os.Getenv("STASH_HW_TEST_TIMEOUT"); timeout != "" { if seconds, err := strconv.Atoi(timeout); err == nil { - hwTestTimeoutSeconds = time.Duration(seconds) * time.Second + hwTestTimeout = time.Duration(seconds) * time.Second } } - testCtx, cancel := context.WithTimeout(ctx, hwTestTimeoutSeconds) + testCtx, cancel := context.WithTimeout(ctx, hwTestTimeout) defer cancel() cmd := f.Command(testCtx, args) @@ -117,7 +117,7 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) { if err := cmd.Run(); err != nil { if testCtx.Err() != nil { - logger.Debugf("[InitHWSupport] Codec %s test timed out after %s", codec, hwTestTimeoutSeconds) + logger.Debugf("[InitHWSupport] Codec %s test timed out after %s", codec, hwTestTimeout) continue } @@ -185,6 +185,12 @@ func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf // Prepend input for hardware encoding only func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { + // check for custom /dev/dri device #6435 + driDevice := os.Getenv("STASH_HW_DRI_DEVICE") + if driDevice == "" { + driDevice = "/dev/dri/renderD128" + } + switch toCodec { case VideoCodecN264, VideoCodecN264H: @@ -201,7 +207,7 @@ func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { case VideoCodecV264, VideoCodecVVP9: args = append(args, "-vaapi_device") - args = append(args, "/dev/dri/renderD128") + args = append(args, driDevice) if fullhw { args = append(args, "-hwaccel") args = append(args, "vaapi") diff --git a/pkg/file/clean.go b/pkg/file/clean.go index 53b2e0612..369600f4c 100644 --- a/pkg/file/clean.go +++ b/pkg/file/clean.go @@ -33,6 +33,11 @@ type cleanJob struct { type CleanOptions struct { Paths []string + // IgnoreZipFileContents will skip checking the contents of zip files when determining whether to clean a file. + // This can significantly speed up the clean process, but will potentially miss removed files within zip files. + // Where users do not modify zip files contents directly, this should be safe to use. + IgnoreZipFileContents bool + // Do a dry run. Don't delete any files DryRun bool @@ -174,13 +179,16 @@ func (j *cleanJob) assessFiles(ctx context.Context, toDelete *deleteSet) error { more := true r := j.Repository + + includeZipContents := !j.options.IgnoreZipFileContents + if err := r.WithReadTxn(ctx, func(ctx context.Context) error { for more { if job.IsCancelled(ctx) { return nil } - files, err := r.File.FindAllInPaths(ctx, j.options.Paths, batchSize, offset) + files, err := r.File.FindAllInPaths(ctx, j.options.Paths, includeZipContents, batchSize, offset) if err != nil { return fmt.Errorf("error querying for files: %w", err) } @@ -258,6 +266,8 @@ func (j *cleanJob) assessFolders(ctx context.Context, toDelete *deleteSet) error offset := 0 progress := j.progress + includeZipContents := !j.options.IgnoreZipFileContents + more := true r := j.Repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { @@ -266,7 +276,7 @@ func (j *cleanJob) assessFolders(ctx context.Context, toDelete *deleteSet) error return nil } - folders, err := r.Folder.FindAllInPaths(ctx, j.options.Paths, batchSize, offset) + folders, err := r.Folder.FindAllInPaths(ctx, j.options.Paths, includeZipContents, batchSize, offset) if err != nil { return fmt.Errorf("error querying for folders: %w", err) } @@ -348,8 +358,14 @@ func (j *cleanJob) shouldClean(ctx context.Context, f models.File) bool { // run through path filter, if returns false then the file should be cleaned filter := j.options.PathFilter + // need to get the zip file path if present + zipFilePath := "" + if f.Base().ZipFile != nil { + zipFilePath = f.Base().ZipFile.Base().Path + } + // don't log anything - assume filter will have logged the reason - return !filter.Accept(ctx, path, info) + return !filter.Accept(ctx, path, info, zipFilePath) } func (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool { @@ -387,8 +403,14 @@ func (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool // run through path filter, if returns false then the file should be cleaned filter := j.options.PathFilter + // need to get the zip file path if present + zipFilePath := "" + if f.ZipFile != nil { + zipFilePath = f.ZipFile.Base().Path + } + // don't log anything - assume filter will have logged the reason - return !filter.Accept(ctx, path, info) + return !filter.Accept(ctx, path, info, zipFilePath) } func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn string) { diff --git a/pkg/file/folder.go b/pkg/file/folder.go index fe260c155..249f73a7a 100644 --- a/pkg/file/folder.go +++ b/pkg/file/folder.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "slices" "strings" "time" @@ -12,8 +13,9 @@ import ( ) // GetOrCreateFolderHierarchy gets the folder for the given path, or creates a folder hierarchy for the given path if one if no existing folder is found. -// Does not create any folders in the file system -func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string) (*models.Folder, error) { +// Creates folder entries for each level of the hierarchy that doesn't already exist, up to the provided root paths. +// Does not create any folders in the file system. +func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string, rootPaths []string) (*models.Folder, error) { // get or create folder hierarchy // assume case sensitive when searching for the folder const caseSensitive = true @@ -23,17 +25,33 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat } if folder == nil { - parentPath := filepath.Dir(path) - parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath) - if err != nil { - return nil, err + var parentID *models.FolderID + + if !slices.Contains(rootPaths, path) { + parentPath := filepath.Dir(path) + + // safety check - don't allow parent path to be the same as the current path, + // otherwise we could end up in an infinite loop + if parentPath == path { + // #6618 - log a warning and return nil for the parent ID, + // which will cause the folder to be created with no parent + logger.Warnf("parent path is the same as the current path: %s", path) + return nil, nil + } + + parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath, rootPaths) + if err != nil { + return nil, err + } + + parentID = &parent.ID } now := time.Now() folder = &models.Folder{ Path: path, - ParentFolderID: &parent.ID, + ParentFolderID: parentID, DirEntry: models.DirEntry{ // leave mod time empty for now - it will be updated when the folder is scanned }, @@ -41,6 +59,8 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat UpdatedAt: now, } + logger.Infof("%s doesn't exist. Creating new folder entry...", path) + if err = fc.Create(ctx, folder); err != nil { return nil, fmt.Errorf("creating folder %s: %w", path, err) } @@ -49,12 +69,18 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat return folder, nil } -func transferZipHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, files models.FileFinderUpdater, zipFileID models.FileID, oldPath string, newPath string) error { - if err := transferZipFolderHierarchy(ctx, folderStore, zipFileID, oldPath, newPath); err != nil { +type zipHierarchyMover struct { + folderStore models.FolderReaderWriter + files models.FileFinderUpdater + rootPaths []string +} + +func (m zipHierarchyMover) transferZipHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error { + if err := m.transferZipFolderHierarchy(ctx, zipFileID, oldPath, newPath); err != nil { return fmt.Errorf("moving folder hierarchy for file %s: %w", oldPath, err) } - if err := transferZipFileEntries(ctx, folderStore, files, zipFileID, oldPath, newPath); err != nil { + if err := m.transferZipFileEntries(ctx, zipFileID, oldPath, newPath); err != nil { return fmt.Errorf("moving zip file contents for file %s: %w", oldPath, err) } @@ -63,8 +89,8 @@ func transferZipHierarchy(ctx context.Context, folderStore models.FolderReaderWr // transferZipFolderHierarchy creates the folder hierarchy for zipFileID under newPath, and removes // ZipFileID from folders under oldPath. -func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, zipFileID models.FileID, oldPath string, newPath string) error { - zipFolders, err := folderStore.FindByZipFileID(ctx, zipFileID) +func (m zipHierarchyMover) transferZipFolderHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error { + zipFolders, err := m.folderStore.FindByZipFileID(ctx, zipFileID) if err != nil { return err } @@ -83,7 +109,7 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe } newZfPath := filepath.Join(newPath, relZfPath) - newFolder, err := GetOrCreateFolderHierarchy(ctx, folderStore, newZfPath) + newFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfPath, m.rootPaths) if err != nil { return err } @@ -91,14 +117,14 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe // add ZipFileID to new folder logger.Debugf("adding zip file %s to folder %s", zipFileID, newFolder.Path) newFolder.ZipFileID = &zipFileID - if err = folderStore.Update(ctx, newFolder); err != nil { + if err = m.folderStore.Update(ctx, newFolder); err != nil { return err } // remove ZipFileID from old folder logger.Debugf("removing zip file %s from folder %s", zipFileID, oldFolder.Path) oldFolder.ZipFileID = nil - if err = folderStore.Update(ctx, oldFolder); err != nil { + if err = m.folderStore.Update(ctx, oldFolder); err != nil { return err } } @@ -106,9 +132,9 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe return nil } -func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCreator, files models.FileFinderUpdater, zipFileID models.FileID, oldPath, newPath string) error { +func (m zipHierarchyMover) transferZipFileEntries(ctx context.Context, zipFileID models.FileID, oldPath, newPath string) error { // move contained files if file is a zip file - zipFiles, err := files.FindByZipFileID(ctx, zipFileID) + zipFiles, err := m.files.FindByZipFileID(ctx, zipFileID) if err != nil { return fmt.Errorf("finding contained files in file %s: %w", oldPath, err) } @@ -129,7 +155,7 @@ func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCrea newZfDir := filepath.Join(newPath, relZfDir) // folder should have been created by transferZipFolderHierarchy - newZfFolder, err := GetOrCreateFolderHierarchy(ctx, folders, newZfDir) + newZfFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfDir, m.rootPaths) if err != nil { return fmt.Errorf("getting or creating folder hierarchy: %w", err) } @@ -137,7 +163,7 @@ func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCrea // update file parent folder zfBase.ParentFolderID = newZfFolder.ID logger.Debugf("moving %s to folder %s", zfBase.Path, newZfFolder.Path) - if err := files.Update(ctx, zf); err != nil { + if err := m.files.Update(ctx, zf); err != nil { return fmt.Errorf("updating file %s: %w", oldZfPath, err) } } diff --git a/pkg/file/folder_rename_detect.go b/pkg/file/folder_rename_detect.go index cfae7e4fb..d45593b28 100644 --- a/pkg/file/folder_rename_detect.go +++ b/pkg/file/folder_rename_detect.go @@ -2,7 +2,6 @@ package file import ( "context" - "errors" "fmt" "io/fs" @@ -88,6 +87,11 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode r := s.Repository + zipFilePath := "" + if file.ZipFile != nil { + zipFilePath = file.ZipFile.Base().Path + } + if err := SymWalk(file.FS, file.Path, func(path string, d fs.DirEntry, err error) error { if err != nil { // don't let errors prevent scanning @@ -111,7 +115,7 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode return nil } - if !s.AcceptEntry(ctx, path, info) { + if !s.AcceptEntry(ctx, path, info, zipFilePath) { return nil } @@ -161,9 +165,7 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode continue } - if !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("checking for parent folder %q: %w", pf.Path, err) - } + // treat any error as missing folder // parent folder is missing, possible candidate // count the total number of files in the existing folder diff --git a/pkg/file/handler.go b/pkg/file/handler.go index 10616eefa..b4056f195 100644 --- a/pkg/file/handler.go +++ b/pkg/file/handler.go @@ -9,7 +9,7 @@ import ( // PathFilter provides a filter function for paths. type PathFilter interface { - Accept(ctx context.Context, path string, info fs.FileInfo) bool + Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool } type PathFilterFunc func(path string) bool diff --git a/pkg/file/move.go b/pkg/file/move.go index ba2a496bb..1f0a5012c 100644 --- a/pkg/file/move.go +++ b/pkg/file/move.go @@ -45,9 +45,12 @@ type Mover struct { moved map[string]string foldersCreated []string + + // needed for creating folder hierarchy when moving zip file entries + rootPaths []string } -func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter) *Mover { +func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter, rootPaths []string) *Mover { return &Mover{ Files: fileStore, Folders: folderStore, @@ -55,6 +58,7 @@ func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReade renamerRemoverImpl: newRenamerRemoverImpl(), mkDirFn: os.Mkdir, }, + rootPaths: rootPaths, } } @@ -87,7 +91,13 @@ func (m *Mover) Move(ctx context.Context, f models.File, folder *models.Folder, return fmt.Errorf("file %s already exists", newPath) } - if err := transferZipHierarchy(ctx, m.Folders, m.Files, fBase.ID, oldPath, newPath); err != nil { + zipMover := zipHierarchyMover{ + folderStore: m.Folders, + files: m.Files, + rootPaths: m.rootPaths, + } + + if err := zipMover.transferZipHierarchy(ctx, fBase.ID, oldPath, newPath); err != nil { return fmt.Errorf("moving folder hierarchy for file %s: %w", fBase.Path, err) } @@ -195,6 +205,25 @@ func correctSubFolderHierarchy(ctx context.Context, rw models.FolderReaderWriter logger.Debugf("updating folder %s to %s", oldPath, correctPath) + // #6427 - ensure folder entry with new path doesn't already exist + const caseSensitive = true + existing, err := rw.FindByPath(ctx, correctPath, caseSensitive) + if err != nil { + return fmt.Errorf("finding folder by path %s: %w", correctPath, err) + } + + if existing != nil { + // this should no longer be possible, but if it does happen, log a warning + // and skip updating this folder and its subfolders + logger.Warnf("folder with path %s already exists, setting parent_folder_id of %s to NULL and skipping", correctPath, oldPath) + f.ParentFolderID = nil + if err := rw.Update(ctx, f); err != nil { + return fmt.Errorf("updating folder parent id to NULL for folder %s: %w", oldPath, err) + } + + continue + } + f.Path = correctPath if err := rw.Update(ctx, f); err != nil { return fmt.Errorf("updating folder path %s -> %s: %w", oldPath, f.Path, err) diff --git a/pkg/file/scan.go b/pkg/file/scan.go index d9a58ad44..4cfcaf7ae 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "path/filepath" + "slices" "strings" "sync" "time" @@ -60,6 +61,10 @@ type Scanner struct { // handlers are called after a file has been scanned. FileHandlers []Handler + // RootPaths form the top-level paths for the library. + // Used to determine the root of the folder hierarchy when creating folders. + RootPaths []string + // Rescan indicates whether files should be rescanned even if they haven't changed. Rescan bool @@ -106,12 +111,12 @@ type ScannedFile struct { } // AcceptEntry determines if the file entry should be accepted for scanning -func (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo) bool { +func (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool { // always accept if there's no filters accept := len(s.ScanFilters) == 0 for _, filter := range s.ScanFilters { // accept if any filter accepts the file - if filter.Accept(ctx, path, info) { + if filter.Accept(ctx, path, info, zipFilePath) { accept = true break } @@ -193,6 +198,10 @@ func (s *Scanner) ScanFolder(ctx context.Context, file ScannedFile) (*models.Fol return f, err } +func (s *Scanner) isRootPath(path string) bool { + return path == "." || slices.Contains(s.RootPaths, path) +} + func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) { renamed, err := s.handleFolderRename(ctx, file) if err != nil { @@ -212,18 +221,16 @@ func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Fo UpdatedAt: now, } - dir := filepath.Dir(file.Path) - if dir != "." { - parentFolderID, err := s.getFolderID(ctx, dir) + if !s.isRootPath(file.Path) { + dir := filepath.Dir(file.Path) + + // create full folder hierarchy if parent folder doesn't exist, and set parent folder ID + parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, dir, s.RootPaths) if err != nil { return nil, fmt.Errorf("getting parent folder %q: %w", dir, err) } - // if parent folder doesn't exist, assume it's a top-level folder - // this may not be true if we're using multiple goroutines - if parentFolderID != nil { - toCreate.ParentFolderID = parentFolderID - } + toCreate.ParentFolderID = &parentFolder.ID } txn.AddPostCommitHook(ctx, func(ctx context.Context) { @@ -312,6 +319,19 @@ func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing } } + // handle case where parent folder was not previously set + if existing.ParentFolderID == nil && !s.isRootPath(existing.Path) { + logger.Infof("Existing folder entry %q has no parent folder. Creating folder hierarchy and setting parent ID...", existing.Path) + + // create full folder hierarchy if parent folder doesn't exist, and set parent folder ID + parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, filepath.Dir(f.Path), s.RootPaths) + if err != nil { + return nil, fmt.Errorf("getting parent folder for %q: %w", f.Path, err) + } + existing.ParentFolderID = &parentFolder.ID + update = true + } + if update { var err error if err = s.Repository.Folder.Update(ctx, existing); err != nil { @@ -323,10 +343,15 @@ func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing } type ScanFileResult struct { - File models.File - New bool - Renamed bool - Updated bool + File models.File + New bool + Renamed bool + Updated bool + FingerprintChanged bool +} + +func (r ScanFileResult) IsUnchanged() bool { + return !r.New && !r.Renamed && !r.Updated } // ScanFile scans the provided file into the database, returning the scan result. @@ -393,13 +418,31 @@ func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult baseFile.UpdatedAt = now // find the parent folder - parentFolderID, err := s.getFolderID(ctx, filepath.Dir(path)) + folderPath := filepath.Dir(path) + parentFolderID, err := s.getFolderID(ctx, folderPath) if err != nil { return nil, fmt.Errorf("getting parent folder for %q: %w", path, err) } if parentFolderID == nil { - return nil, fmt.Errorf("parent folder for %q doesn't exist", path) + // parent folders should have been created before scanning this file in a recursive scan + // assume that we are scanning specifically and only this file, + // so we should create the parent folder hierarchy if it doesn't exist + if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { + parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, folderPath, s.RootPaths) + if err != nil { + return fmt.Errorf("getting parent folder for %q: %w", f.Path, err) + } + + parentFolderID = &parentFolder.ID + return nil + }); err != nil { + return nil, err + } + } + if parentFolderID == nil { + // shouldn't happen + return nil, fmt.Errorf("parent folder ID is nil for %q", path) } baseFile.ParentFolderID = *parentFolderID @@ -419,7 +462,11 @@ func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult // determine if the file is renamed from an existing file in the store // do this after decoration so that missing fields can be populated - renamed, err := s.handleRename(ctx, file, fp) + zipFilePath := "" + if f.ZipFile != nil { + zipFilePath = f.ZipFile.Base().Path + } + renamed, err := s.handleRename(ctx, file, fp, zipFilePath) if err != nil { return nil, err } @@ -529,7 +576,7 @@ func (s *Scanner) getFileFS(f *models.BaseFile) (models.FS, error) { return fs.OpenZip(zipPath, zipSize) } -func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) { +func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint, zipFilePath string) (models.File, error) { var others []models.File for _, tfp := range fp { @@ -571,7 +618,7 @@ func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.F // treat as a move missing = append(missing, other) } - case !s.AcceptEntry(ctx, other.Base().Path, info): + case !s.AcceptEntry(ctx, other.Base().Path, info, zipFilePath): // #4393 - if the file is no longer in the configured library paths, treat it as a move logger.Debugf("File %q no longer in library paths. Treating as a move.", other.Base().Path) missing = append(missing, other) @@ -604,13 +651,19 @@ func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.F fBaseCopy.Fingerprints = updatedBase.Fingerprints *updatedBase = fBaseCopy + zipMover := zipHierarchyMover{ + folderStore: s.Repository.Folder, + files: s.Repository.File, + rootPaths: s.RootPaths, + } + if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { if err := s.Repository.File.Update(ctx, updated); err != nil { return fmt.Errorf("updating file for rename %q: %w", newPath, err) } if s.IsZipFile(updatedBase.Basename) { - if err := transferZipHierarchy(ctx, s.Repository.Folder, s.Repository.File, updatedBase.ID, oldPath, newPath); err != nil { + if err := zipMover.transferZipHierarchy(ctx, updatedBase.ID, oldPath, newPath); err != nil { return fmt.Errorf("moving zip hierarchy for renamed zip file %q: %w", newPath, err) } } @@ -743,6 +796,9 @@ func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing mo return nil, err } + oldFingerprints := existing.Base().Fingerprints + fingerprintChanged := fp.ContentsChanged(oldFingerprints) + s.removeOutdatedFingerprints(existing, fp) existing.SetFingerprints(fp) @@ -766,8 +822,9 @@ func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing mo return nil, err } return &ScanFileResult{ - File: existing, - Updated: true, + File: existing, + Updated: true, + FingerprintChanged: fingerprintChanged, }, nil } diff --git a/pkg/file/stashignore.go b/pkg/file/stashignore.go new file mode 100644 index 000000000..a6de050c6 --- /dev/null +++ b/pkg/file/stashignore.go @@ -0,0 +1,264 @@ +package file + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + + lru "github.com/hashicorp/golang-lru/v2" + ignore "github.com/sabhiram/go-gitignore" + "github.com/stashapp/stash/pkg/logger" +) + +const stashIgnoreFilename = ".stashignore" + +// entriesCacheSize is the size of the LRU cache for collected ignore entries. +// This cache stores the computed list of ignore entries per directory, avoiding +// repeated directory tree walks for files in the same directory. +const entriesCacheSize = 500 + +// StashIgnoreFilter implements PathFilter to exclude files/directories +// based on .stashignore files with gitignore-style patterns. +type StashIgnoreFilter struct { + // cache stores compiled ignore patterns per directory. + cache sync.Map // map[string]*ignoreEntry + // entriesCache stores collected ignore entries per (dir, libraryRoot) pair. + // This avoids recomputing the entry list for every file in the same directory. + entriesCache *lru.Cache[string, []*ignoreEntry] +} + +// ignoreEntry holds the compiled ignore patterns for a directory. +type ignoreEntry struct { + // patterns is the compiled gitignore matcher for this directory. + patterns *ignore.GitIgnore + // dir is the directory this entry applies to. + dir string +} + +// NewStashIgnoreFilter creates a new StashIgnoreFilter. +func NewStashIgnoreFilter() *StashIgnoreFilter { + // Create the LRU cache for collected entries. + // Ignore error as it only fails if size <= 0. + entriesCache, _ := lru.New[string, []*ignoreEntry](entriesCacheSize) + return &StashIgnoreFilter{ + entriesCache: entriesCache, + } +} + +// Accept returns true if the path should be included in the scan. +// It checks for .stashignore files in the directory hierarchy and +// applies gitignore-style pattern matching. +// The libraryRoot parameter bounds the search for .stashignore files - +// only directories within the library root are checked. +// zipFilepath is the path of the zip file if the file is inside a zip. +// .stashignore files will not be read within zip files. +func (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.FileInfo, libraryRoot string, zipFilePath string) bool { + // If no library root provided, accept the file (safety fallback). + if libraryRoot == "" { + return true + } + + // Get the directory containing this path. + dir := filepath.Dir(path) + + // If the file is inside a zip, use the zip file's directory as the base for .stashignore lookup. + if zipFilePath != "" { + dir = filepath.Dir(zipFilePath) + } + + // Collect all applicable ignore entries from library root to this directory. + entries := f.collectIgnoreEntries(dir, libraryRoot) + + // If no .stashignore files found, accept the file. + if len(entries) == 0 { + return true + } + + // Check each ignore entry in order (from root to most specific). + // Later entries can override earlier ones with negation patterns. + ignored := false + for _, entry := range entries { + // Get path relative to the ignore file's directory. + entryRelPath, err := filepath.Rel(entry.dir, path) + if err != nil { + continue + } + entryRelPath = filepath.ToSlash(entryRelPath) + if info.IsDir() { + entryRelPath += "/" + } + + if entry.patterns.MatchesPath(entryRelPath) { + ignored = true + } + } + + return !ignored +} + +// collectIgnoreEntries gathers all ignore entries from library root to the given directory. +// It walks up the directory tree from dir to libraryRoot and returns entries in order +// from root to most specific. Results are cached to avoid repeated computation for +// files in the same directory. +func (f *StashIgnoreFilter) collectIgnoreEntries(dir string, libraryRoot string) []*ignoreEntry { + // Clean paths for consistent comparison and cache key generation. + dir = filepath.Clean(dir) + libraryRoot = filepath.Clean(libraryRoot) + + // Build cache key from dir and libraryRoot. + cacheKey := dir + "\x00" + libraryRoot + + // Check the entries cache first. + if cached, ok := f.entriesCache.Get(cacheKey); ok { + return cached + } + + // Try subdirectory shortcut: if parent's entries are cached, extend them. + if dir != libraryRoot { + parent := filepath.Dir(dir) + if isPathInOrEqual(libraryRoot, parent) { + parentKey := parent + "\x00" + libraryRoot + if parentEntries, ok := f.entriesCache.Get(parentKey); ok { + // Parent is cached - just check if current dir has a .stashignore. + entries := parentEntries + if entry := f.getOrLoadIgnoreEntry(dir); entry != nil { + // Copy parent slice and append to avoid mutating cached slice. + entries = make([]*ignoreEntry, len(parentEntries), len(parentEntries)+1) + copy(entries, parentEntries) + entries = append(entries, entry) + } + f.entriesCache.Add(cacheKey, entries) + return entries + } + } + } + + // No cache hit - compute from scratch. + // Walk up from dir to library root, collecting directories. + var dirs []string + current := dir + for { + // Check if we're still within the library root. + // nolint:staticcheck // QF1006 - we could make this the for condition + // but I don't think it improves readability + if !isPathInOrEqual(libraryRoot, current) { + break + } + + dirs = append(dirs, current) + + // Stop if we've reached the library root. + if current == libraryRoot { + break + } + + parent := filepath.Dir(current) + if parent == current { + // Reached filesystem root without finding library root. + break + } + current = parent + } + + // Reverse to get root-to-leaf order. + for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 { + dirs[i], dirs[j] = dirs[j], dirs[i] + } + + // Check each directory for .stashignore files. + var entries []*ignoreEntry + for _, d := range dirs { + if entry := f.getOrLoadIgnoreEntry(d); entry != nil { + entries = append(entries, entry) + } + } + + // Cache the result. + f.entriesCache.Add(cacheKey, entries) + + return entries +} + +// isPathInOrEqual checks if path is equal to or inside root. +func isPathInOrEqual(root, path string) bool { + if path == root { + return true + } + // Check if path starts with root + separator. + return strings.HasPrefix(path, root+string(filepath.Separator)) +} + +// getOrLoadIgnoreEntry returns the cached ignore entry for a directory, or loads it. +func (f *StashIgnoreFilter) getOrLoadIgnoreEntry(dir string) *ignoreEntry { + // Check cache first. + if cached, ok := f.cache.Load(dir); ok { + entry := cached.(*ignoreEntry) + if entry.patterns == nil { + return nil // Cached negative result. + } + return entry + } + + // Try to load .stashignore from this directory. + stashIgnorePath := filepath.Join(dir, stashIgnoreFilename) + patterns, err := f.loadIgnoreFile(stashIgnorePath) + if err != nil { + if !os.IsNotExist(err) { + logger.Warnf("Failed to load .stashignore from %s: %v", dir, err) + } + f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir}) + return nil + } + if patterns == nil { + // File exists but has no patterns (empty or only comments). + f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir}) + return nil + } + + logger.Debugf("Loaded .stashignore from %s", dir) + + entry := &ignoreEntry{ + patterns: patterns, + dir: dir, + } + f.cache.Store(dir, entry) + return entry +} + +// loadIgnoreFile loads and compiles a .stashignore file. +func (f *StashIgnoreFilter) loadIgnoreFile(path string) (*ignore.GitIgnore, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + lines := strings.Split(string(data), "\n") + var patterns []string + + for _, line := range lines { + // Trim trailing whitespace (but preserve leading for patterns). + line = strings.TrimRight(line, " \t\r") + + // Skip empty lines. + if line == "" { + continue + } + + // Skip comments (but not escaped #). + if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "\\#") { + continue + } + + patterns = append(patterns, line) + } + + if len(patterns) == 0 { + // File exists but has no patterns (e.g., only comments). + return nil, nil + } + + return ignore.CompileIgnoreLines(patterns...), nil +} diff --git a/pkg/file/stashignore_test.go b/pkg/file/stashignore_test.go new file mode 100644 index 000000000..41668b51b --- /dev/null +++ b/pkg/file/stashignore_test.go @@ -0,0 +1,523 @@ +package file + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "sort" + "testing" +) + +// Helper to create an empty file. +func createTestFile(t *testing.T, dir, name string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create directory for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte{}, 0644); err != nil { + t.Fatalf("failed to create file %s: %v", path, err) + } +} + +// Helper to create a file with content. +func createTestFileWithContent(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create directory for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to create file %s: %v", path, err) + } +} + +// Helper to create a directory. +func createTestDir(t *testing.T, dir, name string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatalf("failed to create directory %s: %v", path, err) + } +} + +// walkAndFilter walks the directory tree and returns paths accepted by the filter. +// Returns paths relative to root for easier assertion. +func walkAndFilter(t *testing.T, root string, filter *StashIgnoreFilter) []string { + t.Helper() + var accepted []string + ctx := context.Background() + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip the root directory itself. + if path == root { + return nil + } + + info, err := d.Info() + if err != nil { + return err + } + + if filter.Accept(ctx, path, info, root, "") { + relPath, _ := filepath.Rel(root, path) + accepted = append(accepted, relPath) + } else if info.IsDir() { + // If directory is rejected, skip it. + return filepath.SkipDir + } + + return nil + }) + + if err != nil { + t.Fatalf("walk failed: %v", err) + } + + sort.Strings(accepted) + return accepted +} + +// assertPathsEqual checks that the accepted paths match expected. +func assertPathsEqual(t *testing.T, expected, actual []string) { + t.Helper() + sort.Strings(expected) + + if len(expected) != len(actual) { + t.Errorf("path count mismatch:\nexpected %d: %v\nactual %d: %v", len(expected), expected, len(actual), actual) + return + } + + for i := range expected { + if expected[i] != actual[i] { + t.Errorf("path mismatch at index %d:\nexpected: %s\nactual: %s", i, expected[i], actual[i]) + } + } +} + +func TestStashIgnore_ExactFilename(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "video2.mp4") + createTestFile(t, tmpDir, "ignore_me.mp4") + + // Create .stashignore that excludes exact filename. + createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + "video2.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_WildcardPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "video2.mp4") + createTestFile(t, tmpDir, "temp1.tmp") + createTestFile(t, tmpDir, "temp2.tmp") + createTestFile(t, tmpDir, "notes.log") + + // Create .stashignore that excludes by extension. + createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + "video2.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_DirectoryExclusion(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestDir(t, tmpDir, "excluded_dir") + createTestFile(t, tmpDir, "excluded_dir/video2.mp4") + createTestFile(t, tmpDir, "excluded_dir/video3.mp4") + createTestDir(t, tmpDir, "included_dir") + createTestFile(t, tmpDir, "included_dir/video4.mp4") + + // Create .stashignore that excludes a directory. + createTestFileWithContent(t, tmpDir, ".stashignore", "excluded_dir/\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "included_dir", + "included_dir/video4.mp4", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_NegationPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "file1.tmp") + createTestFile(t, tmpDir, "file2.tmp") + createTestFile(t, tmpDir, "keep_this.tmp") + + // Create .stashignore that excludes *.tmp but keeps one. + createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n!keep_this.tmp\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "keep_this.tmp", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_CommentsAndEmptyLines(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "ignore_me.mp4") + + // Create .stashignore with comments and empty lines. + stashignore := `# This is a comment +ignore_me.mp4 + +# Another comment + +` + createTestFileWithContent(t, tmpDir, ".stashignore", stashignore) + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_NestedStashIgnoreFiles(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "root_video.mp4") + createTestFile(t, tmpDir, "root_ignore.tmp") + createTestDir(t, tmpDir, "subdir") + createTestFile(t, tmpDir, "subdir/sub_video.mp4") + createTestFile(t, tmpDir, "subdir/sub_ignore.log") + createTestFile(t, tmpDir, "subdir/also_tmp.tmp") + + // Root .stashignore excludes *.tmp. + createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n") + + // Subdir .stashignore excludes *.log. + createTestFileWithContent(t, tmpDir, "subdir/.stashignore", "*.log\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + // *.tmp from root should apply everywhere. + // *.log from subdir should only apply in subdir. + expected := []string{ + ".stashignore", + "root_video.mp4", + "subdir", + "subdir/.stashignore", + "subdir/sub_video.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_PathPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestDir(t, tmpDir, "subdir") + createTestFile(t, tmpDir, "subdir/video2.mp4") + createTestFile(t, tmpDir, "subdir/skip_this.mp4") + + // Create .stashignore that excludes a specific path. + createTestFileWithContent(t, tmpDir, ".stashignore", "subdir/skip_this.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "subdir", + "subdir/video2.mp4", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_DoubleStarPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestDir(t, tmpDir, "a") + createTestFile(t, tmpDir, "a/video2.mp4") + createTestDir(t, tmpDir, "a/temp") + createTestFile(t, tmpDir, "a/temp/video3.mp4") + createTestDir(t, tmpDir, "a/b") + createTestDir(t, tmpDir, "a/b/temp") + createTestFile(t, tmpDir, "a/b/temp/video4.mp4") + + // Create .stashignore that excludes temp directories at any level. + createTestFileWithContent(t, tmpDir, ".stashignore", "**/temp/\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "a", + "a/b", + "a/video2.mp4", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_LeadingSlashPattern(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "ignore.mp4") + createTestDir(t, tmpDir, "subdir") + createTestFile(t, tmpDir, "subdir/ignore.mp4") + + // Create .stashignore that excludes only at root level. + createTestFileWithContent(t, tmpDir, ".stashignore", "/ignore.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + // Only root ignore.mp4 should be excluded. + expected := []string{ + ".stashignore", + "subdir", + "subdir/ignore.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_NoStashIgnoreFile(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files without any .stashignore. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "video2.mp4") + createTestDir(t, tmpDir, "subdir") + createTestFile(t, tmpDir, "subdir/video3.mp4") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + // All files should be accepted. + expected := []string{ + "subdir", + "subdir/video3.mp4", + "video1.mp4", + "video2.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_HiddenDirectories(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files including hidden directory. + createTestFile(t, tmpDir, "video1.mp4") + createTestDir(t, tmpDir, ".hidden") + createTestFile(t, tmpDir, ".hidden/video2.mp4") + + // Create .stashignore that excludes hidden directories. + createTestFileWithContent(t, tmpDir, ".stashignore", ".*\n!.stashignore\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_MultiplePatternsSameLine(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "file.tmp") + createTestFile(t, tmpDir, "file.log") + createTestFile(t, tmpDir, "file.bak") + + // Each pattern should be on its own line. + createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n*.bak\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_TrailingSpaces(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "ignore_me.mp4") + + // Pattern with trailing spaces (should be trimmed). + createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4 \n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_EscapedHash(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "#filename.mp4") + + // Escaped hash should match literal # character. + createTestFileWithContent(t, tmpDir, ".stashignore", "\\#filename.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "video1.mp4", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_CaseSensitiveMatching(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files - use distinct names that work on all filesystems. + createTestFile(t, tmpDir, "video_lower.mp4") + createTestFile(t, tmpDir, "VIDEO_UPPER.mp4") + createTestFile(t, tmpDir, "other.avi") + + // Pattern should match exactly (case-sensitive). + createTestFileWithContent(t, tmpDir, ".stashignore", "video_lower.mp4\n") + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + // Only exact match is excluded. + expected := []string{ + ".stashignore", + "VIDEO_UPPER.mp4", + "other.avi", + } + + assertPathsEqual(t, expected, accepted) +} + +func TestStashIgnore_ComplexScenario(t *testing.T) { + tmpDir := t.TempDir() + + // Create a complex directory structure. + createTestFile(t, tmpDir, "video1.mp4") + createTestFile(t, tmpDir, "video2.avi") + createTestFile(t, tmpDir, "thumbnail.jpg") + createTestFile(t, tmpDir, "metadata.nfo") + createTestDir(t, tmpDir, "movies") + createTestFile(t, tmpDir, "movies/movie1.mp4") + createTestFile(t, tmpDir, "movies/movie1.nfo") + createTestDir(t, tmpDir, "movies/.thumbnails") + createTestFile(t, tmpDir, "movies/.thumbnails/thumb1.jpg") + createTestDir(t, tmpDir, "temp") + createTestFile(t, tmpDir, "temp/processing.mp4") + createTestDir(t, tmpDir, "backup") + createTestFile(t, tmpDir, "backup/video1.mp4.bak") + + // Complex .stashignore. + stashignore := `# Ignore metadata files +*.nfo + +# Ignore hidden directories +.* +!.stashignore + +# Ignore temp and backup directories +temp/ +backup/ + +# But keep thumbnails in specific location +!movies/.thumbnails/ +` + createTestFileWithContent(t, tmpDir, ".stashignore", stashignore) + + filter := NewStashIgnoreFilter() + accepted := walkAndFilter(t, tmpDir, filter) + + expected := []string{ + ".stashignore", + "movies", + "movies/.thumbnails", + "movies/.thumbnails/thumb1.jpg", + "movies/movie1.mp4", + "thumbnail.jpg", + "video1.mp4", + "video2.avi", + } + + assertPathsEqual(t, expected, accepted) +} diff --git a/pkg/file/video/caption.go b/pkg/file/video/caption.go index 43723864f..46317d90c 100644 --- a/pkg/file/video/caption.go +++ b/pkg/file/video/caption.go @@ -90,11 +90,20 @@ type CaptionUpdater interface { UpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error } +// MatchesCaption returns true if the caption file matches the video file based on the filename +func MatchesCaption(videoPath, captionPath string) bool { + captionPrefix := getCaptionPrefix(captionPath) + videoPrefix := strings.TrimSuffix(videoPath, filepath.Ext(videoPath)) + "." + return captionPrefix == videoPrefix +} + // associates captions to scene/s with the same basename -func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) { +// returns true if the caption file was matched to a video file and processed, false otherwise +func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) bool { captionLang := getCaptionsLangFromPath(captionPath) captionPrefix := getCaptionPrefix(captionPath) + matched := false if err := txn.WithTxn(ctx, txnMgr, func(ctx context.Context) error { var err error files, er := fqb.FindAllByPath(ctx, captionPrefix+"*", true) @@ -117,28 +126,36 @@ func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manag path := f.Base().Path logger.Debugf("Matched captions to file %s", path) + matched = true + captions, er := w.GetCaptions(ctx, fileID) - if er == nil { - fileExt := filepath.Ext(captionPath) - ext := fileExt[1:] - if !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present - newCaption := &models.VideoCaption{ - LanguageCode: captionLang, - Filename: filepath.Base(captionPath), - CaptionType: ext, - } - captions = append(captions, newCaption) - er = w.UpdateCaptions(ctx, fileID, captions) - if er == nil { - logger.Debugf("Updated captions for file %s. Added %s", path, captionLang) - } + if er != nil { + return fmt.Errorf("getting captions for file %s: %w", path, er) + } + + fileExt := filepath.Ext(captionPath) + ext := fileExt[1:] + if !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present + newCaption := &models.VideoCaption{ + LanguageCode: captionLang, + Filename: filepath.Base(captionPath), + CaptionType: ext, } + captions = append(captions, newCaption) + er = w.UpdateCaptions(ctx, fileID, captions) + if er != nil { + return fmt.Errorf("updating captions for file %s: %w", path, er) + } + + logger.Debugf("Updated captions for file %s. Added %s", path, captionLang) } } return err }); err != nil { logger.Error(err.Error()) } + + return matched } // CleanCaptions removes non existent/accessible language codes from captions diff --git a/pkg/file/zip.go b/pkg/file/zip.go index 5afcd5329..6d00c7e35 100644 --- a/pkg/file/zip.go +++ b/pkg/file/zip.go @@ -99,7 +99,9 @@ func (f *zipFS) rel(name string) (string, error) { relName, err := filepath.Rel(f.zipPath, name) if err != nil { - return "", fmt.Errorf("internal error getting relative path: %w", err) + // if the path is not relative to the zip path, then it's not found in the zip file, + // so treat this as a file not found + return "", fs.ErrNotExist } // convert relName to use slash, since zip files do so regardless diff --git a/pkg/fsutil/file.go b/pkg/fsutil/file.go index 1d0c0c473..05a127129 100644 --- a/pkg/fsutil/file.go +++ b/pkg/fsutil/file.go @@ -148,7 +148,7 @@ func Touch(path string) error { var ( replaceCharsRE = regexp.MustCompile(`[&=\\/:*"?_ ]`) - removeCharsRE = regexp.MustCompile(`[^[:alnum:]-.]`) + removeCharsRE = regexp.MustCompile(`[^\p{L}\p{N}\-.]`) multiHyphenRE = regexp.MustCompile(`\-+`) ) diff --git a/pkg/fsutil/file_test.go b/pkg/fsutil/file_test.go index 4d84f8a47..df1077df2 100644 --- a/pkg/fsutil/file_test.go +++ b/pkg/fsutil/file_test.go @@ -15,6 +15,9 @@ func TestSanitiseBasename(t *testing.T) { {"multi-hyphen", `hyphened--name`, "hyphened-name-2da2a58f"}, {"replaced characters", `a&b=c\d/:e*"f?_ g`, "a-b-c-d-e-f-g-ffca6fb0"}, {"removed characters", `foo!!bar@@and, more`, "foobarand-more-7cee02ab"}, + {"unicode cjk", `テスト`, "テスト-63b560db"}, + {"unicode korean", `시험`, "시험-3fcc7beb"}, + {"mixed unicode", `Test テスト`, "Test-テスト-366aff1e"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/fsutil/fs.go b/pkg/fsutil/fs.go index 10666bb63..032bec53c 100644 --- a/pkg/fsutil/fs.go +++ b/pkg/fsutil/fs.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "unicode" ) @@ -27,18 +26,10 @@ func IsFsPathCaseSensitive(path string) (bool, error) { if err != nil { // cannot be case flipped return false, err } - i := strings.LastIndex(path, base) - if i < 0 { // shouldn't happen - return false, fmt.Errorf("could not case flip path %s", path) - } - flipped := []rune(path) - for _, c := range fBase { // replace base of path with the flipped one ( we need to flip the base or last dir part ) - flipped[i] = c - i++ - } + flippedPath := filepath.Join(filepath.Dir(path), fBase) - fiCase, err := os.Stat(string(flipped)) + fiCase, err := os.Stat(flippedPath) if err != nil { // cannot stat the case flipped path return true, nil // fs of path should be case sensitive } diff --git a/pkg/fsutil/fs_test.go b/pkg/fsutil/fs_test.go index 522e95fa6..155e76ba5 100644 --- a/pkg/fsutil/fs_test.go +++ b/pkg/fsutil/fs_test.go @@ -41,4 +41,15 @@ func TestIsFsPathCaseSensitive_UnicodeByteLength(t *testing.T) { } // assert.True(t, r, "expected fs to be case sensitive") + + // Ensure that subfolders of a folder with multi-byte chars is not causing a panic + path3 := filepath.Join(dir, "NoPanic ❤️") + makeDir(path3) + path4 := filepath.Join(path3, "Test") + makeDir(path4) + + _, err = IsFsPathCaseSensitive(path4) + if err != nil { + t.Fatal(err) + } } diff --git a/pkg/gallery/import.go b/pkg/gallery/import.go index 22f3e6c44..e33297bdb 100644 --- a/pkg/gallery/import.go +++ b/pkg/gallery/import.go @@ -28,8 +28,9 @@ type Importer struct { Input jsonschema.Gallery MissingRefBehaviour models.ImportMissingRefEnum - ID int - gallery models.Gallery + ID int + gallery models.Gallery + customFields map[string]interface{} } func (i *Importer) PreImport(ctx context.Context) error { @@ -51,6 +52,8 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } + i.customFields = i.Input.CustomFields + return nil } @@ -356,7 +359,11 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { for _, f := range i.gallery.Files.List() { fileIDs = append(fileIDs, f.Base().ID) } - err := i.ReaderWriter.Create(ctx, &i.gallery, fileIDs) + err := i.ReaderWriter.Create(ctx, &models.CreateGalleryInput{ + Gallery: &i.gallery, + FileIDs: fileIDs, + CustomFields: i.customFields, + }) if err != nil { return nil, fmt.Errorf("error creating gallery: %v", err) } @@ -368,7 +375,12 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { func (i *Importer) Update(ctx context.Context, id int) error { gallery := i.gallery gallery.ID = id - err := i.ReaderWriter.Update(ctx, &gallery) + err := i.ReaderWriter.Update(ctx, &models.UpdateGalleryInput{ + Gallery: &gallery, + CustomFields: models.CustomFieldsInput{ + Full: i.customFields, + }, + }) if err != nil { return fmt.Errorf("error updating existing gallery: %v", err) } diff --git a/pkg/gallery/scan.go b/pkg/gallery/scan.go index 9d0313b17..7689bb9b6 100644 --- a/pkg/gallery/scan.go +++ b/pkg/gallery/scan.go @@ -17,14 +17,13 @@ type ScanCreatorUpdater interface { FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Gallery, error) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) - Create(ctx context.Context, newGallery *models.Gallery, fileIDs []models.FileID) error + models.GalleryCreator UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) AddFileID(ctx context.Context, id int, fileID models.FileID) error } type ScanSceneFinderUpdater interface { FindByPath(ctx context.Context, p string) ([]*models.Scene, error) - Update(ctx context.Context, updatedScene *models.Scene) error AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error } @@ -80,7 +79,10 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models. logger.Infof("%s doesn't exist. Creating new gallery...", f.Base().Path) - if err := h.CreatorUpdater.Create(ctx, &newGallery, []models.FileID{baseFile.ID}); err != nil { + if err := h.CreatorUpdater.Create(ctx, &models.CreateGalleryInput{ + Gallery: &newGallery, + FileIDs: []models.FileID{baseFile.ID}, + }); err != nil { return fmt.Errorf("creating new gallery: %w", err) } @@ -132,13 +134,14 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. if err := h.CreatorUpdater.AddFileID(ctx, i.ID, f.Base().ID); err != nil { return fmt.Errorf("adding file to gallery: %w", err) } - // update updated_at time - if _, err := h.CreatorUpdater.UpdatePartial(ctx, i.ID, models.NewGalleryPartial()); err != nil { - return fmt.Errorf("updating gallery: %w", err) - } } if !found || updateExisting { + // update updated_at time when file association or content changes + if _, err := h.CreatorUpdater.UpdatePartial(ctx, i.ID, models.NewGalleryPartial()); err != nil { + return fmt.Errorf("updating gallery: %w", err) + } + h.PluginCache.RegisterPostHooks(ctx, i.ID, hook.GalleryUpdatePost, nil, nil) } } diff --git a/pkg/gallery/scan_test.go b/pkg/gallery/scan_test.go new file mode 100644 index 000000000..4a89206e3 --- /dev/null +++ b/pkg/gallery/scan_test.go @@ -0,0 +1,108 @@ +package gallery + +import ( + "context" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { + const ( + testGalleryID = 1 + testFileID = 100 + ) + + existingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: "test.zip"} + + makeGallery := func() *models.Gallery { + return &models.Gallery{ + ID: testGalleryID, + Files: models.NewRelatedFiles([]models.File{existingFile}), + } + } + + tests := []struct { + name string + updateExisting bool + expectUpdate bool + }{ + { + name: "calls UpdatePartial when file content changed", + updateExisting: true, + expectUpdate: true, + }, + { + name: "skips UpdatePartial when file unchanged and already associated", + updateExisting: false, + expectUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := mocks.NewDatabase() + db.Gallery.On("GetFiles", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil) + + if tt.expectUpdate { + db.Gallery.On("UpdatePartial", mock.Anything, testGalleryID, mock.Anything). + Return(&models.Gallery{ID: testGalleryID}, nil) + } + + h := &ScanHandler{ + CreatorUpdater: db.Gallery, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Gallery{makeGallery()}, existingFile, tt.updateExisting) + assert.NoError(t, err) + }) + + if tt.expectUpdate { + db.Gallery.AssertCalled(t, "UpdatePartial", mock.Anything, testGalleryID, mock.Anything) + } else { + db.Gallery.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything) + } + }) + } +} + +func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { + const ( + testGalleryID = 1 + existFileID = 100 + newFileID = 200 + ) + + existingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.zip"} + newFile := &models.BaseFile{ID: models.FileID(newFileID), Path: "new.zip"} + + gallery := &models.Gallery{ + ID: testGalleryID, + Files: models.NewRelatedFiles([]models.File{existingFile}), + } + + db := mocks.NewDatabase() + db.Gallery.On("GetFiles", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil) + db.Gallery.On("AddFileID", mock.Anything, testGalleryID, models.FileID(newFileID)).Return(nil) + db.Gallery.On("UpdatePartial", mock.Anything, testGalleryID, mock.Anything). + Return(&models.Gallery{ID: testGalleryID}, nil) + + h := &ScanHandler{ + CreatorUpdater: db.Gallery, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Gallery{gallery}, newFile, false) + assert.NoError(t, err) + }) + + db.Gallery.AssertCalled(t, "AddFileID", mock.Anything, testGalleryID, models.FileID(newFileID)) + db.Gallery.AssertCalled(t, "UpdatePartial", mock.Anything, testGalleryID, mock.Anything) +} diff --git a/pkg/group/create.go b/pkg/group/create.go index 56d6b7a4e..9cc578b23 100644 --- a/pkg/group/create.go +++ b/pkg/group/create.go @@ -12,27 +12,37 @@ var ( ErrHierarchyLoop = errors.New("a group cannot be contained by one of its subgroups") ) -func (s *Service) Create(ctx context.Context, group *models.Group, frontimageData []byte, backimageData []byte) error { +func (s *Service) Create(ctx context.Context, input *models.CreateGroupInput) error { r := s.Repository + group := input.Group if err := s.validateCreate(ctx, group); err != nil { return err } - err := r.Create(ctx, group) + err := r.Create(ctx, input.Group) if err != nil { return err } - // update image table - if len(frontimageData) > 0 { - if err := r.UpdateFrontImage(ctx, group.ID, frontimageData); err != nil { + // set custom fields + if len(input.CustomFields) > 0 { + if err := r.SetCustomFields(ctx, group.ID, models.CustomFieldsInput{ + Full: input.CustomFields, + }); err != nil { return err } } - if len(backimageData) > 0 { - if err := r.UpdateBackImage(ctx, group.ID, backimageData); err != nil { + // update image table + if len(input.FrontImageData) > 0 { + if err := r.UpdateFrontImage(ctx, group.ID, input.FrontImageData); err != nil { + return err + } + } + + if len(input.BackImageData) > 0 { + if err := r.UpdateBackImage(ctx, group.ID, input.BackImageData); err != nil { return err } } diff --git a/pkg/group/export.go b/pkg/group/export.go index 418ce7bed..0a56fbdbb 100644 --- a/pkg/group/export.go +++ b/pkg/group/export.go @@ -11,61 +11,67 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -type ImageGetter interface { - GetFrontImage(ctx context.Context, movieID int) ([]byte, error) - GetBackImage(ctx context.Context, movieID int) ([]byte, error) +type GroupExportReader interface { + GetFrontImage(ctx context.Context, groupID int) ([]byte, error) + GetBackImage(ctx context.Context, groupID int) ([]byte, error) + GetCustomFields(ctx context.Context, groupID int) (map[string]interface{}, error) } -// ToJSON converts a Movie into its JSON equivalent. -func ToJSON(ctx context.Context, reader ImageGetter, studioReader models.StudioGetter, movie *models.Group) (*jsonschema.Group, error) { - newMovieJSON := jsonschema.Group{ - Name: movie.Name, - Aliases: movie.Aliases, - Director: movie.Director, - Synopsis: movie.Synopsis, - URLs: movie.URLs.List(), - CreatedAt: json.JSONTime{Time: movie.CreatedAt}, - UpdatedAt: json.JSONTime{Time: movie.UpdatedAt}, +// ToJSON converts a Group into its JSON equivalent. +func ToJSON(ctx context.Context, reader GroupExportReader, studioReader models.StudioGetter, group *models.Group) (*jsonschema.Group, error) { + newGroupJSON := jsonschema.Group{ + Name: group.Name, + Aliases: group.Aliases, + Director: group.Director, + Synopsis: group.Synopsis, + URLs: group.URLs.List(), + CreatedAt: json.JSONTime{Time: group.CreatedAt}, + UpdatedAt: json.JSONTime{Time: group.UpdatedAt}, } - if movie.Date != nil { - newMovieJSON.Date = movie.Date.String() + if group.Date != nil { + newGroupJSON.Date = group.Date.String() } - if movie.Rating != nil { - newMovieJSON.Rating = *movie.Rating + if group.Rating != nil { + newGroupJSON.Rating = *group.Rating } - if movie.Duration != nil { - newMovieJSON.Duration = *movie.Duration + if group.Duration != nil { + newGroupJSON.Duration = *group.Duration } - if movie.StudioID != nil { - studio, err := studioReader.Find(ctx, *movie.StudioID) + if group.StudioID != nil { + studio, err := studioReader.Find(ctx, *group.StudioID) if err != nil { return nil, fmt.Errorf("error getting movie studio: %v", err) } if studio != nil { - newMovieJSON.Studio = studio.Name + newGroupJSON.Studio = studio.Name } } - frontImage, err := reader.GetFrontImage(ctx, movie.ID) + frontImage, err := reader.GetFrontImage(ctx, group.ID) if err != nil { logger.Errorf("Error getting movie front image: %v", err) } if len(frontImage) > 0 { - newMovieJSON.FrontImage = utils.GetBase64StringFromData(frontImage) + newGroupJSON.FrontImage = utils.GetBase64StringFromData(frontImage) } - backImage, err := reader.GetBackImage(ctx, movie.ID) + backImage, err := reader.GetBackImage(ctx, group.ID) if err != nil { logger.Errorf("Error getting movie back image: %v", err) } if len(backImage) > 0 { - newMovieJSON.BackImage = utils.GetBase64StringFromData(backImage) + newGroupJSON.BackImage = utils.GetBase64StringFromData(backImage) } - return &newMovieJSON, nil + newGroupJSON.CustomFields, err = reader.GetCustomFields(ctx, group.ID) + if err != nil { + return nil, fmt.Errorf("getting group custom fields: %v", err) + } + + return &newGroupJSON, nil } diff --git a/pkg/group/export_test.go b/pkg/group/export_test.go index 5f8d9f7dc..bff50de5e 100644 --- a/pkg/group/export_test.go +++ b/pkg/group/export_test.go @@ -8,24 +8,26 @@ import ( "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "testing" "time" ) const ( - movieID = 1 - emptyID = 2 - errFrontImageID = 3 - errBackImageID = 4 - errStudioMovieID = 5 - missingStudioMovieID = 6 + movieID = iota + 1 + emptyID + errFrontImageID + errBackImageID + errStudioMovieID + missingStudioMovieID + errCustomFieldsID ) const ( - studioID = 1 - missingStudioID = 2 - errStudioID = 3 + studioID = iota + 1 + missingStudioID + errStudioID ) const movieName = "testMovie" @@ -51,6 +53,11 @@ const ( var ( frontImageBytes = []byte("frontImageBytes") backImageBytes = []byte("backImageBytes") + + emptyCustomFields = make(map[string]interface{}) + customFields = map[string]interface{}{ + "customField1": "customValue1", + } ) var movieStudio models.Studio = models.Studio{ @@ -88,7 +95,7 @@ func createEmptyMovie(id int) models.Group { } } -func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Group { +func createFullJSONMovie(studio, frontImage, backImage string, customFields map[string]interface{}) *jsonschema.Group { return &jsonschema.Group{ Name: movieName, Aliases: movieAliases, @@ -107,6 +114,7 @@ func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Group UpdatedAt: json.JSONTime{ Time: updateTime, }, + CustomFields: customFields, } } @@ -119,13 +127,15 @@ func createEmptyJSONMovie() *jsonschema.Group { UpdatedAt: json.JSONTime{ Time: updateTime, }, + CustomFields: emptyCustomFields, } } type testScenario struct { - movie models.Group - expected *jsonschema.Group - err bool + movie models.Group + customFields map[string]interface{} + expected *jsonschema.Group + err bool } var scenarios []testScenario @@ -134,36 +144,48 @@ func initTestTable() { scenarios = []testScenario{ { createFullMovie(movieID, studioID), - createFullJSONMovie(studioName, frontImage, backImage), + customFields, + createFullJSONMovie(studioName, frontImage, backImage, customFields), false, }, { createEmptyMovie(emptyID), + emptyCustomFields, createEmptyJSONMovie(), false, }, { createFullMovie(errFrontImageID, studioID), - createFullJSONMovie(studioName, "", backImage), + emptyCustomFields, + createFullJSONMovie(studioName, "", backImage, emptyCustomFields), // failure to get front image should not cause error false, }, { createFullMovie(errBackImageID, studioID), - createFullJSONMovie(studioName, frontImage, ""), + emptyCustomFields, + createFullJSONMovie(studioName, frontImage, "", emptyCustomFields), // failure to get back image should not cause error false, }, { createFullMovie(errStudioMovieID, errStudioID), + emptyCustomFields, nil, true, }, { createFullMovie(missingStudioMovieID, missingStudioID), - createFullJSONMovie("", frontImage, backImage), + emptyCustomFields, + createFullJSONMovie("", frontImage, backImage, emptyCustomFields), false, }, + { + createFullMovie(errCustomFieldsID, studioID), + customFields, + nil, + true, + }, } } @@ -179,6 +201,7 @@ func TestToJSON(t *testing.T) { db.Group.On("GetFrontImage", testCtx, emptyID).Return(nil, nil).Once().Maybe() db.Group.On("GetFrontImage", testCtx, errFrontImageID).Return(nil, imageErr).Once() db.Group.On("GetFrontImage", testCtx, errBackImageID).Return(frontImageBytes, nil).Once() + db.Group.On("GetFrontImage", testCtx, errCustomFieldsID).Return(nil, nil).Once() db.Group.On("GetBackImage", testCtx, movieID).Return(backImageBytes, nil).Once() db.Group.On("GetBackImage", testCtx, missingStudioMovieID).Return(backImageBytes, nil).Once() @@ -186,6 +209,11 @@ func TestToJSON(t *testing.T) { db.Group.On("GetBackImage", testCtx, errBackImageID).Return(nil, imageErr).Once() db.Group.On("GetBackImage", testCtx, errFrontImageID).Return(backImageBytes, nil).Maybe() db.Group.On("GetBackImage", testCtx, errStudioMovieID).Return(backImageBytes, nil).Maybe() + db.Group.On("GetBackImage", testCtx, errCustomFieldsID).Return(nil, nil).Once() + + db.Group.On("GetCustomFields", testCtx, movieID).Return(customFields, nil).Once() + db.Group.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, errors.New("error getting custom fields")).Once() + db.Group.On("GetCustomFields", testCtx, mock.Anything).Return(emptyCustomFields, nil).Times(4) studioErr := errors.New("error getting studio") diff --git a/pkg/group/import.go b/pkg/group/import.go index d7acad47c..1a332bac2 100644 --- a/pkg/group/import.go +++ b/pkg/group/import.go @@ -14,6 +14,7 @@ import ( type ImporterReaderWriter interface { models.GroupCreatorUpdater + models.CustomFieldsWriter FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) } @@ -233,6 +234,14 @@ func (i *Importer) PostImport(ctx context.Context, id int) error { } } + if len(i.Input.CustomFields) > 0 { + if err := i.ReaderWriter.SetCustomFields(ctx, id, models.CustomFieldsInput{ + Full: i.Input.CustomFields, + }); err != nil { + return fmt.Errorf("error setting custom fields: %v", err) + } + } + if len(i.frontImageData) > 0 { if err := i.ReaderWriter.UpdateFrontImage(ctx, id, i.frontImageData); err != nil { return fmt.Errorf("error setting group front image: %v", err) diff --git a/pkg/group/import_test.go b/pkg/group/import_test.go index 387ceb87e..006c91327 100644 --- a/pkg/group/import_test.go +++ b/pkg/group/import_test.go @@ -259,17 +259,29 @@ func TestImporterPostImport(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - ReaderWriter: db.Group, - StudioWriter: db.Studio, + ReaderWriter: db.Group, + StudioWriter: db.Studio, + Input: jsonschema.Group{ + CustomFields: customFields, + }, frontImageData: frontImageBytes, backImageData: backImageBytes, } updateMovieImageErr := errors.New("UpdateImages error") + customFieldsErr := errors.New("SetCustomFields error") + + customFieldsInput := models.CustomFieldsInput{ + Full: customFields, + } db.Group.On("UpdateFrontImage", testCtx, movieID, frontImageBytes).Return(nil).Once() - db.Group.On("UpdateBackImage", testCtx, movieID, backImageBytes).Return(nil).Once() db.Group.On("UpdateFrontImage", testCtx, errImageID, frontImageBytes).Return(updateMovieImageErr).Once() + db.Group.On("UpdateBackImage", testCtx, movieID, backImageBytes).Return(nil).Once() + + db.Group.On("SetCustomFields", testCtx, movieID, customFieldsInput).Return(nil).Once() + db.Group.On("SetCustomFields", testCtx, errImageID, customFieldsInput).Return(nil).Once() + db.Group.On("SetCustomFields", testCtx, errCustomFieldsID, customFieldsInput).Return(customFieldsErr).Once() err := i.PostImport(testCtx, movieID) assert.Nil(t, err) @@ -277,6 +289,9 @@ func TestImporterPostImport(t *testing.T) { err = i.PostImport(testCtx, errImageID) assert.NotNil(t, err) + err = i.PostImport(testCtx, errCustomFieldsID) + assert.NotNil(t, err) + db.AssertExpectations(t) } diff --git a/pkg/group/service.go b/pkg/group/service.go index ff6e03541..37094665a 100644 --- a/pkg/group/service.go +++ b/pkg/group/service.go @@ -10,6 +10,7 @@ type CreatorUpdater interface { models.GroupGetter models.GroupCreator models.GroupUpdater + models.CustomFieldsWriter models.ContainingGroupLoader models.SubGroupLoader diff --git a/pkg/hash/imagephash/phash.go b/pkg/hash/imagephash/phash.go index 73e8e3667..0af5adec9 100644 --- a/pkg/hash/imagephash/phash.go +++ b/pkg/hash/imagephash/phash.go @@ -3,10 +3,9 @@ package imagephash import ( "bytes" "context" + "errors" "fmt" "image" - "path/filepath" - "strings" "github.com/corona10/goimagehash" "github.com/stashapp/stash/pkg/ffmpeg" @@ -32,17 +31,9 @@ func Generate(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (*uint64, err } // loadImage loads an image from disk and decodes it. -// For AVIF files, ffmpeg is used to convert to BMP first since Go has no built-in AVIF decoder. +// Where Go has no built-in decoder for a specific format, ffmpeg is used to convert to BMP first. func loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image, error) { - ext := strings.ToLower(filepath.Ext(imageFile.Path)) - if ext == ".avif" { - // AVIF in zip files is not supported - ffmpeg cannot read files inside zips - if imageFile.Base().ZipFileID != nil { - return nil, fmt.Errorf("AVIF images in zip files are not supported for phash generation") - } - return loadImageFFmpeg(encoder, imageFile.Path) - } - + // try to load with Go's built-in decoders first for better performance reader, err := imageFile.Open(&file.OsFS{}) if err != nil { return nil, err @@ -55,6 +46,15 @@ func loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image } img, _, err := image.Decode(buf) + if errors.Is(err, image.ErrFormat) { + // try ffmpeg as a fallback for unsupported formats + // ffmpeg cannot read files inside zips + if imageFile.Base().ZipFileID != nil { + return nil, fmt.Errorf("ffmpeg fallback unsupported for images in zip files") + } + return loadImageFFmpeg(encoder, imageFile.Path) + } + if err != nil { return nil, fmt.Errorf("decoding image: %w", err) } diff --git a/pkg/image/export.go b/pkg/image/export.go index fdba6165c..eb5d5da27 100644 --- a/pkg/image/export.go +++ b/pkg/image/export.go @@ -2,16 +2,21 @@ package image import ( "context" + "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" ) +type ExportReader interface { + models.CustomFieldsReader +} + // ToBasicJSON converts a image object into its JSON object equivalent. It // does not convert the relationships to other objects, with the exception // of cover image. -func ToBasicJSON(image *models.Image) *jsonschema.Image { +func ToBasicJSON(ctx context.Context, reader ExportReader, image *models.Image) (*jsonschema.Image, error) { newImageJSON := jsonschema.Image{ Title: image.Title, Code: image.Code, @@ -33,11 +38,17 @@ func ToBasicJSON(image *models.Image) *jsonschema.Image { newImageJSON.Organized = image.Organized newImageJSON.OCounter = image.OCounter + var err error + newImageJSON.CustomFields, err = reader.GetCustomFields(ctx, image.ID) + if err != nil { + return nil, fmt.Errorf("getting image custom fields: %v", err) + } + for _, f := range image.Files.List() { newImageJSON.Files = append(newImageJSON.Files, f.Base().Path) } - return &newImageJSON + return &newImageJSON, nil } // GetStudioName returns the name of the provided image's studio. It returns an diff --git a/pkg/image/export_test.go b/pkg/image/export_test.go index 6adaf1d33..d0d36afbb 100644 --- a/pkg/image/export_test.go +++ b/pkg/image/export_test.go @@ -29,6 +29,10 @@ var ( dateObj, _ = models.ParseDate(date) organized = true ocounter = 2 + + customFields = map[string]interface{}{ + "customField1": "customValue1", + } ) const ( @@ -60,7 +64,7 @@ func createFullImage(id int) models.Image { } } -func createFullJSONImage() *jsonschema.Image { +func createFullJSONImage(customFields map[string]interface{}) *jsonschema.Image { return &jsonschema.Image{ Title: title, OCounter: ocounter, @@ -75,28 +79,40 @@ func createFullJSONImage() *jsonschema.Image { UpdatedAt: json.JSONTime{ Time: updateTime, }, + CustomFields: customFields, } } type basicTestScenario struct { - input models.Image - expected *jsonschema.Image + input models.Image + customFields map[string]interface{} + expected *jsonschema.Image } var scenarios = []basicTestScenario{ { createFullImage(imageID), - createFullJSONImage(), + customFields, + createFullJSONImage(customFields), }, } func TestToJSON(t *testing.T) { + db := mocks.NewDatabase() + db.Image.On("GetCustomFields", testCtx, imageID).Return(customFields, nil).Once() + for i, s := range scenarios { image := s.input - json := ToBasicJSON(&image) + json, err := ToBasicJSON(testCtx, db.Image, &image) + if err != nil { + t.Errorf("[%d] unexpected error: %s", i, err.Error()) + continue + } assert.Equal(t, s.expected, json, "[%d]", i) } + + db.AssertExpectations(t) } func createStudioImage(studioID int) models.Image { diff --git a/pkg/image/import.go b/pkg/image/import.go index c7ef7f00c..d8dfa987f 100644 --- a/pkg/image/import.go +++ b/pkg/image/import.go @@ -31,8 +31,9 @@ type Importer struct { Input jsonschema.Image MissingRefBehaviour models.ImportMissingRefEnum - ID int - image models.Image + ID int + image models.Image + customFields map[string]interface{} } func (i *Importer) PreImport(ctx context.Context) error { @@ -58,6 +59,8 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } + i.customFields = i.Input.CustomFields + return nil } @@ -344,7 +347,11 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { fileIDs = append(fileIDs, f.Base().ID) } - err := i.ReaderWriter.Create(ctx, &i.image, fileIDs) + err := i.ReaderWriter.Create(ctx, &models.CreateImageInput{ + Image: &i.image, + FileIDs: fileIDs, + CustomFields: i.customFields, + }) if err != nil { return nil, fmt.Errorf("error creating image: %v", err) } diff --git a/pkg/image/import_test.go b/pkg/image/import_test.go index 5d01d4b97..a693c4568 100644 --- a/pkg/image/import_test.go +++ b/pkg/image/import_test.go @@ -45,7 +45,8 @@ func TestImporterPreImportWithStudio(t *testing.T) { i := Importer{ StudioWriter: db.Studio, Input: jsonschema.Image{ - Studio: existingStudioName, + Studio: existingStudioName, + CustomFields: customFields, }, } @@ -57,6 +58,7 @@ func TestImporterPreImportWithStudio(t *testing.T) { err := i.PreImport(testCtx) assert.Nil(t, err) assert.Equal(t, existingStudioID, *i.image.StudioID) + assert.Equal(t, customFields, i.customFields) i.Input.Studio = existingStudioErr err = i.PreImport(testCtx) diff --git a/pkg/image/query.go b/pkg/image/query.go index b9b9e6628..958c9de9b 100644 --- a/pkg/image/query.go +++ b/pkg/image/query.go @@ -2,7 +2,9 @@ package image import ( "context" + "path/filepath" "strconv" + "strings" "github.com/stashapp/stash/pkg/models" ) @@ -46,6 +48,35 @@ func Query(ctx context.Context, qb Queryer, imageFilter *models.ImageFilterType, return images, nil } +// FilterFromPaths creates a ImageFilterType that filters using the provided +// paths. +func FilterFromPaths(paths []string) *models.ImageFilterType { + ret := &models.ImageFilterType{} + or := ret + sep := string(filepath.Separator) + + for _, p := range paths { + if !strings.HasSuffix(p, sep) { + p += sep + } + + if ret.Path == nil { + or = ret + } else { + newOr := &models.ImageFilterType{} + or.Or = newOr + or = newOr + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + return ret +} + func CountByPerformerID(ctx context.Context, r QueryCounter, id int) (int, error) { filter := &models.ImageFilterType{ Performers: &models.MultiCriterionInput{ diff --git a/pkg/image/scan.go b/pkg/image/scan.go index a6002057f..a1844bd38 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "slices" + "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -27,7 +28,7 @@ type ScanCreatorUpdater interface { GetFiles(ctx context.Context, relatedID int) ([]models.File, error) GetGalleryIDs(ctx context.Context, relatedID int) ([]int, error) - Create(ctx context.Context, newImage *models.Image, fileIDs []models.FileID) error + Create(ctx context.Context, newImage *models.CreateImageInput) error UpdatePartial(ctx context.Context, id int, updatedImage models.ImagePartial) (*models.Image, error) AddFileID(ctx context.Context, id int, fileID models.FileID) error } @@ -35,10 +36,15 @@ type ScanCreatorUpdater interface { type GalleryFinderCreator interface { FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error) FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error) - Create(ctx context.Context, newObject *models.Gallery, fileIDs []models.FileID) error + models.GalleryCreator UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) } +type ScanSceneFinderUpdater interface { + FindByPath(ctx context.Context, p string) ([]*models.Scene, error) + AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error +} + type ScanConfig interface { GetCreateGalleriesFromFolders() bool } @@ -48,8 +54,9 @@ type ScanGenerator interface { } type ScanHandler struct { - CreatorUpdater ScanCreatorUpdater - GalleryFinder GalleryFinderCreator + CreatorUpdater ScanCreatorUpdater + GalleryFinder GalleryFinderCreator + SceneFinderUpdater ScanSceneFinderUpdater ScanGenerator ScanGenerator @@ -62,19 +69,19 @@ type ScanHandler struct { func (h *ScanHandler) validate() error { if h.CreatorUpdater == nil { - return errors.New("CreatorUpdater is required") + return errors.New("internal error: CreatorUpdater is required") } if h.ScanGenerator == nil { - return errors.New("ScanGenerator is required") + return errors.New("internal error: ScanGenerator is required") } if h.GalleryFinder == nil { - return errors.New("GalleryFinder is required") + return errors.New("internal error: GalleryFinder is required") } if h.ScanConfig == nil { - return errors.New("ScanConfig is required") + return errors.New("internal error: ScanConfig is required") } if h.Paths == nil { - return errors.New("Paths is required") + return errors.New("internal error: Paths is required") } return nil @@ -124,7 +131,10 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models. logger.Infof("Adding %s to gallery %s", f.Base().Path, g.Path) } - if err := h.CreatorUpdater.Create(ctx, &newImage, []models.FileID{imageFile.ID}); err != nil { + if err := h.CreatorUpdater.Create(ctx, &models.CreateImageInput{ + Image: &newImage, + FileIDs: []models.FileID{imageFile.ID}, + }); err != nil { return fmt.Errorf("creating new image: %w", err) } @@ -207,8 +217,8 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. changed = true } - if changed { - // always update updated_at time + if changed || updateExisting { + // update updated_at time when file association or content changes imagePartial := models.NewImagePartial() imagePartial.GalleryIDs = galleryIDs @@ -226,9 +236,7 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. return fmt.Errorf("updating gallery updated at timestamp: %w", err) } } - } - if changed || updateExisting { h.PluginCache.RegisterPostHooks(ctx, i.ID, hook.ImageUpdatePost, nil, nil) } } @@ -252,9 +260,13 @@ func (h *ScanHandler) getOrCreateFolderBasedGallery(ctx context.Context, f model newGallery := models.NewGallery() newGallery.FolderID = &folderID + input := models.CreateGalleryInput{ + Gallery: &newGallery, + } + logger.Infof("Creating folder-based gallery for %s", filepath.Dir(f.Base().Path)) - if err := h.GalleryFinder.Create(ctx, &newGallery, nil); err != nil { + if err := h.GalleryFinder.Create(ctx, &input); err != nil { return nil, fmt.Errorf("creating folder based gallery: %w", err) } @@ -308,15 +320,48 @@ func (h *ScanHandler) getOrCreateZipBasedGallery(ctx context.Context, zipFile mo logger.Infof("%s doesn't exist. Creating new gallery...", zipFile.Base().Path) - if err := h.GalleryFinder.Create(ctx, &newGallery, []models.FileID{zipFile.Base().ID}); err != nil { + input := models.CreateGalleryInput{ + Gallery: &newGallery, + FileIDs: []models.FileID{zipFile.Base().ID}, + } + + if err := h.GalleryFinder.Create(ctx, &input); err != nil { return nil, fmt.Errorf("creating zip-based gallery: %w", err) } + // try to associate with scene + if err := h.associateScene(ctx, &newGallery, zipFile); err != nil { + return nil, fmt.Errorf("associating scene: %w", err) + } + h.PluginCache.RegisterPostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, nil, nil) return &newGallery, nil } +func (h *ScanHandler) associateScene(ctx context.Context, existing *models.Gallery, zipFile models.File) error { + galleryIDs := []int{existing.ID} + + path := zipFile.Base().Path + withoutExt := strings.TrimSuffix(path, filepath.Ext(path)) + ".*" + + // find scenes with a file that matches + scenes, err := h.SceneFinderUpdater.FindByPath(ctx, withoutExt) + if err != nil { + return err + } + + for _, scene := range scenes { + // found related Scene + logger.Infof("associate: Gallery %s is related to scene: %d", path, scene.ID) + if err := h.SceneFinderUpdater.AddGalleryIDs(ctx, scene.ID, galleryIDs); err != nil { + return err + } + } + + return nil +} + func (h *ScanHandler) getOrCreateGallery(ctx context.Context, f models.File) (*models.Gallery, error) { // don't create folder-based galleries for files in zip file if f.Base().ZipFile != nil { @@ -330,13 +375,13 @@ func (h *ScanHandler) getOrCreateGallery(ctx context.Context, f models.File) (*m if _, err := os.Stat(filepath.Join(folderPath, ".forcegallery")); err == nil { forceGallery = true } else if !errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("Could not test Path %s: %w", folderPath, err) + return nil, fmt.Errorf("could not test Path %s: %w", folderPath, err) } exemptGallery := false if _, err := os.Stat(filepath.Join(folderPath, ".nogallery")); err == nil { exemptGallery = true } else if !errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("Could not test Path %s: %w", folderPath, err) + return nil, fmt.Errorf("could not test Path %s: %w", folderPath, err) } if forceGallery || (h.ScanConfig.GetCreateGalleriesFromFolders() && !exemptGallery) { diff --git a/pkg/image/scan_test.go b/pkg/image/scan_test.go new file mode 100644 index 000000000..f48c188ee --- /dev/null +++ b/pkg/image/scan_test.go @@ -0,0 +1,120 @@ +package image + +import ( + "context" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type mockScanConfig struct{} + +func (m *mockScanConfig) GetCreateGalleriesFromFolders() bool { return false } + +func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { + const ( + testImageID = 1 + testFileID = 100 + ) + + existingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: "/images/test.jpg"} + + makeImage := func() *models.Image { + return &models.Image{ + ID: testImageID, + Files: models.NewRelatedFiles([]models.File{existingFile}), + GalleryIDs: models.NewRelatedIDs([]int{}), + } + } + + tests := []struct { + name string + updateExisting bool + expectUpdate bool + }{ + { + name: "calls UpdatePartial when file content changed", + updateExisting: true, + expectUpdate: true, + }, + { + name: "skips UpdatePartial when file unchanged and already associated", + updateExisting: false, + expectUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := mocks.NewDatabase() + db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil) + db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil) + + if tt.expectUpdate { + db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything). + Return(&models.Image{ID: testImageID}, nil) + } + + h := &ScanHandler{ + CreatorUpdater: db.Image, + GalleryFinder: db.Gallery, + ScanConfig: &mockScanConfig{}, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Image{makeImage()}, existingFile, tt.updateExisting) + assert.NoError(t, err) + }) + + if tt.expectUpdate { + db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything) + } else { + db.Image.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything) + } + }) + } +} + +func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { + const ( + testImageID = 1 + existFileID = 100 + newFileID = 200 + ) + + existingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: "/images/existing.jpg"} + newFile := &models.BaseFile{ID: models.FileID(newFileID), Path: "/images/new.jpg"} + + image := &models.Image{ + ID: testImageID, + Files: models.NewRelatedFiles([]models.File{existingFile}), + GalleryIDs: models.NewRelatedIDs([]int{}), + } + + db := mocks.NewDatabase() + db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil) + db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil) + db.Image.On("AddFileID", mock.Anything, testImageID, models.FileID(newFileID)).Return(nil) + db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything). + Return(&models.Image{ID: testImageID}, nil) + + h := &ScanHandler{ + CreatorUpdater: db.Image, + GalleryFinder: db.Gallery, + ScanConfig: &mockScanConfig{}, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Image{image}, newFile, false) + assert.NoError(t, err) + }) + + db.Image.AssertCalled(t, "AddFileID", mock.Anything, testImageID, models.FileID(newFileID)) + db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything) +} diff --git a/pkg/job/job.go b/pkg/job/job.go index 48b5e7b13..94d5fe2f5 100644 --- a/pkg/job/job.go +++ b/pkg/job/job.go @@ -66,6 +66,23 @@ type Job struct { cancelFunc context.CancelFunc } +// statusCopy returns a copy of the Job with only the fields needed for +// status reporting. Internal fields (exec, cancelFunc, outerCtx) are +// excluded so that subscription channels don't retain heavy resources. +func (j *Job) statusCopy() Job { + return Job{ + ID: j.ID, + Status: j.Status, + Details: j.Details, + Description: j.Description, + Progress: j.Progress, + StartTime: j.StartTime, + EndTime: j.EndTime, + AddTime: j.AddTime, + Error: j.Error, + } +} + // TimeElapsed returns the total time elapsed for the job. // If the EndTime is set, then it uses this to calculate the elapsed time, otherwise it uses time.Now. func (j *Job) TimeElapsed() time.Duration { @@ -80,9 +97,10 @@ func (j *Job) TimeElapsed() time.Duration { } func (j *Job) cancel() { - if j.Status == StatusReady { + switch j.Status { + case StatusReady: j.Status = StatusCancelled - } else if j.Status == StatusRunning { + case StatusRunning: j.Status = StatusStopping } diff --git a/pkg/job/manager.go b/pkg/job/manager.go index 3e47d842b..ba62d102c 100644 --- a/pkg/job/manager.go +++ b/pkg/job/manager.go @@ -105,7 +105,7 @@ func (m *Manager) notifyNewJob(j *Job) { for _, s := range m.subscriptions { // don't block if channel is full select { - case s.newJob <- *j: + case s.newJob <- j.statusCopy(): default: } } @@ -232,7 +232,9 @@ func (m *Manager) removeJob(job *Job) { return } - // clear any subtasks + // release the executor and subtask details so they can be GC'd + // while the job remains in the graveyard for status reporting + job.exec = nil job.Details = nil m.queue = append(m.queue[:index], m.queue[index+1:]...) @@ -246,7 +248,7 @@ func (m *Manager) removeJob(job *Job) { for _, s := range m.subscriptions { // don't block if channel is full select { - case s.removedJob <- *job: + case s.removedJob <- job.statusCopy(): default: } } @@ -310,8 +312,7 @@ func (m *Manager) GetJob(id int) *Job { // get from the queue or graveyard _, j := m.getJob(append(m.queue, m.graveyard...), id) if j != nil { - // make a copy of the job and return the pointer - jCopy := *j + jCopy := j.statusCopy() return &jCopy } @@ -326,8 +327,7 @@ func (m *Manager) GetQueue() []Job { var ret []Job for _, j := range m.queue { - jCopy := *j - ret = append(ret, jCopy) + ret = append(ret, j.statusCopy()) } return ret @@ -372,7 +372,7 @@ func (m *Manager) notifyJobUpdate(j *Job) { for _, s := range m.subscriptions { // don't block if channel is full select { - case s.updatedJob <- *j: + case s.updatedJob <- j.statusCopy(): default: } } diff --git a/pkg/job/task.go b/pkg/job/task.go index fa0891e6f..6dd2cf02b 100644 --- a/pkg/job/task.go +++ b/pkg/job/task.go @@ -51,7 +51,7 @@ func (tq *TaskQueue) executer(ctx context.Context) { defer tq.wg.Wait() for task := range tq.tasks { if IsCancelled(ctx) { - return + continue // allow channel to continue draining until Close() } tt := task diff --git a/pkg/match/scraped.go b/pkg/match/scraped.go index d3039f4c6..32759a2a4 100644 --- a/pkg/match/scraped.go +++ b/pkg/match/scraped.go @@ -22,7 +22,7 @@ type GroupNamesFinder interface { type SceneRelationships struct { PerformerFinder PerformerFinder - TagFinder models.TagQueryer + TagFinder models.TagNameFinder StudioFinder StudioFinder } @@ -188,9 +188,23 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na return } +// ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent. +func ScrapedTagHierarchy(ctx context.Context, qb models.TagNameFinder, s *models.ScrapedTag, stashBoxEndpoint string) error { + if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil { + return err + } + + if s.Parent == nil { + return nil + } + + // Match parent by name only (categories don't have StashDB tag IDs) + return ScrapedTag(ctx, qb, s.Parent, "") +} + // ScrapedTag matches the provided tag with the tags // in the database and sets the ID field if one is found. -func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error { +func ScrapedTag(ctx context.Context, qb models.TagNameFinder, s *models.ScrapedTag, stashBoxEndpoint string) error { if s.StoredID != nil { return nil } diff --git a/pkg/models/date.go b/pkg/models/date.go index dbd5c4ec6..912361507 100644 --- a/pkg/models/date.go +++ b/pkg/models/date.go @@ -2,6 +2,7 @@ package models import ( "fmt" + "strings" "time" "github.com/stashapp/stash/pkg/utils" @@ -61,3 +62,114 @@ func ParseDate(s string) (Date, error) { return Date{}, fmt.Errorf("failed to parse date %q: %v", s, errs) } + +func DateFromYear(year int) Date { + return Date{ + Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC), + Precision: DatePrecisionYear, + } +} + +func FormatYearRange(start *Date, end *Date) string { + var ( + startStr, endStr string + ) + + if start != nil { + startStr = start.Format(dateFormatPrecision[DatePrecisionYear]) + } + + if end != nil { + endStr = end.Format(dateFormatPrecision[DatePrecisionYear]) + } + + switch { + case startStr == "" && endStr == "": + return "" + case endStr == "": + return fmt.Sprintf("%s -", startStr) + case startStr == "": + return fmt.Sprintf("- %s", endStr) + default: + return fmt.Sprintf("%s - %s", startStr, endStr) + } +} + +func FormatYearRangeString(start *string, end *string) string { + switch { + case start == nil && end == nil: + return "" + case end == nil: + return fmt.Sprintf("%s -", *start) + case start == nil: + return fmt.Sprintf("- %s", *end) + default: + return fmt.Sprintf("%s - %s", *start, *end) + } +} + +// ParseYearRangeString parses a year range string into start and end year integers. +// Supported formats: "YYYY", "YYYY - YYYY", "YYYY-YYYY", "YYYY -", "- YYYY", "YYYY-present". +// Returns nil for start/end if not present in the string. +func ParseYearRangeString(s string) (start *Date, end *Date, err error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil, fmt.Errorf("empty year range string") + } + + // normalize "present" to empty end + lower := strings.ToLower(s) + lower = strings.ReplaceAll(lower, "present", "") + + // split on "-" if it contains one + var parts []string + if strings.Contains(lower, "-") { + parts = strings.SplitN(lower, "-", 2) + } else { + // single value, treat as start year + year, err := parseYear(lower) + if err != nil { + return nil, nil, fmt.Errorf("invalid year range %q: %w", s, err) + } + return year, nil, nil + } + + startStr := strings.TrimSpace(parts[0]) + endStr := strings.TrimSpace(parts[1]) + + if startStr != "" { + y, err := parseYear(startStr) + if err != nil { + return nil, nil, fmt.Errorf("invalid start year in %q: %w", s, err) + } + start = y + } + + if endStr != "" { + y, err := parseYear(endStr) + if err != nil { + return nil, nil, fmt.Errorf("invalid end year in %q: %w", s, err) + } + end = y + } + + if start == nil && end == nil { + return nil, nil, fmt.Errorf("could not parse year range %q", s) + } + + return start, end, nil +} + +func parseYear(s string) (*Date, error) { + ret, err := ParseDate(s) + if err != nil { + return nil, fmt.Errorf("parsing year %q: %w", s, err) + } + + year := ret.Time.Year() + if year < 1900 || year > 2200 { + return nil, fmt.Errorf("year %d out of reasonable range", year) + } + + return &ret, nil +} diff --git a/pkg/models/date_test.go b/pkg/models/date_test.go index b6cca9ee1..3b2962e28 100644 --- a/pkg/models/date_test.go +++ b/pkg/models/date_test.go @@ -3,6 +3,8 @@ package models import ( "testing" "time" + + "github.com/stretchr/testify/assert" ) func TestParseDateStringAsTime(t *testing.T) { @@ -48,3 +50,102 @@ func TestParseDateStringAsTime(t *testing.T) { }) } } + +func TestFormatYearRange(t *testing.T) { + datePtr := func(v int) *Date { + date := DateFromYear(v) + return &date + } + + tests := []struct { + name string + start *Date + end *Date + want string + }{ + {"both nil", nil, nil, ""}, + {"only start", datePtr(2005), nil, "2005 -"}, + {"only end", nil, datePtr(2010), "- 2010"}, + {"start and end", datePtr(2005), datePtr(2010), "2005 - 2010"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatYearRange(tt.start, tt.end) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFormatYearRangeString(t *testing.T) { + stringPtr := func(v string) *string { return &v } + + tests := []struct { + name string + start *string + end *string + want string + }{ + {"both nil", nil, nil, ""}, + {"only start", stringPtr("2005"), nil, "2005 -"}, + {"only end", nil, stringPtr("2010"), "- 2010"}, + {"start and end", stringPtr("2005"), stringPtr("2010"), "2005 - 2010"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatYearRangeString(tt.start, tt.end) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseYearRangeString(t *testing.T) { + intPtr := func(v int) *int { return &v } + + tests := []struct { + name string + input string + wantStart *int + wantEnd *int + wantErr bool + }{ + {"single year", "2005", intPtr(2005), nil, false}, + {"year range with spaces", "2005 - 2010", intPtr(2005), intPtr(2010), false}, + {"year range no spaces", "2005-2010", intPtr(2005), intPtr(2010), false}, + {"year dash open", "2005 -", intPtr(2005), nil, false}, + {"year dash open no space", "2005-", intPtr(2005), nil, false}, + {"dash year", "- 2010", nil, intPtr(2010), false}, + {"year present", "2005-present", intPtr(2005), nil, false}, + {"year Present caps", "2005 - Present", intPtr(2005), nil, false}, + {"whitespace padding", " 2005 - 2010 ", intPtr(2005), intPtr(2010), false}, + {"empty string", "", nil, nil, true}, + {"garbage", "not a year", nil, nil, true}, + {"partial garbage start", "abc - 2010", nil, nil, true}, + {"partial garbage end", "2005 - abc", nil, nil, true}, + {"year out of range", "1800", nil, nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start, end, err := ParseYearRangeString(tt.input) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + if tt.wantStart != nil { + assert.NotNil(t, start) + assert.Equal(t, *tt.wantStart, start.Time.Year()) + } else { + assert.Nil(t, start) + } + if tt.wantEnd != nil { + assert.NotNil(t, end) + assert.Equal(t, *tt.wantEnd, end.Time.Year()) + } else { + assert.Nil(t, end) + } + }) + } +} diff --git a/pkg/models/folder.go b/pkg/models/folder.go index ada9e17b7..e9e9a3971 100644 --- a/pkg/models/folder.go +++ b/pkg/models/folder.go @@ -18,10 +18,8 @@ type FolderQueryOptions struct { type FolderFilterType struct { OperatorFilter[FolderFilterType] - Path *StringCriterionInput `json:"path,omitempty"` - Basename *StringCriterionInput `json:"basename,omitempty"` - // Filter by parent directory path - Dir *StringCriterionInput `json:"dir,omitempty"` + Path *StringCriterionInput `json:"path,omitempty"` + Basename *StringCriterionInput `json:"basename,omitempty"` ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"` ZipFile *MultiCriterionInput `json:"zip_file,omitempty"` // Filter by modification time diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index dfc776afe..3bf70b754 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -11,6 +11,8 @@ type GalleryFilterType struct { Checksum *StringCriterionInput `json:"checksum"` // Filter by path Path *StringCriterionInput `json:"path"` + // Filter by parent folder + ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"` // Filter by zip file count FileCount *IntCriterionInput `json:"file_count"` // Filter to only include galleries missing this property @@ -67,6 +69,9 @@ type GalleryFilterType struct { CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` + + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type GalleryUpdateInput struct { @@ -86,6 +91,8 @@ type GalleryUpdateInput struct { PerformerIds []string `json:"performer_ids"` PrimaryFileID *string `json:"primary_file_id"` + CustomFields *CustomFieldsInput `json:"custom_fields"` + // deprecated URL *string `json:"url"` } diff --git a/pkg/models/group.go b/pkg/models/group.go index ec550eea8..396384b51 100644 --- a/pkg/models/group.go +++ b/pkg/models/group.go @@ -43,4 +43,6 @@ type GroupFilterType struct { CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } diff --git a/pkg/models/image.go b/pkg/models/image.go index 84be79360..b99267e8c 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -1,6 +1,8 @@ package models -import "context" +import ( + "context" +) type ImageFilterType struct { OperatorFilter[ImageFilterType] @@ -65,25 +67,28 @@ type ImageFilterType struct { CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type ImageUpdateInput struct { - ClientMutationID *string `json:"clientMutationId"` - ID string `json:"id"` - Title *string `json:"title"` - Code *string `json:"code"` - Urls []string `json:"urls"` - Date *string `json:"date"` - Details *string `json:"details"` - Photographer *string `json:"photographer"` - Rating100 *int `json:"rating100"` - Organized *bool `json:"organized"` - SceneIds []string `json:"scene_ids"` - StudioID *string `json:"studio_id"` - TagIds []string `json:"tag_ids"` - PerformerIds []string `json:"performer_ids"` - GalleryIds []string `json:"gallery_ids"` - PrimaryFileID *string `json:"primary_file_id"` + ClientMutationID *string `json:"clientMutationId"` + ID string `json:"id"` + Title *string `json:"title"` + Code *string `json:"code"` + Urls []string `json:"urls"` + Date *string `json:"date"` + Details *string `json:"details"` + Photographer *string `json:"photographer"` + Rating100 *int `json:"rating100"` + Organized *bool `json:"organized"` + SceneIds []string `json:"scene_ids"` + StudioID *string `json:"studio_id"` + TagIds []string `json:"tag_ids"` + PerformerIds []string `json:"performer_ids"` + GalleryIds []string `json:"gallery_ids"` + PrimaryFileID *string `json:"primary_file_id"` + CustomFields *CustomFieldsInput `json:"custom_fields"` // deprecated URL *string `json:"url"` diff --git a/pkg/models/jsonschema/gallery.go b/pkg/models/jsonschema/gallery.go index 7323e37ba..5fb6e16ab 100644 --- a/pkg/models/jsonschema/gallery.go +++ b/pkg/models/jsonschema/gallery.go @@ -18,22 +18,23 @@ type GalleryChapter struct { } type Gallery struct { - ZipFiles []string `json:"zip_files,omitempty"` - FolderPath string `json:"folder_path,omitempty"` - Title string `json:"title,omitempty"` - Code string `json:"code,omitempty"` - URLs []string `json:"urls,omitempty"` - Date string `json:"date,omitempty"` - Details string `json:"details,omitempty"` - Photographer string `json:"photographer,omitempty"` - Rating int `json:"rating,omitempty"` - Organized bool `json:"organized,omitempty"` - Chapters []GalleryChapter `json:"chapters,omitempty"` - Studio string `json:"studio,omitempty"` - Performers []string `json:"performers,omitempty"` - Tags []string `json:"tags,omitempty"` - CreatedAt json.JSONTime `json:"created_at,omitempty"` - UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + ZipFiles []string `json:"zip_files,omitempty"` + FolderPath string `json:"folder_path,omitempty"` + Title string `json:"title,omitempty"` + Code string `json:"code,omitempty"` + URLs []string `json:"urls,omitempty"` + Date string `json:"date,omitempty"` + Details string `json:"details,omitempty"` + Photographer string `json:"photographer,omitempty"` + Rating int `json:"rating,omitempty"` + Organized bool `json:"organized,omitempty"` + Chapters []GalleryChapter `json:"chapters,omitempty"` + Studio string `json:"studio,omitempty"` + Performers []string `json:"performers,omitempty"` + Tags []string `json:"tags,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` // deprecated - for import only URL string `json:"url,omitempty"` diff --git a/pkg/models/jsonschema/group.go b/pkg/models/jsonschema/group.go index b284dab6e..357ac70bc 100644 --- a/pkg/models/jsonschema/group.go +++ b/pkg/models/jsonschema/group.go @@ -33,6 +33,8 @@ type Group struct { CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` + // deprecated - for import only URL string `json:"url,omitempty"` } diff --git a/pkg/models/jsonschema/image.go b/pkg/models/jsonschema/image.go index 1bdac8770..168ea9eec 100644 --- a/pkg/models/jsonschema/image.go +++ b/pkg/models/jsonschema/image.go @@ -18,18 +18,19 @@ type Image struct { // deprecated - for import only URL string `json:"url,omitempty"` - URLs []string `json:"urls,omitempty"` - Date string `json:"date,omitempty"` - Details string `json:"details,omitempty"` - Photographer string `json:"photographer,omitempty"` - Organized bool `json:"organized,omitempty"` - OCounter int `json:"o_counter,omitempty"` - Galleries []GalleryRef `json:"galleries,omitempty"` - Performers []string `json:"performers,omitempty"` - Tags []string `json:"tags,omitempty"` - Files []string `json:"files,omitempty"` - CreatedAt json.JSONTime `json:"created_at,omitempty"` - UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + URLs []string `json:"urls,omitempty"` + Date string `json:"date,omitempty"` + Details string `json:"details,omitempty"` + Photographer string `json:"photographer,omitempty"` + Organized bool `json:"organized,omitempty"` + OCounter int `json:"o_counter,omitempty"` + Galleries []GalleryRef `json:"galleries,omitempty"` + Performers []string `json:"performers,omitempty"` + Tags []string `json:"tags,omitempty"` + Files []string `json:"files,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` } func (s Image) Filename(basename string, hash string) string { diff --git a/pkg/models/jsonschema/performer.go b/pkg/models/jsonschema/performer.go index b738fbfac..1a8acd5f3 100644 --- a/pkg/models/jsonschema/performer.go +++ b/pkg/models/jsonschema/performer.go @@ -49,8 +49,8 @@ type Performer struct { PenisLength float64 `json:"penis_length,omitempty"` Circumcised string `json:"circumcised,omitempty"` CareerLength string `json:"career_length,omitempty"` // deprecated - for import only - CareerStart *int `json:"career_start,omitempty"` - CareerEnd *int `json:"career_end,omitempty"` + CareerStart string `json:"career_start,omitempty"` + CareerEnd string `json:"career_end,omitempty"` Tattoos string `json:"tattoos,omitempty"` Piercings string `json:"piercings,omitempty"` Aliases StringOrStringList `json:"aliases,omitempty"` diff --git a/pkg/models/mocks/FileReaderWriter.go b/pkg/models/mocks/FileReaderWriter.go index 97a0136e6..4b370459e 100644 --- a/pkg/models/mocks/FileReaderWriter.go +++ b/pkg/models/mocks/FileReaderWriter.go @@ -153,13 +153,13 @@ func (_m *FileReaderWriter) FindAllByPath(ctx context.Context, path string, case return r0, r1 } -// FindAllInPaths provides a mock function with given fields: ctx, p, limit, offset -func (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, limit int, offset int) ([]models.File, error) { - ret := _m.Called(ctx, p, limit, offset) +// FindAllInPaths provides a mock function with given fields: ctx, p, includeZipContents, limit, offset +func (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit int, offset int) ([]models.File, error) { + ret := _m.Called(ctx, p, includeZipContents, limit, offset) var r0 []models.File - if rf, ok := ret.Get(0).(func(context.Context, []string, int, int) []models.File); ok { - r0 = rf(ctx, p, limit, offset) + if rf, ok := ret.Get(0).(func(context.Context, []string, bool, int, int) []models.File); ok { + r0 = rf(ctx, p, includeZipContents, limit, offset) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]models.File) @@ -167,8 +167,8 @@ func (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, limi } var r1 error - if rf, ok := ret.Get(1).(func(context.Context, []string, int, int) error); ok { - r1 = rf(ctx, p, limit, offset) + if rf, ok := ret.Get(1).(func(context.Context, []string, bool, int, int) error); ok { + r1 = rf(ctx, p, includeZipContents, limit, offset) } else { r1 = ret.Error(1) } diff --git a/pkg/models/mocks/FolderReaderWriter.go b/pkg/models/mocks/FolderReaderWriter.go index 7bca013fe..d2230c645 100644 --- a/pkg/models/mocks/FolderReaderWriter.go +++ b/pkg/models/mocks/FolderReaderWriter.go @@ -86,13 +86,13 @@ func (_m *FolderReaderWriter) Find(ctx context.Context, id models.FolderID) (*mo return r0, r1 } -// FindAllInPaths provides a mock function with given fields: ctx, p, limit, offset -func (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, limit int, offset int) ([]*models.Folder, error) { - ret := _m.Called(ctx, p, limit, offset) +// FindAllInPaths provides a mock function with given fields: ctx, p, includeZipContents, limit, offset +func (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit int, offset int) ([]*models.Folder, error) { + ret := _m.Called(ctx, p, includeZipContents, limit, offset) var r0 []*models.Folder - if rf, ok := ret.Get(0).(func(context.Context, []string, int, int) []*models.Folder); ok { - r0 = rf(ctx, p, limit, offset) + if rf, ok := ret.Get(0).(func(context.Context, []string, bool, int, int) []*models.Folder); ok { + r0 = rf(ctx, p, includeZipContents, limit, offset) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Folder) @@ -100,8 +100,8 @@ func (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, li } var r1 error - if rf, ok := ret.Get(1).(func(context.Context, []string, int, int) error); ok { - r1 = rf(ctx, p, limit, offset) + if rf, ok := ret.Get(1).(func(context.Context, []string, bool, int, int) error); ok { + r1 = rf(ctx, p, includeZipContents, limit, offset) } else { r1 = ret.Error(1) } @@ -201,6 +201,52 @@ func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID return r0, r1 } +// GetManyParentFolderIDs provides a mock function with given fields: ctx, folderIDs +func (_m *FolderReaderWriter) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) { + ret := _m.Called(ctx, folderIDs) + + var r0 [][]models.FolderID + if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) [][]models.FolderID); ok { + r0 = rf(ctx, folderIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([][]models.FolderID) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok { + r1 = rf(ctx, folderIDs) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetManySubFolderIDs provides a mock function with given fields: ctx, folderIDs +func (_m *FolderReaderWriter) GetManySubFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) { + ret := _m.Called(ctx, folderIDs) + + var r0 [][]models.FolderID + if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) [][]models.FolderID); ok { + r0 = rf(ctx, folderIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([][]models.FolderID) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok { + r1 = rf(ctx, folderIDs) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Query provides a mock function with given fields: ctx, options func (_m *FolderReaderWriter) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) { ret := _m.Called(ctx, options) diff --git a/pkg/models/mocks/GalleryReaderWriter.go b/pkg/models/mocks/GalleryReaderWriter.go index f07f8a7d9..e835ea2bc 100644 --- a/pkg/models/mocks/GalleryReaderWriter.go +++ b/pkg/models/mocks/GalleryReaderWriter.go @@ -49,6 +49,20 @@ func (_m *GalleryReaderWriter) AddImages(ctx context.Context, galleryID int, ima return r0 } +// AddSceneIDs provides a mock function with given fields: ctx, galleryID, sceneIDs +func (_m *GalleryReaderWriter) AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error { + ret := _m.Called(ctx, galleryID, sceneIDs) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok { + r0 = rf(ctx, galleryID, sceneIDs) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // All provides a mock function with given fields: ctx func (_m *GalleryReaderWriter) All(ctx context.Context) ([]*models.Gallery, error) { ret := _m.Called(ctx) @@ -114,13 +128,13 @@ func (_m *GalleryReaderWriter) CountByFileID(ctx context.Context, fileID models. return r0, r1 } -// Create provides a mock function with given fields: ctx, newGallery, fileIDs -func (_m *GalleryReaderWriter) Create(ctx context.Context, newGallery *models.Gallery, fileIDs []models.FileID) error { - ret := _m.Called(ctx, newGallery, fileIDs) +// Create provides a mock function with given fields: ctx, newGallery +func (_m *GalleryReaderWriter) Create(ctx context.Context, newGallery *models.CreateGalleryInput) error { + ret := _m.Called(ctx, newGallery) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Gallery, []models.FileID) error); ok { - r0 = rf(ctx, newGallery, fileIDs) + if rf, ok := ret.Get(0).(func(context.Context, *models.CreateGalleryInput) error); ok { + r0 = rf(ctx, newGallery) } else { r0 = ret.Error(0) } @@ -395,6 +409,52 @@ func (_m *GalleryReaderWriter) FindUserGalleryByTitle(ctx context.Context, title return r0, r1 } +// GetCustomFields provides a mock function with given fields: ctx, id +func (_m *GalleryReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { + ret := _m.Called(ctx, id) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *GalleryReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetFiles provides a mock function with given fields: ctx, relatedID func (_m *GalleryReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) { ret := _m.Called(ctx, relatedID) @@ -656,12 +716,26 @@ func (_m *GalleryReaderWriter) SetCover(ctx context.Context, galleryID int, cove return r0 } +// SetCustomFields provides a mock function with given fields: ctx, id, fields +func (_m *GalleryReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { + ret := _m.Called(ctx, id, fields) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { + r0 = rf(ctx, id, fields) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Update provides a mock function with given fields: ctx, updatedGallery -func (_m *GalleryReaderWriter) Update(ctx context.Context, updatedGallery *models.Gallery) error { +func (_m *GalleryReaderWriter) Update(ctx context.Context, updatedGallery *models.UpdateGalleryInput) error { ret := _m.Called(ctx, updatedGallery) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Gallery) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, *models.UpdateGalleryInput) error); ok { r0 = rf(ctx, updatedGallery) } else { r0 = ret.Error(0) diff --git a/pkg/models/mocks/GroupReaderWriter.go b/pkg/models/mocks/GroupReaderWriter.go index dc745d094..ac9e513f4 100644 --- a/pkg/models/mocks/GroupReaderWriter.go +++ b/pkg/models/mocks/GroupReaderWriter.go @@ -312,6 +312,52 @@ func (_m *GroupReaderWriter) GetContainingGroupDescriptions(ctx context.Context, return r0, r1 } +// GetCustomFields provides a mock function with given fields: ctx, id +func (_m *GroupReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { + ret := _m.Called(ctx, id) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *GroupReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetFrontImage provides a mock function with given fields: ctx, groupID func (_m *GroupReaderWriter) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) { ret := _m.Called(ctx, groupID) @@ -497,6 +543,20 @@ func (_m *GroupReaderWriter) QueryCount(ctx context.Context, groupFilter *models return r0, r1 } +// SetCustomFields provides a mock function with given fields: ctx, id, fields +func (_m *GroupReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { + ret := _m.Called(ctx, id, fields) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { + r0 = rf(ctx, id, fields) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Update provides a mock function with given fields: ctx, updatedGroup func (_m *GroupReaderWriter) Update(ctx context.Context, updatedGroup *models.Group) error { ret := _m.Called(ctx, updatedGroup) diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index afc5efdb7..f2c9934be 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -137,13 +137,13 @@ func (_m *ImageReaderWriter) CoverByGalleryID(ctx context.Context, galleryId int return r0, r1 } -// Create provides a mock function with given fields: ctx, newImage, fileIDs -func (_m *ImageReaderWriter) Create(ctx context.Context, newImage *models.Image, fileIDs []models.FileID) error { - ret := _m.Called(ctx, newImage, fileIDs) +// Create provides a mock function with given fields: ctx, newImage +func (_m *ImageReaderWriter) Create(ctx context.Context, newImage *models.CreateImageInput) error { + ret := _m.Called(ctx, newImage) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Image, []models.FileID) error); ok { - r0 = rf(ctx, newImage, fileIDs) + if rf, ok := ret.Get(0).(func(context.Context, *models.CreateImageInput) error); ok { + r0 = rf(ctx, newImage) } else { r0 = ret.Error(0) } @@ -393,6 +393,52 @@ func (_m *ImageReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models return r0, r1 } +// GetCustomFields provides a mock function with given fields: ctx, id +func (_m *ImageReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { + ret := _m.Called(ctx, id) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *ImageReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetFiles provides a mock function with given fields: ctx, relatedID func (_m *ImageReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) { ret := _m.Called(ctx, relatedID) @@ -694,6 +740,20 @@ func (_m *ImageReaderWriter) ResetOCounter(ctx context.Context, id int) (int, er return r0, r1 } +// SetCustomFields provides a mock function with given fields: ctx, id, fields +func (_m *ImageReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { + ret := _m.Called(ctx, id, fields) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { + r0 = rf(ctx, id, fields) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Size provides a mock function with given fields: ctx func (_m *ImageReaderWriter) Size(ctx context.Context) (float64, error) { ret := _m.Called(ctx) diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index 95a3b7a87..194f475c8 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -197,6 +197,29 @@ func (_m *TagReaderWriter) FindAllDescendants(ctx context.Context, tagID int, ex return r0, r1 } +// FindByAlias provides a mock function with given fields: ctx, alias, nocase +func (_m *TagReaderWriter) FindByAlias(ctx context.Context, alias string, nocase bool) (*models.Tag, error) { + ret := _m.Called(ctx, alias, nocase) + + var r0 *models.Tag + if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Tag); ok { + r0 = rf(ctx, alias, nocase) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok { + r1 = rf(ctx, alias, nocase) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindByChildTagID provides a mock function with given fields: ctx, childID func (_m *TagReaderWriter) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) { ret := _m.Called(ctx, childID) @@ -450,6 +473,29 @@ func (_m *TagReaderWriter) FindByStashID(ctx context.Context, stashID models.Sta return r0, r1 } +// FindByStashIDStatus provides a mock function with given fields: ctx, hasStashID, stashboxEndpoint +func (_m *TagReaderWriter) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) { + ret := _m.Called(ctx, hasStashID, stashboxEndpoint) + + var r0 []*models.Tag + if rf, ok := ret.Get(0).(func(context.Context, bool, string) []*models.Tag); ok { + r0 = rf(ctx, hasStashID, stashboxEndpoint) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, bool, string) error); ok { + r1 = rf(ctx, hasStashID, stashboxEndpoint) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindByStudioID provides a mock function with given fields: ctx, studioID func (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) { ret := _m.Called(ctx, studioID) diff --git a/pkg/models/mocks/database.go b/pkg/models/mocks/database.go index ec4177b30..88f106e19 100644 --- a/pkg/models/mocks/database.go +++ b/pkg/models/mocks/database.go @@ -3,6 +3,7 @@ package mocks import ( "context" + "errors" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" @@ -89,6 +90,16 @@ func (db *Database) AssertExpectations(t mock.TestingT) { db.SavedFilter.AssertExpectations(t) } +// WithTxnCtx runs fn with a context that has a transaction hook manager registered, +// so code that calls txn.AddPostCommitHook (e.g. plugin cache) won't nil-panic. +// Always rolls back to avoid executing the registered hooks. +func (db *Database) WithTxnCtx(fn func(ctx context.Context)) { + _ = txn.WithTxn(context.Background(), db, func(ctx context.Context) error { + fn(ctx) + return errors.New("rollback") + }) +} + func (db *Database) Repository() models.Repository { return models.Repository{ TxnManager: db, diff --git a/pkg/models/model_gallery.go b/pkg/models/model_gallery.go index 4b6a3183d..bbdba46a6 100644 --- a/pkg/models/model_gallery.go +++ b/pkg/models/model_gallery.go @@ -46,6 +46,20 @@ func NewGallery() Gallery { } } +type CreateGalleryInput struct { + *Gallery + + FileIDs []FileID + CustomFields map[string]interface{} `json:"custom_fields"` +} + +type UpdateGalleryInput struct { + *Gallery + + FileIDs []FileID + CustomFields CustomFieldsInput `json:"custom_fields"` +} + // GalleryPartial represents part of a Gallery object. It is used to update // the database entry. Only non-nil fields will be updated. type GalleryPartial struct { @@ -70,6 +84,8 @@ type GalleryPartial struct { TagIDs *UpdateIDs PerformerIDs *UpdateIDs PrimaryFileID *FileID + + CustomFields CustomFieldsInput } func NewGalleryPartial() GalleryPartial { diff --git a/pkg/models/model_group.go b/pkg/models/model_group.go index 82c71996a..5bfb42c44 100644 --- a/pkg/models/model_group.go +++ b/pkg/models/model_group.go @@ -34,6 +34,14 @@ func NewGroup() Group { } } +type CreateGroupInput struct { + *Group + + CustomFields map[string]interface{} `json:"custom_fields"` + FrontImageData []byte + BackImageData []byte +} + func (m *Group) LoadURLs(ctx context.Context, l URLLoader) error { return m.URLs.load(func() ([]string, error) { return l.GetURLs(ctx, m.ID) @@ -74,6 +82,8 @@ type GroupPartial struct { SubGroups *UpdateGroupDescriptions CreatedAt OptionalTime UpdatedAt OptionalTime + + CustomFields CustomFieldsInput } func NewGroupPartial() GroupPartial { diff --git a/pkg/models/model_image.go b/pkg/models/model_image.go index 1d0993536..72ca61826 100644 --- a/pkg/models/model_image.go +++ b/pkg/models/model_image.go @@ -47,6 +47,13 @@ func NewImage() Image { } } +type CreateImageInput struct { + *Image + + FileIDs []FileID + CustomFields map[string]interface{} `json:"custom_fields"` +} + type ImagePartial struct { Title OptionalString Code OptionalString @@ -66,6 +73,7 @@ type ImagePartial struct { TagIDs *UpdateIDs PerformerIDs *UpdateIDs PrimaryFileID *FileID + CustomFields CustomFieldsInput } func NewImagePartial() ImagePartial { diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index a30eafa0a..7bc3b3174 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -6,26 +6,26 @@ import ( ) type Performer struct { - ID int `json:"id"` - Name string `json:"name"` - Disambiguation string `json:"disambiguation"` - Gender *GenderEnum `json:"gender"` - Birthdate *Date `json:"birthdate"` - Ethnicity string `json:"ethnicity"` - Country string `json:"country"` - EyeColor string `json:"eye_color"` - Height *int `json:"height"` - Measurements string `json:"measurements"` - FakeTits string `json:"fake_tits"` - PenisLength *float64 `json:"penis_length"` - Circumcised *CircumisedEnum `json:"circumcised"` - CareerStart *int `json:"career_start"` - CareerEnd *int `json:"career_end"` - Tattoos string `json:"tattoos"` - Piercings string `json:"piercings"` - Favorite bool `json:"favorite"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int `json:"id"` + Name string `json:"name"` + Disambiguation string `json:"disambiguation"` + Gender *GenderEnum `json:"gender"` + Birthdate *Date `json:"birthdate"` + Ethnicity string `json:"ethnicity"` + Country string `json:"country"` + EyeColor string `json:"eye_color"` + Height *int `json:"height"` + Measurements string `json:"measurements"` + FakeTits string `json:"fake_tits"` + PenisLength *float64 `json:"penis_length"` + Circumcised *CircumcisedEnum `json:"circumcised"` + CareerStart *Date `json:"career_start"` + CareerEnd *Date `json:"career_end"` + Tattoos string `json:"tattoos"` + Piercings string `json:"piercings"` + Favorite bool `json:"favorite"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` Details string `json:"details"` @@ -76,8 +76,8 @@ type PerformerPartial struct { FakeTits OptionalString PenisLength OptionalFloat64 Circumcised OptionalString - CareerStart OptionalInt - CareerEnd OptionalInt + CareerStart OptionalDate + CareerEnd OptionalDate Tattoos OptionalString Piercings OptionalString Favorite OptionalBool diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 3c0e083c1..d20fbd589 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -177,8 +177,8 @@ type ScrapedPerformer struct { PenisLength *string `json:"penis_length"` Circumcised *string `json:"circumcised"` CareerLength *string `json:"career_length"` // deprecated: use CareerStart/CareerEnd - CareerStart *int `json:"career_start"` - CareerEnd *int `json:"career_end"` + CareerStart *string `json:"career_start"` + CareerEnd *string `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` @@ -225,12 +225,16 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool // assume that career length is _not_ populated in favour of start/end if p.CareerStart != nil && !excluded["career_start"] { - cs := *p.CareerStart - ret.CareerStart = &cs + date, err := ParseDate(*p.CareerStart) + if err == nil { + ret.CareerStart = &date + } } if p.CareerEnd != nil && !excluded["career_end"] { - ce := *p.CareerEnd - ret.CareerEnd = &ce + date, err := ParseDate(*p.CareerEnd) + if err == nil { + ret.CareerEnd = &date + } } if p.Country != nil && !excluded["country"] { ret.Country = *p.Country @@ -288,7 +292,7 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool } } if p.Circumcised != nil && !excluded["circumcised"] { - v := CircumisedEnum(*p.Circumcised) + v := CircumcisedEnum(*p.Circumcised) if v.IsValid() { ret.Circumcised = &v } @@ -367,13 +371,13 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, } if p.CareerLength != nil && !excluded["career_length"] { // parse career_length into career_start/career_end - start, end, err := utils.ParseYearRangeString(*p.CareerLength) + start, end, err := ParseYearRangeString(*p.CareerLength) if err == nil { if start != nil { - ret.CareerStart = NewOptionalInt(*start) + ret.CareerStart = NewOptionalDate(*start) } if end != nil { - ret.CareerEnd = NewOptionalInt(*end) + ret.CareerEnd = NewOptionalDate(*end) } } } @@ -471,9 +475,12 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, type ScrapedTag struct { // Set if tag matched - StoredID *string `json:"stored_id"` - Name string `json:"name"` - RemoteSiteID *string `json:"remote_site_id"` + StoredID *string `json:"stored_id"` + Name string `json:"name"` + Description *string `json:"description"` + AliasList []string `json:"alias_list"` + RemoteSiteID *string `json:"remote_site_id"` + Parent *ScrapedTag `json:"parent"` } func (ScrapedTag) IsScrapedContent() {} @@ -482,6 +489,24 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { currentTime := time.Now() ret := NewTag() ret.Name = t.Name + ret.ParentIDs = NewRelatedIDs([]int{}) + ret.ChildIDs = NewRelatedIDs([]int{}) + ret.Aliases = NewRelatedStrings([]string{}) + + if t.Description != nil && !excluded["description"] { + ret.Description = *t.Description + } + + if len(t.AliasList) > 0 && !excluded["aliases"] { + ret.Aliases = NewRelatedStrings(t.AliasList) + } + + if t.Parent != nil && t.Parent.StoredID != nil { + parentID, err := strconv.Atoi(*t.Parent.StoredID) + if err == nil && parentID > 0 { + ret.ParentIDs = NewRelatedIDs([]int{parentID}) + } + } if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ @@ -496,6 +521,49 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { return &ret } +func (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) TagPartial { + ret := NewTagPartial() + + if t.Name != "" && !excluded["name"] { + ret.Name = NewOptionalString(t.Name) + } + + if t.Description != nil && !excluded["description"] { + ret.Description = NewOptionalString(*t.Description) + } + + if len(t.AliasList) > 0 && !excluded["aliases"] { + ret.Aliases = &UpdateStrings{ + Values: t.AliasList, + Mode: RelationshipUpdateModeSet, + } + } + + if t.Parent != nil && t.Parent.StoredID != nil { + parentID, err := strconv.Atoi(*t.Parent.StoredID) + if err == nil && parentID > 0 { + ret.ParentIDs = &UpdateIDs{ + IDs: []int{parentID}, + Mode: RelationshipUpdateModeAdd, + } + } + } + + if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { + ret.StashIDs = &UpdateStashIDs{ + StashIDs: existingStashIDs, + Mode: RelationshipUpdateModeSet, + } + ret.StashIDs.Set(StashID{ + Endpoint: endpoint, + StashID: *t.RemoteSiteID, + UpdatedAt: time.Now(), + }) + } + + return ret +} + func ScrapedTagSortFunction(a, b *ScrapedTag) int { return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) } diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index 09d8fbb32..1956d8a0b 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -8,8 +8,6 @@ import ( "github.com/stretchr/testify/assert" ) -func intPtr(i int) *int { return &i } - func Test_scrapedToStudioInput(t *testing.T) { const name = "name" url := "url" @@ -186,8 +184,8 @@ func Test_scrapedToPerformerInput(t *testing.T) { Weight: nextVal(), Measurements: nextVal(), FakeTits: nextVal(), - CareerStart: intPtr(2005), - CareerEnd: intPtr(2015), + CareerStart: dateStrFromInt(2005), + CareerEnd: dateStrFromInt(2015), Tattoos: nextVal(), Piercings: nextVal(), Aliases: nextVal(), @@ -212,8 +210,8 @@ func Test_scrapedToPerformerInput(t *testing.T) { Weight: nextIntVal(), Measurements: *nextVal(), FakeTits: *nextVal(), - CareerStart: intPtr(2005), - CareerEnd: intPtr(2015), + CareerStart: dateFromInt(2005), + CareerEnd: dateFromInt(2015), Tattoos: *nextVal(), // skip CareerLength counter slot Piercings: *nextVal(), Aliases: NewRelatedStrings([]string{*nextVal()}), diff --git a/pkg/models/performer.go b/pkg/models/performer.go index e4fb8dd98..606b87f9f 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -61,49 +61,49 @@ type GenderCriterionInput struct { Modifier CriterionModifier `json:"modifier"` } -type CircumisedEnum string +type CircumcisedEnum string const ( - CircumisedEnumCut CircumisedEnum = "CUT" - CircumisedEnumUncut CircumisedEnum = "UNCUT" + CircumcisedEnumCut CircumcisedEnum = "CUT" + CircumcisedEnumUncut CircumcisedEnum = "UNCUT" ) -var AllCircumcisionEnum = []CircumisedEnum{ - CircumisedEnumCut, - CircumisedEnumUncut, +var AllCircumcisionEnum = []CircumcisedEnum{ + CircumcisedEnumCut, + CircumcisedEnumUncut, } -func (e CircumisedEnum) IsValid() bool { +func (e CircumcisedEnum) IsValid() bool { switch e { - case CircumisedEnumCut, CircumisedEnumUncut: + case CircumcisedEnumCut, CircumcisedEnumUncut: return true } return false } -func (e CircumisedEnum) String() string { +func (e CircumcisedEnum) String() string { return string(e) } -func (e *CircumisedEnum) UnmarshalGQL(v interface{}) error { +func (e *CircumcisedEnum) UnmarshalGQL(v interface{}) error { str, ok := v.(string) if !ok { return fmt.Errorf("enums must be strings") } - *e = CircumisedEnum(str) + *e = CircumcisedEnum(str) if !e.IsValid() { - return fmt.Errorf("%s is not a valid CircumisedEnum", str) + return fmt.Errorf("%s is not a valid CircumcisedEnum", str) } return nil } -func (e CircumisedEnum) MarshalGQL(w io.Writer) { +func (e CircumcisedEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } type CircumcisionCriterionInput struct { - Value []CircumisedEnum `json:"value"` + Value []CircumcisedEnum `json:"value"` Modifier CriterionModifier `json:"modifier"` } @@ -139,9 +139,9 @@ type PerformerFilterType struct { // Filter by career length CareerLength *StringCriterionInput `json:"career_length"` // deprecated // Filter by career start year - CareerStart *IntCriterionInput `json:"career_start"` + CareerStart *DateCriterionInput `json:"career_start"` // Filter by career end year - CareerEnd *IntCriterionInput `json:"career_end"` + CareerEnd *DateCriterionInput `json:"career_end"` // Filter by tattoos Tattoos *StringCriterionInput `json:"tattoos"` // Filter by piercings @@ -158,6 +158,8 @@ type PerformerFilterType struct { TagCount *IntCriterionInput `json:"tag_count"` // Filter by scene count SceneCount *IntCriterionInput `json:"scene_count"` + // Filter by scene marker count (via scene) + MarkerCount *IntCriterionInput `json:"marker_count"` // Filter by image count ImageCount *IntCriterionInput `json:"image_count"` // Filter by gallery count @@ -202,6 +204,8 @@ type PerformerFilterType struct { GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by related tags that meet this criteria TagsFilter *TagFilterType `json:"tags_filter"` + // Filter by related scene markers (via scene) that meet this criteria + MarkersFilter *SceneMarkerFilterType `json:"markers_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at @@ -212,32 +216,32 @@ type PerformerFilterType struct { } type PerformerCreateInput struct { - Name string `json:"name"` - Disambiguation *string `json:"disambiguation"` - URL *string `json:"url"` // deprecated - Urls []string `json:"urls"` - Gender *GenderEnum `json:"gender"` - Birthdate *string `json:"birthdate"` - Ethnicity *string `json:"ethnicity"` - Country *string `json:"country"` - EyeColor *string `json:"eye_color"` - Height *string `json:"height"` - HeightCm *int `json:"height_cm"` - Measurements *string `json:"measurements"` - FakeTits *string `json:"fake_tits"` - PenisLength *float64 `json:"penis_length"` - Circumcised *CircumisedEnum `json:"circumcised"` - CareerLength *string `json:"career_length"` - CareerStart *int `json:"career_start"` - CareerEnd *int `json:"career_end"` - Tattoos *string `json:"tattoos"` - Piercings *string `json:"piercings"` - Aliases *string `json:"aliases"` - AliasList []string `json:"alias_list"` - Twitter *string `json:"twitter"` // deprecated - Instagram *string `json:"instagram"` // deprecated - Favorite *bool `json:"favorite"` - TagIds []string `json:"tag_ids"` + Name string `json:"name"` + Disambiguation *string `json:"disambiguation"` + URL *string `json:"url"` // deprecated + Urls []string `json:"urls"` + Gender *GenderEnum `json:"gender"` + Birthdate *string `json:"birthdate"` + Ethnicity *string `json:"ethnicity"` + Country *string `json:"country"` + EyeColor *string `json:"eye_color"` + Height *string `json:"height"` + HeightCm *int `json:"height_cm"` + Measurements *string `json:"measurements"` + FakeTits *string `json:"fake_tits"` + PenisLength *float64 `json:"penis_length"` + Circumcised *CircumcisedEnum `json:"circumcised"` + CareerLength *string `json:"career_length"` + CareerStart *string `json:"career_start"` + CareerEnd *string `json:"career_end"` + Tattoos *string `json:"tattoos"` + Piercings *string `json:"piercings"` + Aliases *string `json:"aliases"` + AliasList []string `json:"alias_list"` + Twitter *string `json:"twitter"` // deprecated + Instagram *string `json:"instagram"` // deprecated + Favorite *bool `json:"favorite"` + TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` @@ -252,33 +256,33 @@ type PerformerCreateInput struct { } type PerformerUpdateInput struct { - ID string `json:"id"` - Name *string `json:"name"` - Disambiguation *string `json:"disambiguation"` - URL *string `json:"url"` // deprecated - Urls []string `json:"urls"` - Gender *GenderEnum `json:"gender"` - Birthdate *string `json:"birthdate"` - Ethnicity *string `json:"ethnicity"` - Country *string `json:"country"` - EyeColor *string `json:"eye_color"` - Height *string `json:"height"` - HeightCm *int `json:"height_cm"` - Measurements *string `json:"measurements"` - FakeTits *string `json:"fake_tits"` - PenisLength *float64 `json:"penis_length"` - Circumcised *CircumisedEnum `json:"circumcised"` - CareerLength *string `json:"career_length"` - CareerStart *int `json:"career_start"` - CareerEnd *int `json:"career_end"` - Tattoos *string `json:"tattoos"` - Piercings *string `json:"piercings"` - Aliases *string `json:"aliases"` - AliasList []string `json:"alias_list"` - Twitter *string `json:"twitter"` // deprecated - Instagram *string `json:"instagram"` // deprecated - Favorite *bool `json:"favorite"` - TagIds []string `json:"tag_ids"` + ID string `json:"id"` + Name *string `json:"name"` + Disambiguation *string `json:"disambiguation"` + URL *string `json:"url"` // deprecated + Urls []string `json:"urls"` + Gender *GenderEnum `json:"gender"` + Birthdate *string `json:"birthdate"` + Ethnicity *string `json:"ethnicity"` + Country *string `json:"country"` + EyeColor *string `json:"eye_color"` + Height *string `json:"height"` + HeightCm *int `json:"height_cm"` + Measurements *string `json:"measurements"` + FakeTits *string `json:"fake_tits"` + PenisLength *float64 `json:"penis_length"` + Circumcised *CircumcisedEnum `json:"circumcised"` + CareerLength *string `json:"career_length"` + CareerStart *string `json:"career_start"` + CareerEnd *string `json:"career_end"` + Tattoos *string `json:"tattoos"` + Piercings *string `json:"piercings"` + Aliases *string `json:"aliases"` + AliasList []string `json:"alias_list"` + Twitter *string `json:"twitter"` // deprecated + Instagram *string `json:"instagram"` // deprecated + Favorite *bool `json:"favorite"` + TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` diff --git a/pkg/models/repository_file.go b/pkg/models/repository_file.go index c851ce08c..e1ac0b213 100644 --- a/pkg/models/repository_file.go +++ b/pkg/models/repository_file.go @@ -14,7 +14,7 @@ type FileGetter interface { type FileFinder interface { FileGetter FindAllByPath(ctx context.Context, path string, caseSensitive bool) ([]File, error) - FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]File, error) + FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]File, error) FindByPath(ctx context.Context, path string, caseSensitive bool) (File, error) FindByFingerprint(ctx context.Context, fp Fingerprint) ([]File, error) FindByZipFileID(ctx context.Context, zipFileID FileID) ([]File, error) diff --git a/pkg/models/repository_folder.go b/pkg/models/repository_folder.go index 3d0fdb822..1169e53ac 100644 --- a/pkg/models/repository_folder.go +++ b/pkg/models/repository_folder.go @@ -11,10 +11,12 @@ type FolderGetter interface { // FolderFinder provides methods to find folders. type FolderFinder interface { FolderGetter - FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]*Folder, error) + FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]*Folder, error) FindByPath(ctx context.Context, path string, caseSensitive bool) (*Folder, error) FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Folder, error) FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error) + GetManyParentFolderIDs(ctx context.Context, folderIDs []FolderID) ([][]FolderID, error) + GetManySubFolderIDs(ctx context.Context, folderIDs []FolderID) ([][]FolderID, error) } type FolderQueryer interface { diff --git a/pkg/models/repository_gallery.go b/pkg/models/repository_gallery.go index 0cfb9964f..8fc3b29d5 100644 --- a/pkg/models/repository_gallery.go +++ b/pkg/models/repository_gallery.go @@ -37,12 +37,12 @@ type GalleryCounter interface { // GalleryCreator provides methods to create galleries. type GalleryCreator interface { - Create(ctx context.Context, newGallery *Gallery, fileIDs []FileID) error + Create(ctx context.Context, newGallery *CreateGalleryInput) error } // GalleryUpdater provides methods to update galleries. type GalleryUpdater interface { - Update(ctx context.Context, updatedGallery *Gallery) error + Update(ctx context.Context, updatedGallery *UpdateGalleryInput) error UpdatePartial(ctx context.Context, id int, updatedGallery GalleryPartial) (*Gallery, error) UpdateImages(ctx context.Context, galleryID int, imageIDs []int) error } @@ -70,6 +70,7 @@ type GalleryReader interface { PerformerIDLoader TagIDLoader FileLoader + CustomFieldsReader All(ctx context.Context) ([]*Gallery, error) } @@ -80,6 +81,9 @@ type GalleryWriter interface { GalleryUpdater GalleryDestroyer + CustomFieldsWriter + + AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error AddFileID(ctx context.Context, id int, fileID FileID) error AddImages(ctx context.Context, galleryID int, imageIDs ...int) error RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error diff --git a/pkg/models/repository_group.go b/pkg/models/repository_group.go index 704390d77..d7f74de64 100644 --- a/pkg/models/repository_group.go +++ b/pkg/models/repository_group.go @@ -68,6 +68,7 @@ type GroupReader interface { TagIDLoader ContainingGroupLoader SubGroupLoader + CustomFieldsReader All(ctx context.Context) ([]*Group, error) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) @@ -81,6 +82,7 @@ type GroupWriter interface { GroupCreator GroupUpdater GroupDestroyer + CustomFieldsWriter } // GroupReaderWriter provides all group methods. diff --git a/pkg/models/repository_image.go b/pkg/models/repository_image.go index 672ecd063..99dab3479 100644 --- a/pkg/models/repository_image.go +++ b/pkg/models/repository_image.go @@ -43,7 +43,7 @@ type ImageCounter interface { // ImageCreator provides methods to create images. type ImageCreator interface { - Create(ctx context.Context, newImage *Image, fileIDs []FileID) error + Create(ctx context.Context, newImage *CreateImageInput) error } // ImageUpdater provides methods to update images. @@ -78,6 +78,7 @@ type ImageReader interface { FileLoader GalleryCoverFinder + CustomFieldsReader All(ctx context.Context) ([]*Image, error) Size(ctx context.Context) (float64, error) @@ -88,6 +89,7 @@ type ImageWriter interface { ImageCreator ImageUpdater ImageDestroyer + CustomFieldsWriter AddFileID(ctx context.Context, id int, fileID FileID) error RemoveFileID(ctx context.Context, id int, fileID FileID) error diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index ba403cf2d..bd2ab2592 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -9,9 +9,16 @@ type TagGetter interface { Find(ctx context.Context, id int) (*Tag, error) } +type TagNameFinder interface { + FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) + FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error) + FindByAlias(ctx context.Context, alias string, nocase bool) (*Tag, error) +} + // TagFinder provides methods to find tags. type TagFinder interface { TagGetter + TagNameFinder FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error) FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error) FindByParentTagID(ctx context.Context, parentID int) ([]*Tag, error) @@ -23,9 +30,8 @@ type TagFinder interface { FindByGroupID(ctx context.Context, groupID int) ([]*Tag, error) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error) FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error) - FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) - FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error) FindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error) + FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Tag, error) } // TagQueryer provides methods to query tags. diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 3a133dcad..b166e5a69 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -56,6 +56,8 @@ type TagFilterType struct { PerformersFilter *PerformerFilterType `json:"performers_filter"` // Filter by related studios that meet this criteria StudiosFilter *StudioFilterType `json:"studios_filter"` + // Filter by related scene markers that meet this criteria + MarkersFilter *SceneMarkerFilterType `json:"markers_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 691175b1f..d7807f651 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -71,10 +71,10 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode } if performer.CareerStart != nil { - newPerformerJSON.CareerStart = performer.CareerStart + newPerformerJSON.CareerStart = performer.CareerStart.String() } if performer.CareerEnd != nil { - newPerformerJSON.CareerEnd = performer.CareerEnd + newPerformerJSON.CareerEnd = performer.CareerEnd.String() } if err := performer.LoadAliases(ctx, reader); err != nil { diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index 1a87bc2b1..2cf476321 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -48,10 +48,10 @@ var ( rating = 5 height = 123 weight = 60 - careerStart = 2005 - careerEnd = 2015 + careerStart, _ = models.ParseDate("2005") + careerEnd, _ = models.ParseDate("2015") penisLength = 1.23 - circumcisedEnum = models.CircumisedEnumCut + circumcisedEnum = models.CircumcisedEnumCut circumcised = circumcisedEnum.String() emptyCustomFields = make(map[string]interface{}) @@ -134,8 +134,8 @@ func createFullJSONPerformer(name string, image string, withCustomFields bool) * URLs: []string{url, twitter, instagram}, Aliases: aliases, Birthdate: birthDate.String(), - CareerStart: &careerStart, - CareerEnd: &careerEnd, + CareerStart: careerStart.String(), + CareerEnd: careerEnd.String(), Country: country, Ethnicity: ethnicity, EyeColor: eyeColor, diff --git a/pkg/performer/import.go b/pkg/performer/import.go index 1df69521a..62b4d87d0 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -247,7 +247,7 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) (models.Perfor } if performerJSON.Circumcised != "" { - v := models.CircumisedEnum(performerJSON.Circumcised) + v := models.CircumcisedEnum(performerJSON.Circumcised) newPerformer.Circumcised = &v } @@ -285,11 +285,17 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) (models.Perfor } // prefer explicit career_start/career_end, fall back to parsing legacy career_length - if performerJSON.CareerStart != nil || performerJSON.CareerEnd != nil { - newPerformer.CareerStart = performerJSON.CareerStart - newPerformer.CareerEnd = performerJSON.CareerEnd + if performerJSON.CareerStart != "" || performerJSON.CareerEnd != "" { + careerStart, err := models.ParseDate(performerJSON.CareerStart) + if err == nil { + newPerformer.CareerStart = &careerStart + } + careerEnd, err := models.ParseDate(performerJSON.CareerEnd) + if err == nil { + newPerformer.CareerEnd = &careerEnd + } } else if performerJSON.CareerLength != "" { - start, end, err := utils.ParseYearRangeString(performerJSON.CareerLength) + start, end, err := models.ParseYearRangeString(performerJSON.CareerLength) if err != nil { return models.Performer{}, fmt.Errorf("invalid career_length %q: %w", performerJSON.CareerLength, err) } diff --git a/pkg/performer/import_test.go b/pkg/performer/import_test.go index ca28c1990..0d5f80d01 100644 --- a/pkg/performer/import_test.go +++ b/pkg/performer/import_test.go @@ -317,15 +317,15 @@ func TestUpdate(t *testing.T) { } func TestImportCareerFields(t *testing.T) { - startYear := 2005 - endYear := 2015 + startYear, _ := models.ParseDate("2005") + endYear, _ := models.ParseDate("2015") // explicit career_start/career_end should be used directly t.Run("explicit fields", func(t *testing.T) { input := jsonschema.Performer{ Name: "test", - CareerStart: &startYear, - CareerEnd: &endYear, + CareerStart: startYear.String(), + CareerEnd: endYear.String(), } p, err := performerJSONToPerformer(input) @@ -338,8 +338,8 @@ func TestImportCareerFields(t *testing.T) { t.Run("explicit fields override legacy", func(t *testing.T) { input := jsonschema.Performer{ Name: "test", - CareerStart: &startYear, - CareerEnd: &endYear, + CareerStart: startYear.String(), + CareerEnd: endYear.String(), CareerLength: "1990 - 1995", } diff --git a/pkg/pkg/cache.go b/pkg/pkg/cache.go index 9d36bdd1d..e94b2cb41 100644 --- a/pkg/pkg/cache.go +++ b/pkg/pkg/cache.go @@ -1,6 +1,7 @@ package pkg import ( + "sync" "time" ) @@ -10,22 +11,23 @@ type cacheEntry struct { } type repositoryCache struct { + mu sync.RWMutex // cache maps the URL to the last modified time and the data cache map[string]cacheEntry } -func (c *repositoryCache) ensureCache() { - if c.cache == nil { - c.cache = make(map[string]cacheEntry) - } -} - func (c *repositoryCache) lastModified(url string) *time.Time { if c == nil { return nil } - c.ensureCache() + c.mu.RLock() + defer c.mu.RUnlock() + + if c.cache == nil { + return nil + } + e, found := c.cache[url] if !found { @@ -36,7 +38,13 @@ func (c *repositoryCache) lastModified(url string) *time.Time { } func (c *repositoryCache) getPackageList(url string) []RemotePackage { - c.ensureCache() + c.mu.RLock() + defer c.mu.RUnlock() + + if c.cache == nil { + return nil + } + e, found := c.cache[url] if !found { @@ -51,7 +59,13 @@ func (c *repositoryCache) cacheList(url string, lastModified time.Time, data []R return } - c.ensureCache() + c.mu.Lock() + defer c.mu.Unlock() + + if c.cache == nil { + c.cache = make(map[string]cacheEntry) + } + c.cache[url] = cacheEntry{ lastModified: lastModified, data: data, diff --git a/pkg/pkg/manager.go b/pkg/pkg/manager.go index 18fa4e0d1..4024191ad 100644 --- a/pkg/pkg/manager.go +++ b/pkg/pkg/manager.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "path/filepath" + "sync" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -31,13 +32,14 @@ type Manager struct { Client *http.Client - cache *repositoryCache + cacheOnce sync.Once + cache *repositoryCache } func (m *Manager) getCache() *repositoryCache { - if m.cache == nil { + m.cacheOnce.Do(func() { m.cache = &repositoryCache{} - } + }) return m.cache } diff --git a/pkg/scene/filename_parser.go b/pkg/scene/filename_parser.go index b8dff89d7..1ce6e7b4a 100644 --- a/pkg/scene/filename_parser.go +++ b/pkg/scene/filename_parser.go @@ -456,7 +456,7 @@ type FilenameParserRepository struct { Performer PerformerNamesFinder Studio models.StudioQueryer Group GroupNameFinder - Tag models.TagQueryer + Tag models.TagNameFinder } func NewFilenameParserRepository(repo models.Repository) FilenameParserRepository { @@ -599,7 +599,7 @@ func (p *FilenameParser) queryGroup(ctx context.Context, qb GroupNameFinder, gro return ret } -func (p *FilenameParser) queryTag(ctx context.Context, qb models.TagQueryer, tagName string) *models.Tag { +func (p *FilenameParser) queryTag(ctx context.Context, qb models.TagNameFinder, tagName string) *models.Tag { // massage the tag name tagName = delimiterRE.ReplaceAllString(tagName, " ") @@ -638,7 +638,7 @@ func (p *FilenameParser) setPerformers(ctx context.Context, qb PerformerNamesFin } } -func (p *FilenameParser) setTags(ctx context.Context, qb models.TagQueryer, h sceneHolder, result *models.SceneParserResult) { +func (p *FilenameParser) setTags(ctx context.Context, qb models.TagNameFinder, h sceneHolder, result *models.SceneParserResult) { // query for each performer tagsSet := make(map[int]bool) for _, tagName := range h.tags { diff --git a/pkg/scene/generate/preview.go b/pkg/scene/generate/preview.go index ceefd617c..a0fea4994 100644 --- a/pkg/scene/generate/preview.go +++ b/pkg/scene/generate/preview.go @@ -232,7 +232,7 @@ func (g Generator) generateConcatFile(chunkFiles []string) (fn string, err error for _, f := range chunkFiles { // files in concat file should be relative to concat relFile := filepath.Base(f) - if _, err := w.WriteString(fmt.Sprintf("file '%s'\n", relFile)); err != nil { + if _, err := fmt.Fprintf(w, "file '%s'\n", relFile); err != nil { return concatFile.Name(), fmt.Errorf("writing concat file: %w", err) } } diff --git a/pkg/scene/scan.go b/pkg/scene/scan.go index e1038fbc3..8d2944a36 100644 --- a/pkg/scene/scan.go +++ b/pkg/scene/scan.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "path/filepath" + "strings" "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/logger" @@ -32,12 +34,18 @@ type ScanCreatorUpdater interface { AddFileID(ctx context.Context, id int, fileID models.FileID) error } +type ScanGalleryFinderUpdater interface { + FindByPath(ctx context.Context, p string) ([]*models.Gallery, error) + AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error +} + type ScanGenerator interface { Generate(ctx context.Context, s *models.Scene, f *models.VideoFile) error } type ScanHandler struct { - CreatorUpdater ScanCreatorUpdater + CreatorUpdater ScanCreatorUpdater + GalleryFinderUpdater ScanGalleryFinderUpdater ScanGenerator ScanGenerator CaptionUpdater video.CaptionUpdater @@ -49,19 +57,19 @@ type ScanHandler struct { func (h *ScanHandler) validate() error { if h.CreatorUpdater == nil { - return errors.New("CreatorUpdater is required") + return errors.New("internal error: CreatorUpdater is required") } if h.ScanGenerator == nil { - return errors.New("ScanGenerator is required") + return errors.New("internal error: ScanGenerator is required") } if h.CaptionUpdater == nil { - return errors.New("CaptionUpdater is required") + return errors.New("internal error: CaptionUpdater is required") } if !h.FileNamingAlgorithm.IsValid() { - return errors.New("FileNamingAlgorithm is required") + return errors.New("internal error: FileNamingAlgorithm is required") } if h.Paths == nil { - return errors.New("Paths is required") + return errors.New("internal error: Paths is required") } return nil @@ -127,6 +135,10 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models. } } + if err := h.associateGallery(ctx, existing, f); err != nil { + return err + } + // do this after the commit so that cover generation doesn't hold up the transaction txn.AddPostCommitHook(ctx, func(ctx context.Context) { for _, s := range existing { @@ -160,18 +172,44 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. if err := h.CreatorUpdater.AddFileID(ctx, s.ID, f.ID); err != nil { return fmt.Errorf("adding file to scene: %w", err) } + } - // update updated_at time + if !found || updateExisting { + // update updated_at time when file association or content changes scenePartial := models.NewScenePartial() if _, err := h.CreatorUpdater.UpdatePartial(ctx, s.ID, scenePartial); err != nil { return fmt.Errorf("updating scene: %w", err) } - } - if !found || updateExisting { h.PluginCache.RegisterPostHooks(ctx, s.ID, hook.SceneUpdatePost, nil, nil) } } return nil } + +func (h *ScanHandler) associateGallery(ctx context.Context, existing []*models.Scene, f models.File) error { + sceneIDs := make([]int, len(existing)) + for i, s := range existing { + sceneIDs[i] = s.ID + } + + path := f.Base().Path + zipPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".zip" + + // find galleries with a file that matches + galleries, err := h.GalleryFinderUpdater.FindByPath(ctx, zipPath) + if err != nil { + return err + } + + for _, gallery := range galleries { + // found related Scene + logger.Infof("associate: Scene %s is related to gallery: %d", path, gallery.ID) + if err := h.GalleryFinderUpdater.AddSceneIDs(ctx, gallery.ID, sceneIDs); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/scene/scan_test.go b/pkg/scene/scan_test.go new file mode 100644 index 000000000..71729bb57 --- /dev/null +++ b/pkg/scene/scan_test.go @@ -0,0 +1,114 @@ +package scene + +import ( + "context" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { + const ( + testSceneID = 1 + testFileID = 100 + ) + + existingFile := &models.VideoFile{ + BaseFile: &models.BaseFile{ID: models.FileID(testFileID), Path: "test.mp4"}, + } + + makeScene := func() *models.Scene { + return &models.Scene{ + ID: testSceneID, + Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}), + } + } + + tests := []struct { + name string + updateExisting bool + expectUpdate bool + }{ + { + name: "calls UpdatePartial when file content changed", + updateExisting: true, + expectUpdate: true, + }, + { + name: "skips UpdatePartial when file unchanged and already associated", + updateExisting: false, + expectUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := mocks.NewDatabase() + db.Scene.On("GetFiles", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil) + + if tt.expectUpdate { + db.Scene.On("UpdatePartial", mock.Anything, testSceneID, mock.Anything). + Return(&models.Scene{ID: testSceneID}, nil) + } + + h := &ScanHandler{ + CreatorUpdater: db.Scene, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Scene{makeScene()}, existingFile, tt.updateExisting) + assert.NoError(t, err) + }) + + if tt.expectUpdate { + db.Scene.AssertCalled(t, "UpdatePartial", mock.Anything, testSceneID, mock.Anything) + } else { + db.Scene.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything) + } + }) + } +} + +func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { + const ( + testSceneID = 1 + existFileID = 100 + newFileID = 200 + ) + + existingFile := &models.VideoFile{ + BaseFile: &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.mp4"}, + } + newFile := &models.VideoFile{ + BaseFile: &models.BaseFile{ID: models.FileID(newFileID), Path: "new.mp4"}, + } + + scene := &models.Scene{ + ID: testSceneID, + Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}), + } + + db := mocks.NewDatabase() + db.Scene.On("GetFiles", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil) + db.Scene.On("AddFileID", mock.Anything, testSceneID, models.FileID(newFileID)).Return(nil) + db.Scene.On("UpdatePartial", mock.Anything, testSceneID, mock.Anything). + Return(&models.Scene{ID: testSceneID}, nil) + + h := &ScanHandler{ + CreatorUpdater: db.Scene, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Scene{scene}, newFile, false) + assert.NoError(t, err) + }) + + db.Scene.AssertCalled(t, "AddFileID", mock.Anything, testSceneID, models.FileID(newFileID)) + db.Scene.AssertCalled(t, "UpdatePartial", mock.Anything, testSceneID, mock.Anything) +} diff --git a/pkg/scraper/cache.go b/pkg/scraper/cache.go index 6aeb95fcf..83a590b3e 100644 --- a/pkg/scraper/cache.go +++ b/pkg/scraper/cache.go @@ -70,6 +70,7 @@ type StudioFinder interface { type TagFinder interface { models.TagGetter + models.TagNameFinder models.TagAutoTagQueryer } diff --git a/pkg/scraper/mapped_result.go b/pkg/scraper/mapped_result.go index 1260f3082..64cc97ec7 100644 --- a/pkg/scraper/mapped_result.go +++ b/pkg/scraper/mapped_result.go @@ -140,8 +140,8 @@ func (r mappedResult) scrapedPerformer() *models.ScrapedPerformer { PenisLength: r.stringPtr("PenisLength"), Circumcised: r.stringPtr("Circumcised"), CareerLength: r.stringPtr("CareerLength"), - CareerStart: r.IntPtr("CareerStart"), - CareerEnd: r.IntPtr("CareerEnd"), + CareerStart: r.stringPtr("CareerStart"), + CareerEnd: r.stringPtr("CareerEnd"), Tattoos: r.stringPtr("Tattoos"), Piercings: r.stringPtr("Piercings"), Aliases: r.stringPtr("Aliases"), diff --git a/pkg/scraper/performer.go b/pkg/scraper/performer.go index 4684a6683..e05240453 100644 --- a/pkg/scraper/performer.go +++ b/pkg/scraper/performer.go @@ -20,8 +20,8 @@ type ScrapedPerformerInput struct { PenisLength *string `json:"penis_length"` Circumcised *string `json:"circumcised"` CareerLength *string `json:"career_length"` - CareerStart *int `json:"career_start"` - CareerEnd *int `json:"career_end"` + CareerStart *string `json:"career_start"` + CareerEnd *string `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` diff --git a/pkg/scraper/post_processing_test.go b/pkg/scraper/post_processing_test.go new file mode 100644 index 000000000..2eb9385e1 --- /dev/null +++ b/pkg/scraper/post_processing_test.go @@ -0,0 +1,144 @@ +package scraper + +import ( + "context" + "testing" + + "github.com/stashapp/stash/pkg/models" +) + +func TestPostScrapePerformerCareerLength(t *testing.T) { + ctx := context.Background() + const related = false + + strPtr := func(s string) *string { + return &s + } + + tests := []struct { + name string + input models.ScrapedPerformer + want models.ScrapedPerformer + }{ + { + "start = 2000", + models.ScrapedPerformer{ + CareerStart: strPtr("2000"), + }, + models.ScrapedPerformer{ + CareerStart: strPtr("2000"), + CareerLength: strPtr("2000 -"), + }, + }, + { + "end = 2000", + models.ScrapedPerformer{ + CareerEnd: strPtr("2000"), + }, + models.ScrapedPerformer{ + CareerEnd: strPtr("2000"), + CareerLength: strPtr("- 2000"), + }, + }, + { + "start = 2000, end = 2020", + models.ScrapedPerformer{ + CareerStart: strPtr("2000"), + CareerEnd: strPtr("2020"), + }, + models.ScrapedPerformer{ + CareerStart: strPtr("2000"), + CareerEnd: strPtr("2020"), + CareerLength: strPtr("2000 - 2020"), + }, + }, + { + "length = 2000 -", + models.ScrapedPerformer{ + CareerLength: strPtr("2000 -"), + }, + models.ScrapedPerformer{ + CareerStart: strPtr("2000"), + CareerLength: strPtr("2000 -"), + }, + }, + { + "length = - 2010", + models.ScrapedPerformer{ + CareerLength: strPtr("- 2010"), + }, + models.ScrapedPerformer{ + CareerEnd: strPtr("2010"), + CareerLength: strPtr("- 2010"), + }, + }, + { + "length = 2000 - 2010", + models.ScrapedPerformer{ + CareerLength: strPtr("2000 - 2010"), + }, + models.ScrapedPerformer{ + CareerStart: strPtr("2000"), + CareerEnd: strPtr("2010"), + CareerLength: strPtr("2000 - 2010"), + }, + }, + { + "invalid start", + models.ScrapedPerformer{ + CareerStart: strPtr("two thousand"), + }, + models.ScrapedPerformer{ + CareerStart: strPtr("two thousand"), + }, + }, + { + "invalid end", + models.ScrapedPerformer{ + CareerEnd: strPtr("two thousand"), + }, + models.ScrapedPerformer{ + CareerEnd: strPtr("two thousand"), + }, + }, + { + "invalid career length", + models.ScrapedPerformer{ + CareerLength: strPtr("1234 - 4567 - 9224"), + }, + models.ScrapedPerformer{ + CareerLength: strPtr("1234 - 4567 - 9224"), + }, + }, + } + + compareStrPtr := func(a, b *string) bool { + if a == b { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &postScraper{} + got, err := c.postScrapePerformer(ctx, tt.input, related) + if err != nil { + t.Fatalf("postScrapePerformer returned error: %v", err) + } + postScraped := got.(models.ScrapedPerformer) + if !compareStrPtr(postScraped.CareerStart, tt.want.CareerStart) { + t.Errorf("CareerStart = %v, want %v", postScraped.CareerStart, tt.want.CareerStart) + } + if !compareStrPtr(postScraped.CareerEnd, tt.want.CareerEnd) { + t.Errorf("CareerEnd = %v, want %v", postScraped.CareerEnd, tt.want.CareerEnd) + } + if !compareStrPtr(postScraped.CareerLength, tt.want.CareerLength) { + t.Errorf("CareerLength = %v, want %v", postScraped.CareerLength, tt.want.CareerLength) + } + }) + } +} diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index 8a4d4de7d..4b8f7e022 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -125,23 +125,64 @@ func (c *postScraper) postScrapePerformer(ctx context.Context, p models.ScrapedP } } - isEmptyStr := func(s *string) bool { return s == nil || *s == "" } - isEmptyInt := func(s *int) bool { return s == nil || *s == 0 } - - // populate career start/end from career length and vice versa - if !isEmptyStr(p.CareerLength) && isEmptyInt(p.CareerStart) && isEmptyInt(p.CareerEnd) { - p.CareerStart, p.CareerEnd, err = utils.ParseYearRangeString(*p.CareerLength) - if err != nil { - logger.Warnf("Could not parse career length %s: %v", *p.CareerLength, err) - } - } else if isEmptyStr(p.CareerLength) && (!isEmptyInt(p.CareerStart) || !isEmptyInt(p.CareerEnd)) { - v := utils.FormatYearRange(p.CareerStart, p.CareerEnd) - p.CareerLength = &v - } + c.postProcessCareerLength(&p) return p, nil } +func (c *postScraper) postProcessCareerLength(p *models.ScrapedPerformer) { + isEmptyStr := func(s *string) bool { return s == nil || *s == "" } + + // populate career start/end from career length and vice versa + if !isEmptyStr(p.CareerLength) && isEmptyStr(p.CareerStart) && isEmptyStr(p.CareerEnd) { + start, end, err := models.ParseYearRangeString(*p.CareerLength) + if err != nil { + logger.Warnf("Could not parse career length %s: %v", *p.CareerLength, err) + return + } + + if start != nil { + startStr := start.String() + p.CareerStart = &startStr + } + if end != nil { + endStr := end.String() + p.CareerEnd = &endStr + } + + return + } + + // populate career length from career start/end if career length is missing + if isEmptyStr(p.CareerLength) { + var ( + start *models.Date + end *models.Date + ) + + if !isEmptyStr(p.CareerStart) { + date, err := models.ParseDate(*p.CareerStart) + if err != nil { + logger.Warnf("Could not parse career start %s: %v", *p.CareerStart, err) + return + } + start = &date + } + + if !isEmptyStr(p.CareerEnd) { + date, err := models.ParseDate(*p.CareerEnd) + if err != nil { + logger.Warnf("Could not parse career end %s: %v", *p.CareerEnd, err) + return + } + end = &date + } + + v := models.FormatYearRange(start, end) + p.CareerLength = &v + } +} + func (c *postScraper) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, related bool) (_ ScrapedContent, err error) { r := c.repository tqb := r.TagFinder diff --git a/pkg/scraper/query_url.go b/pkg/scraper/query_url.go index 2cd9f683e..7fe874947 100644 --- a/pkg/scraper/query_url.go +++ b/pkg/scraper/query_url.go @@ -17,6 +17,12 @@ func queryURLParametersFromScene(scene *models.Scene) queryURLParameters { ret["oshash"] = scene.OSHash ret["filename"] = filepath.Base(scene.Path) + // pull phash from primary file + phashFingerprints := scene.Files.Primary().Base().Fingerprints.Filter(models.FingerprintTypePhash) + if len(phashFingerprints) > 0 { + ret["phash"] = phashFingerprints[0].Value() + } + if scene.Title != "" { ret["title"] = scene.Title } diff --git a/pkg/scraper/tag.go b/pkg/scraper/tag.go index 14f02e397..c9c2530de 100644 --- a/pkg/scraper/tag.go +++ b/pkg/scraper/tag.go @@ -11,7 +11,7 @@ import ( "github.com/stashapp/stash/pkg/sliceutil" ) -func postProcessTags(ctx context.Context, tqb models.TagQueryer, scrapedTags []*models.ScrapedTag) (ret []*models.ScrapedTag, err error) { +func postProcessTags(ctx context.Context, tqb models.TagNameFinder, scrapedTags []*models.ScrapedTag) (ret []*models.ScrapedTag, err error) { ret = make([]*models.ScrapedTag, 0, len(scrapedTags)) for _, t := range scrapedTags { diff --git a/pkg/session/local.go b/pkg/session/local.go new file mode 100644 index 000000000..519328496 --- /dev/null +++ b/pkg/session/local.go @@ -0,0 +1,44 @@ +package session + +import ( + "context" + "net" + "net/http" + + "github.com/stashapp/stash/pkg/logger" +) + +// SetLocalRequest checks if the request is from localhost and sets the context value accordingly. +// It returns the modified request with the updated context, or the original request if it did +// not come from localhost or if there was an error parsing the remote address. +func SetLocalRequest(r *http.Request) *http.Request { + // determine if request is from localhost + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + logger.Errorf("Error parsing remote address: %v", err) + return r + } + + ip := net.ParseIP(host) + if ip == nil { + logger.Errorf("Error parsing IP address: %s", host) + return r + } + + if ip.IsLoopback() { + ctx := context.WithValue(r.Context(), contextLocalRequest, true) + r = r.WithContext(ctx) + } + + return r +} + +// IsLocalRequest returns true if the request is from localhost, as determined by the context value set by SetLocalRequest. +// If the context value is not set, it returns false. +func IsLocalRequest(ctx context.Context) bool { + val := ctx.Value(contextLocalRequest) + if val == nil { + return false + } + return val.(bool) +} diff --git a/pkg/session/session.go b/pkg/session/session.go index 66cb39e09..3e4c2eea1 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -15,6 +15,7 @@ type key int const ( contextUser key = iota contextVisitedPlugins + contextLocalRequest ) const ( diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index e0a354980..ace306169 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -522,6 +522,10 @@ func (db *Anonymiser) anonymiseGalleries(ctx context.Context) error { return err } + if err := db.anonymiseCustomFields(ctx, goqu.T(galleriesCustomFieldsTable.GetTable()), "gallery_id"); err != nil { + return err + } + return nil } @@ -960,6 +964,10 @@ func (db *Anonymiser) anonymiseGroups(ctx context.Context) error { return err } + if err := db.anonymiseCustomFields(ctx, goqu.T(groupsCustomFieldsTable.GetTable()), "group_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index 1496df71d..c703a85e3 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -70,11 +70,52 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite } } -func joinedStringCriterionHandler(c *models.StringCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func stringNoTrimCriterionHandler(c *models.StringCriterionInput, column string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + if modifier := c.Modifier; c.Modifier.IsValid() { + switch modifier { + case models.CriterionModifierIncludes: + f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, false)) + case models.CriterionModifierExcludes: + f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, true)) + case models.CriterionModifierEquals: + f.addWhere(column+" LIKE ?", c.Value) + case models.CriterionModifierNotEquals: + f.addWhere(column+" NOT LIKE ?", c.Value) + case models.CriterionModifierMatchesRegex: + if _, err := regexp.Compile(c.Value); err != nil { + f.setError(err) + return + } + f.addWhere(fmt.Sprintf("(%s IS NOT NULL AND %[1]s regexp ?)", column), c.Value) + case models.CriterionModifierNotMatchesRegex: + if _, err := regexp.Compile(c.Value); err != nil { + f.setError(err) + return + } + f.addWhere(fmt.Sprintf("(%s IS NULL OR %[1]s NOT regexp ?)", column), c.Value) + case models.CriterionModifierIsNull: + f.addWhere("(" + column + " IS NULL)") + case models.CriterionModifierNotNull: + f.addWhere("(" + column + " IS NOT NULL)") + default: + panic("unsupported string filter modifier") + } + } + } + } +} + +func joinedStringCriterionHandler(c *models.StringCriterionInput, column string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { if addJoinFn != nil { - addJoinFn(f) + joinType := joinTypeInner + if c.Modifier == models.CriterionModifierIsNull || c.Modifier == models.CriterionModifierNotMatchesRegex { + joinType = joinTypeLeft + } + addJoinFn(f, joinType) } stringCriterionHandler(c, column)(ctx, f) } @@ -104,16 +145,20 @@ func enumCriterionHandler(modifier models.CriterionModifier, values []string, co } } -func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { - if addJoinFn != nil { - addJoinFn(f) - } - addWildcards := true - not := false - if modifier := c.Modifier; c.Modifier.IsValid() { + if addJoinFn != nil { + joinType := joinTypeInner + if modifier == models.CriterionModifierIsNull || modifier == models.CriterionModifierNotMatchesRegex { + joinType = joinTypeLeft + } + addJoinFn(f, joinType) + } + addWildcards := true + not := false + switch modifier { case models.CriterionModifierIncludes: f.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)) @@ -194,11 +239,15 @@ func getPathSearchClauseMany(pathColumn, basenameColumn, p string, addWildcards, return getPathSearchClause(pathColumn, basenameColumn, trimmedQuery, addWildcards, not) } -func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { if addJoinFn != nil { - addJoinFn(f) + joinType := joinTypeInner + if c.Modifier == models.CriterionModifierIsNull { + joinType = joinTypeLeft + } + addJoinFn(f, joinType) } clause, args := getIntCriterionWhereClause(column, *c) f.addWhere(clause, args...) @@ -206,11 +255,15 @@ func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn f } } -func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { if addJoinFn != nil { - addJoinFn(f) + joinType := joinTypeInner + if c.Modifier == models.CriterionModifierIsNull { + joinType = joinTypeLeft + } + addJoinFn(f, joinType) } clause, args := getFloatCriterionWhereClause(column, *c) f.addWhere(clause, args...) @@ -218,11 +271,15 @@ func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoin } } -func floatIntCriterionHandler(durationFilter *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func floatIntCriterionHandler(durationFilter *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if durationFilter != nil { if addJoinFn != nil { - addJoinFn(f) + joinType := joinTypeInner + if durationFilter.Modifier == models.CriterionModifierIsNull { + joinType = joinTypeLeft + } + addJoinFn(f, joinType) } clause, args := getIntCriterionWhereClause("cast("+column+" as int)", *durationFilter) f.addWhere(clause, args...) @@ -230,11 +287,11 @@ func floatIntCriterionHandler(durationFilter *models.IntCriterionInput, column s } } -func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { if addJoinFn != nil { - addJoinFn(f) + addJoinFn(f, joinTypeInner) } var v string if *c { @@ -289,11 +346,11 @@ func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) crit } } -func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if resolution != nil && resolution.Value.IsValid() { if addJoinFn != nil { - addJoinFn(f) + addJoinFn(f, joinTypeInner) } mn := resolution.Value.GetMinResolution() @@ -315,11 +372,11 @@ func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, hei } } -func orientationCriterionHandler(orientation *models.OrientationCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func orientationCriterionHandler(orientation *models.OrientationCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if orientation != nil { if addJoinFn != nil { - addJoinFn(f) + addJoinFn(f, joinTypeInner) } var clauses []sqlClause @@ -362,7 +419,7 @@ type joinedMultiCriterionHandlerBuilder struct { // foreign key of the foreign object on the join table foreignFK string - addJoinTable func(f *filterBuilder) + addJoinTable func(f *filterBuilder, joinType joinType) } func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInput) criterionHandlerFunc { @@ -378,11 +435,13 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInp if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string + joinType := joinTypeLeft if criterion.Modifier == models.CriterionModifierNotNull { notClause = "NOT" + joinType = joinTypeInner } - m.addJoinTable(f) + m.addJoinTable(f, joinType) f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ "table": joinAlias, @@ -415,11 +474,11 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInp switch criterion.Modifier { case models.CriterionModifierIncludes: // includes any of the provided ids - m.addJoinTable(f) + m.addJoinTable(f, joinTypeInner) whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) case models.CriterionModifierEquals: // includes only the provided ids - m.addJoinTable(f) + m.addJoinTable(f, joinTypeInner) whereClause = utils.StrFormat("{joinAlias}.{foreignFK} IN {inBinding} AND (SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ "joinAlias": joinAlias, "foreignFK": m.foreignFK, @@ -434,7 +493,7 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInp f.setError(fmt.Errorf("not equals modifier is not supported for multi criterion input")) case models.CriterionModifierIncludesAll: // includes all of the provided ids - m.addJoinTable(f) + m.addJoinTable(f, joinTypeInner) whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) } @@ -468,7 +527,7 @@ type multiCriterionHandlerBuilder struct { foreignFK string // function that will be called to perform any necessary joins - addJoinsFunc func(f *filterBuilder) + addJoinsFunc func(f *filterBuilder, joinType joinType) } func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc { @@ -500,7 +559,7 @@ func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionI } if m.addJoinsFunc != nil { - m.addJoinsFunc(f) + m.addJoinsFunc(f, joinTypeInner) } whereClause, havingClause := getMultiCriterionClause(m.primaryTable, m.foreignTable, m.joinTable, m.primaryFK, m.foreignFK, criterion) @@ -536,7 +595,7 @@ type stringListCriterionHandlerBuilder struct { // string field on the join table stringColumn string - addJoinTable func(f *filterBuilder) + addJoinTable func(f *filterBuilder, joinType joinType) excludeHandler func(f *filterBuilder, criterion *models.StringCriterionInput) } @@ -570,7 +629,11 @@ func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCrit // Modifier: models.CriterionModifierNotNull, // }, m.joinTable+"."+m.stringColumn)(ctx, f) } else { - m.addJoinTable(f) + joinType := joinTypeInner + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotMatchesRegex { + joinType = joinTypeLeft + } + m.addJoinTable(f, joinType) stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(ctx, f) } } @@ -1028,14 +1091,18 @@ func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder) joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint) } - f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause) + joinType := joinTypeInner + if h.c.Modifier == models.CriterionModifierIsNull || h.c.Modifier == models.CriterionModifierNotMatchesRegex { + joinType = joinTypeLeft + } + f.addJoin(joinType, stashIDRepo.tableName, h.stashIDTableAs, joinClause) v := "" if h.c.StashID != nil { v = *h.c.StashID } - stringCriterionHandler(&models.StringCriterionInput{ + stringNoTrimCriterionHandler(&models.StringCriterionInput{ Value: v, Modifier: h.c.Modifier, }, t+".stash_id")(ctx, f) @@ -1064,7 +1131,12 @@ func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder) joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint) } - f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause) + joinType := joinTypeInner + if h.c.Modifier == models.CriterionModifierIsNull { + joinType = joinTypeLeft + } + + f.addJoin(joinType, stashIDRepo.tableName, h.stashIDTableAs, joinClause) switch h.c.Modifier { case models.CriterionModifierIsNull: @@ -1089,11 +1161,16 @@ func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder) } type relatedFilterHandler struct { - relatedIDCol string - relatedRepo repository + // column on the primary table that relates to the related table (eg scene_id) + relatedIDCol string + // repository for the related table (eg sceneRepository) + relatedRepo repository + // handler for the filter on the related table relatedHandler criterionHandler - joinFn func(f *filterBuilder) - directJoin bool + // optional function to perform the necessary join(s) to the related table + joinFn func(f *filterBuilder) + // if true, related filter handler will be run using the existing filterBuilder instead of a subquery. + directJoin bool } func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) { @@ -1124,7 +1201,7 @@ func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) { return } - f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.args...) + f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.allArgs()...) } type phashDistanceCriterionHandler struct { diff --git a/pkg/sqlite/custom_fields.go b/pkg/sqlite/custom_fields.go index 63f85b250..22dbbfeb2 100644 --- a/pkg/sqlite/custom_fields.go +++ b/pkg/sqlite/custom_fields.go @@ -192,6 +192,10 @@ func (s *customFieldsStore) GetCustomFieldsBulk(ctx context.Context, ids []int) const single = false ret := make([]models.CustomFieldMap, len(ids)) + // initialise ret with empty maps for each id + for i := range ret { + ret[i] = make(map[string]interface{}) + } idi := make(map[int]int, len(ids)) for i, id := range ids { @@ -257,8 +261,8 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str h.innerJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("%[1]s.value IN %s", joinAs, getInBinding(len(cv))), cv...) case models.CriterionModifierNotEquals: - h.innerJoin(f, joinAs, cc.Field) - f.addWhere(fmt.Sprintf("%[1]s.value NOT IN %s", joinAs, getInBinding(len(cv))), cv...) + h.leftJoin(f, joinAs, cc.Field) + f.addWhere(fmt.Sprintf("(%[1]s.value NOT IN %s OR %[1]s.value IS NULL)", joinAs, getInBinding(len(cv))), cv...) case models.CriterionModifierIncludes: clauses := make([]sqlClause, len(cv)) for i, v := range cv { @@ -268,7 +272,7 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str f.whereClauses = append(f.whereClauses, clauses...) case models.CriterionModifierExcludes: for _, v := range cv { - f.addWhere(fmt.Sprintf("%[1]s.value NOT LIKE ?", joinAs), fmt.Sprintf("%%%v%%", v)) + f.addWhere(fmt.Sprintf("(%[1]s.value NOT LIKE ? OR %[1]s.value IS NULL)", joinAs), fmt.Sprintf("%%%v%%", v)) } h.leftJoin(f, joinAs, cc.Field) case models.CriterionModifierMatchesRegex: @@ -311,8 +315,8 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str h.innerJoin(f, joinAs, cc.Field) f.addWhere(fmt.Sprintf("%s.value BETWEEN ? AND ?", joinAs), cv[0], cv[1]) case models.CriterionModifierNotBetween: - h.innerJoin(f, joinAs, cc.Field) - f.addWhere(fmt.Sprintf("%s.value NOT BETWEEN ? AND ?", joinAs), cv[0], cv[1]) + h.leftJoin(f, joinAs, cc.Field) + f.addWhere(fmt.Sprintf("(%s.value NOT BETWEEN ? AND ? OR %[1]s.value IS NULL)", joinAs), cv[0], cv[1]) case models.CriterionModifierLessThan: if len(cv) != 1 { f.setError(fmt.Errorf("expected 1 value for custom field criterion modifier LESS_THAN, got %d", len(cv))) diff --git a/pkg/sqlite/custom_fields_test.go b/pkg/sqlite/custom_fields_test.go index a2c045851..5d5545210 100644 --- a/pkg/sqlite/custom_fields_test.go +++ b/pkg/sqlite/custom_fields_test.go @@ -240,3 +240,21 @@ func TestSceneSetCustomFields(t *testing.T) { testSetCustomFields(t, "Scene", db.Scene, sceneIDs[sceneIdx], getSceneCustomFields(sceneIdx)) } + +func TestGallerySetCustomFields(t *testing.T) { + galleryIdx := galleryIdxWithChapters + + testSetCustomFields(t, "Gallery", db.Gallery, galleryIDs[galleryIdx], getGalleryCustomFields(galleryIdx)) +} + +func TestImageSetCustomFields(t *testing.T) { + imageIdx := imageIdx2WithGallery + + testSetCustomFields(t, "Image", db.Image, imageIDs[imageIdx], getImageCustomFields(imageIdx)) +} + +func TestGroupSetCustomFields(t *testing.T) { + groupIdx := groupIdxWithScene + + testSetCustomFields(t, "Group", db.Group, groupIDs[groupIdx], getGroupCustomFields(groupIdx)) +} diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 5b67e5602..7c383dc4c 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 80 +var appSchemaVersion uint = 85 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index 1be5648b4..b8e807e37 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -695,7 +695,7 @@ func (qb *FileStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectD // FindAllByPaths returns the all files that are within any of the given paths. // Returns all if limit is < 0. // Returns all files if p is empty. -func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]models.File, error) { +func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]models.File, error) { table := qb.table() folderTable := folderTableMgr.table @@ -706,6 +706,10 @@ func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, limit, offs q = qb.allInPaths(q, p) + if !includeZipContents { + q = q.Where(table.Col("zip_file_id").IsNull()) + } + if limit > -1 { q = q.Limit(uint(limit)) } @@ -975,7 +979,7 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File Megapixels float64 Size int64 }{} - if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil { return nil, err } diff --git a/pkg/sqlite/file_filter.go b/pkg/sqlite/file_filter.go index 157efb1d8..b8e9253a0 100644 --- a/pkg/sqlite/file_filter.go +++ b/pkg/sqlite/file_filter.go @@ -238,22 +238,32 @@ func (qb *fileFilterHandler) hashesCriterionHandler(hashes []*models.Fingerprint t := fmt.Sprintf("file_fingerprints_%d", i) f.addLeftJoin(fingerprintTable, t, fmt.Sprintf("files.id = %s.file_id AND %s.type = ?", t, t), hash.Type) - value, _ := utils.StringToPhash(hash.Value) distance := 0 if hash.Distance != nil { distance = *hash.Distance } - if distance > 0 { - // needed to avoid a type mismatch - f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t)) - f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance) + // Only phash supports distance matching and is stored as integer + if hash.Type == models.FingerprintTypePhash { + value, err := utils.StringToPhash(hash.Value) + if err != nil { + f.setError(fmt.Errorf("invalid phash value: %w", err)) + return + } + if distance > 0 { + // needed to avoid a type mismatch + f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t)) + f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance) + } else { + intCriterionHandler(&models.IntCriterionInput{ + Value: int(value), + Modifier: models.CriterionModifierEquals, + }, t+".fingerprint", nil)(ctx, f) + } } else { - // use the default handler - intCriterionHandler(&models.IntCriterionInput{ - Value: int(value), - Modifier: models.CriterionModifierEquals, - }, t+".fingerprint", nil)(ctx, f) + // All other fingerprint types (md5, oshash, sha1, etc.) are stored as strings + // Use exact match for string-based fingerprints + f.addWhere(fmt.Sprintf("%s.fingerprint = ?", t), hash.Value) } } } @@ -290,15 +300,19 @@ func (qb *videoFileFilterHandler) criterionHandler() criterionHandler { } } -func (qb *videoFileFilterHandler) addVideoFilesTable(f *filterBuilder) { - f.addLeftJoin(videoFileTable, "", "video_files.file_id = files.id") +func (qb *videoFileFilterHandler) addVideoFilesTable(f *filterBuilder, joinType joinType) { + f.addJoin(joinType, videoFileTable, "", "video_files.file_id = files.id") } -func (qb *videoFileFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func (qb *videoFileFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if codec != nil { if addJoinFn != nil { - addJoinFn(f) + joinType := joinTypeInner + if codec.Modifier == models.CriterionModifierIsNull || codec.Modifier == models.CriterionModifierNotMatchesRegex { + joinType = joinTypeLeft + } + addJoinFn(f, joinType) } stringCriterionHandler(codec, codecColumn)(ctx, f) @@ -312,8 +326,8 @@ func (qb *videoFileFilterHandler) captionCriterionHandler(captions *models.Strin primaryFK: sceneIDColumn, joinTable: videoCaptionsTable, stringColumn: captionCodeColumn, - addJoinTable: func(f *filterBuilder) { - f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = files.id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + f.addJoin(joinType, videoCaptionsTable, "", "video_captions.file_id = files.id") }, excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) { excludeClause := `files.id NOT IN ( @@ -351,6 +365,6 @@ func (qb *imageFileFilterHandler) criterionHandler() criterionHandler { } } -func (qb *imageFileFilterHandler) addImageFilesTable(f *filterBuilder) { - f.addLeftJoin(imageFileTable, "", "image_files.file_id = files.id") +func (qb *imageFileFilterHandler) addImageFilesTable(f *filterBuilder, joinType joinType) { + f.addJoin(joinType, imageFileTable, "", "image_files.file_id = files.id") } diff --git a/pkg/sqlite/file_filter_test.go b/pkg/sqlite/file_filter_test.go index 50eed0129..648e502f7 100644 --- a/pkg/sqlite/file_filter_test.go +++ b/pkg/sqlite/file_filter_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" "github.com/stretchr/testify/assert" ) @@ -81,7 +82,45 @@ func TestFileQuery(t *testing.T) { includeIDs: []models.FileID{fileIDs[fileIdxInZip]}, excludeIdxs: []int{fileIdxStartImageFiles}, }, - // TODO - add more tests for other file filters + { + name: "hashes md5", + filter: &models.FileFilterType{ + Hashes: []*models.FingerprintFilterInput{ + { + Type: models.FingerprintTypeMD5, + Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "md5"), + }, + }, + }, + includeIdxs: []int{fileIdxStartVideoFiles}, + excludeIdxs: []int{fileIdxStartImageFiles}, + }, + { + name: "hashes oshash", + filter: &models.FileFilterType{ + Hashes: []*models.FingerprintFilterInput{ + { + Type: models.FingerprintTypeOshash, + Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "oshash"), + }, + }, + }, + includeIdxs: []int{fileIdxStartVideoFiles}, + excludeIdxs: []int{fileIdxStartImageFiles}, + }, + { + name: "hashes phash", + filter: &models.FileFilterType{ + Hashes: []*models.FingerprintFilterInput{ + { + Type: models.FingerprintTypePhash, + Value: utils.PhashToString(getFilePhash(fileIdxStartImageFiles)), + }, + }, + }, + includeIdxs: []int{fileIdxStartImageFiles}, + excludeIdxs: []int{fileIdxStartVideoFiles}, + }, } for _, tt := range tests { diff --git a/pkg/sqlite/file_test.go b/pkg/sqlite/file_test.go index 8422390c0..55c41f4f7 100644 --- a/pkg/sqlite/file_test.go +++ b/pkg/sqlite/file_test.go @@ -572,7 +572,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) { { "by MD5", models.Fingerprint{ - Type: "MD5", + Type: models.FingerprintTypeMD5, Fingerprint: getPrefixedStringValue("file", fileIdxZip, "md5"), }, []models.File{makeFileWithID(fileIdxZip)}, @@ -581,7 +581,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) { { "by OSHASH", models.Fingerprint{ - Type: "OSHASH", + Type: models.FingerprintTypeOshash, Fingerprint: getPrefixedStringValue("file", fileIdxZip, "oshash"), }, []models.File{makeFileWithID(fileIdxZip)}, @@ -590,7 +590,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) { { "non-existing", models.Fingerprint{ - Type: "OSHASH", + Type: models.FingerprintTypeOshash, Fingerprint: "foo", }, nil, diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index fa6759ae6..c5e78c1d3 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -90,11 +90,18 @@ func andClauses(clauses ...sqlClause) sqlClause { return joinClauses("AND", clauses...) } +type joinType string + +const ( + joinTypeLeft joinType = "LEFT" + joinTypeInner joinType = "INNER" +) + type join struct { table string as string onClause string - joinType string + joinType joinType args []interface{} // if true, indicates this is required for sorting only @@ -115,15 +122,19 @@ func (j join) alias() string { return j.as } +func (j join) getJoinType() joinType { + if j.joinType == "" { + return joinTypeLeft + } + return j.joinType +} + func (j join) toSQL() string { asStr := "" - joinStr := j.joinType + joinStr := j.getJoinType() if j.as != "" && j.as != j.table { asStr = " AS " + j.as } - if j.joinType == "" { - joinStr = "LEFT" - } return fmt.Sprintf("%s JOIN %s%s ON %s", joinStr, j.table, asStr, j.onClause) } @@ -141,6 +152,12 @@ func (j *joins) addUnique(newJoin join) bool { if !newJoin.sort && jj.sort { (*j)[i].sort = false } + + // if the new join is inner, override existing left join + if newJoin.getJoinType() == joinTypeInner && jj.getJoinType() == joinTypeLeft { + (*j)[i].joinType = joinTypeInner + } + break } } @@ -243,6 +260,23 @@ func (f *filterBuilder) not(n *filterBuilder) { f.subFilterOp = notOp } +// addJoin adds a join to the filter. The join is expressed in SQL as: +// JOIN [AS ] ON +// The AS is omitted if as is empty. +// This method does not add a join if it its alias/table name is already +// present in another existing join. +func (f *filterBuilder) addJoin(joinType joinType, table, as, onClause string, args ...interface{}) { + newJoin := join{ + table: table, + as: as, + onClause: onClause, + joinType: joinType, + args: args, + } + + f.joins.add(newJoin) +} + // addLeftJoin adds a left join to the filter. The join is expressed in SQL as: // LEFT JOIN
[AS ] ON // The AS is omitted if as is empty. @@ -253,7 +287,7 @@ func (f *filterBuilder) addLeftJoin(table, as, onClause string, args ...interfac table: table, as: as, onClause: onClause, - joinType: "LEFT", + joinType: joinTypeLeft, args: args, } @@ -270,7 +304,7 @@ func (f *filterBuilder) addInnerJoin(table, as, onClause string, args ...interfa table: table, as: as, onClause: onClause, - joinType: "INNER", + joinType: joinTypeInner, args: args, } diff --git a/pkg/sqlite/folder.go b/pkg/sqlite/folder.go index f250f7861..6cd1e0ade 100644 --- a/pkg/sqlite/folder.go +++ b/pkg/sqlite/folder.go @@ -20,6 +20,7 @@ const folderIDColumn = "folder_id" type folderRow struct { ID models.FolderID `db:"id" goqu:"skipinsert"` + Basename string `db:"basename"` Path string `db:"path"` ZipFileID null.Int `db:"zip_file_id"` ParentFolderID null.Int `db:"parent_folder_id"` @@ -30,6 +31,8 @@ type folderRow struct { func (r *folderRow) fromFolder(o models.Folder) { r.ID = o.ID + // derive basename from path + r.Basename = filepath.Base(o.Path) r.Path = o.Path r.ZipFileID = nullIntFromFileIDPtr(o.ZipFileID) r.ParentFolderID = nullIntFromFolderIDPtr(o.ParentFolderID) @@ -322,6 +325,126 @@ func (qb *FolderStore) FindByParentFolderID(ctx context.Context, parentFolderID return ret, nil } +func (qb *FolderStore) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) { + table := qb.table() + + // SQL recursive query to get all parent folder IDs for each folder ID + /* + WITH RECURSIVE parent_folders AS ( + SELECT id, parent_folder_id + FROM folders + WHERE id IN (folderIDs) + + UNION ALL + + SELECT f.id, f.parent_folder_id + FROM folders f + INNER JOIN parent_folders pf ON f.id = pf.parent_folder_id + ) + SELECT id, parent_folder_id FROM parent_folders; + */ + const parentFolders = "parent_folders" + const parentFolderID = "parent_folder_id" + const parentID = "parent_id" + const foldersAlias = "f" + + const parentFoldersAlias = "pf" + foldersAliasedI := table.As(foldersAlias) + parentFoldersI := goqu.T(parentFolders).As(parentFoldersAlias) + + q := dialect.From(parentFolders).Prepared(true). + WithRecursive(parentFolders, + dialect.From(table).Select(table.Col(idColumn), table.Col(parentFolderID).As(parentID)). + Where(table.Col(idColumn).In(folderIDs)). + Union( + dialect.From(foldersAliasedI).InnerJoin( + parentFoldersI, + goqu.On(foldersAliasedI.Col(idColumn).Eq(parentFoldersI.Col(parentID))), + ).Select(foldersAliasedI.Col(idColumn), foldersAliasedI.Col(parentFolderID).As(parentID)), + ), + ).Select(idColumn, parentID) + + type resultRow struct { + FolderID models.FolderID `db:"id"` + ParentFolderID null.Int `db:"parent_id"` + } + + folderMap := make(map[models.FolderID]models.FolderID) + + if err := queryFunc(ctx, q, false, func(r *sqlx.Rows) error { + var row resultRow + if err := r.StructScan(&row); err != nil { + return err + } + + if row.ParentFolderID.Valid { + folderMap[row.FolderID] = models.FolderID(row.ParentFolderID.Int64) + } else { + folderMap[row.FolderID] = 0 + } + + return nil + }); err != nil { + return nil, err + } + + ret := make([][]models.FolderID, len(folderIDs)) + + for i, folderID := range folderIDs { + var parents []models.FolderID + currentID := folderID + + for { + parentID, exists := folderMap[currentID] + if !exists || parentID == 0 { + break + } + parents = append(parents, parentID) + currentID = parentID + } + + ret[i] = parents + } + + return ret, nil +} + +func (qb *FolderStore) GetManySubFolderIDs(ctx context.Context, parentFolderIDs []models.FolderID) ([][]models.FolderID, error) { + table := qb.table() + q := dialect.From(table).Select( + table.Col(idColumn), + table.Col("parent_folder_id"), + ).Where(qb.table().Col("parent_folder_id").In(parentFolderIDs)) + + sql, args, err := q.ToSQL() + if err != nil { + return nil, fmt.Errorf("building query: %w", err) + } + + var results []struct { + FolderID int `db:"id"` + ParentFolderID models.FolderID `db:"parent_folder_id"` + } + + if err := querySelect(ctx, sql, args, &results); err != nil { + return nil, fmt.Errorf("getting folders by parent folder ids %v: %w", parentFolderIDs, err) + } + + retMap := make(map[models.FolderID][]models.FolderID) + + for _, v := range results { + retMap[v.ParentFolderID] = append(retMap[v.ParentFolderID], models.FolderID(v.FolderID)) + } + + ret := make([][]models.FolderID, len(parentFolderIDs)) + + for i, parentID := range parentFolderIDs { + ret[i] = retMap[parentID] + } + + return ret, nil +} + func (qb *FolderStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectDataset { table := qb.table() @@ -340,10 +463,14 @@ func (qb *FolderStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.Selec // FindAllInPaths returns the all folders that are or are within any of the given paths. // Returns all if limit is < 0. // Returns all folders if p is empty. -func (qb *FolderStore) FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]*models.Folder, error) { +func (qb *FolderStore) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]*models.Folder, error) { q := qb.selectDataset().Prepared(true) q = qb.allInPaths(q, p) + if !includeZipContents { + q = q.Where(qb.table().Col("zip_file_id").IsNull()) + } + if limit > -1 { q = q.Limit(uint(limit)) } @@ -513,7 +640,7 @@ func (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.Fo Megapixels float64 Size int64 }{} - if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil { return nil, err } @@ -527,6 +654,7 @@ var folderSortOptions = sortOptions{ "created_at", "id", "path", + "basename", "random", "updated_at", } diff --git a/pkg/sqlite/folder_filter.go b/pkg/sqlite/folder_filter.go index 6b2bd96e9..e0145bcca 100644 --- a/pkg/sqlite/folder_filter.go +++ b/pkg/sqlite/folder_filter.go @@ -65,6 +65,7 @@ func (qb *folderFilterHandler) criterionHandler() criterionHandler { folderFilter := qb.folderFilter return compoundHandler{ stringCriterionHandler(folderFilter.Path, qb.table.Col("path")), + stringCriterionHandler(folderFilter.Basename, qb.table.Col("basename")), ×tampCriterionHandler{folderFilter.ModTime, qb.table.Col("mod_time"), nil}, qb.parentFolderCriterionHandler(folderFilter.ParentFolder), diff --git a/pkg/sqlite/folder_filter_test.go b/pkg/sqlite/folder_filter_test.go index c1c7d7a37..c08208f30 100644 --- a/pkg/sqlite/folder_filter_test.go +++ b/pkg/sqlite/folder_filter_test.go @@ -33,6 +33,17 @@ func TestFolderQuery(t *testing.T) { includeIdxs: []int{folderIdxWithSubFolder, folderIdxWithParentFolder}, excludeIdxs: []int{folderIdxInZip}, }, + { + name: "basename", + filter: &models.FolderFilterType{ + Basename: &models.StringCriterionInput{ + Value: getFolderBasename(folderIdxWithParentFolder, nil), + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIdxs: []int{folderIdxWithParentFolder}, + excludeIdxs: []int{folderIdxInZip}, + }, { name: "parent folder", filter: &models.FolderFilterType{ diff --git a/pkg/sqlite/folder_test.go b/pkg/sqlite/folder_test.go index 15b2b96b8..072a1167f 100644 --- a/pkg/sqlite/folder_test.go +++ b/pkg/sqlite/folder_test.go @@ -186,8 +186,6 @@ func Test_FolderStore_Update(t *testing.T) { } assert.Equal(copy, *s) - - return }) } } @@ -239,3 +237,75 @@ func Test_FolderStore_FindByPath(t *testing.T) { }) } } + +func Test_FolderStore_GetManyParentFolderIDs(t *testing.T) { + var empty []models.FolderID + emptyResult := [][]models.FolderID{empty} + tests := []struct { + name string + parentFolderIDs []models.FolderID + want [][]models.FolderID + wantErr bool + }{ + { + "valid with parent folders", + []models.FolderID{folderIDs[folderIdxWithParentFolder]}, + [][]models.FolderID{ + { + folderIDs[folderIdxWithSubFolder], + folderIDs[folderIdxRoot], + }, + }, + false, + }, + { + "valid multiple folders", + []models.FolderID{ + folderIDs[folderIdxWithParentFolder], + folderIDs[folderIdxWithSceneFiles], + }, + [][]models.FolderID{ + { + folderIDs[folderIdxWithSubFolder], + folderIDs[folderIdxRoot], + }, + { + folderIDs[folderIdxForObjectFiles], + folderIDs[folderIdxRoot], + }, + }, + false, + }, + { + "valid without parent folders", + []models.FolderID{folderIDs[folderIdxRoot]}, + emptyResult, + false, + }, + { + "invalid folder id", + []models.FolderID{invalidFolderID}, + emptyResult, + // does not error, just returns empty result + false, + }, + } + + qb := db.Folder + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + got, err := qb.GetManyParentFolderIDs(ctx, tt.parentFolderIDs) + if (err != nil) != tt.wantErr { + assert.Errorf(err, "FolderStore.GetManyParentFolderIDs() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + + assert.Equal(got, tt.want) + }) + } +} diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 41729057b..ad7a94b04 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -183,6 +183,8 @@ var ( ) type GalleryStore struct { + customFieldsStore + tableMgr *table fileStore *FileStore @@ -191,6 +193,10 @@ type GalleryStore struct { func NewGalleryStore(fileStore *FileStore, folderStore *FolderStore) *GalleryStore { return &GalleryStore{ + customFieldsStore: customFieldsStore{ + table: galleriesCustomFieldsTable, + fk: galleriesCustomFieldsTable.Col(galleryIDColumn), + }, tableMgr: galleryTableMgr, fileStore: fileStore, folderStore: folderStore, @@ -231,18 +237,18 @@ func (qb *GalleryStore) selectDataset() *goqu.SelectDataset { ) } -func (qb *GalleryStore) Create(ctx context.Context, newObject *models.Gallery, fileIDs []models.FileID) error { +func (qb *GalleryStore) Create(ctx context.Context, newObject *models.CreateGalleryInput) error { var r galleryRow - r.fromGallery(*newObject) + r.fromGallery(*newObject.Gallery) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } - if len(fileIDs) > 0 { + if len(newObject.FileIDs) > 0 { const firstPrimary = true - if err := galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, fileIDs); err != nil { + if err := galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, newObject.FileIDs); err != nil { return err } } @@ -269,19 +275,24 @@ func (qb *GalleryStore) Create(ctx context.Context, newObject *models.Gallery, f } } + const partial = false + if err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil { + return err + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } - *newObject = *updated + *newObject.Gallery = *updated return nil } -func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.Gallery) error { +func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.UpdateGalleryInput) error { var r galleryRow - r.fromGallery(*updatedObject) + r.fromGallery(*updatedObject.Gallery) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err @@ -319,6 +330,10 @@ func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.Galler } } + if err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil { + return err + } + return nil } @@ -364,6 +379,10 @@ func (qb *GalleryStore) UpdatePartial(ctx context.Context, id int, partial model } } + if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { + return nil, err + } + return qb.find(ctx, id) } @@ -907,3 +926,7 @@ func (qb *GalleryStore) ResetCover(ctx context.Context, galleryID int) error { func (qb *GalleryStore) GetSceneIDs(ctx context.Context, id int) ([]int, error) { return galleryRepository.scenes.getIDs(ctx, id) } + +func (qb *GalleryStore) AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error { + return galleriesScenesTableMgr.insertJoins(ctx, galleryID, sceneIDs) +} diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index f05ff7b81..c70af1308 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -84,6 +84,7 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler { }), qb.pathCriterionHandler(filter.Path), + qb.parentFolderCriterionHandler(filter.ParentFolder), qb.fileCountCriterionHandler(filter.FileCount), intCriterionHandler(filter.Rating100, "galleries.rating", nil), qb.urlsCriterionHandler(filter.URL), @@ -105,6 +106,13 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{filter.CreatedAt, "galleries.created_at", nil}, ×tampCriterionHandler{filter.UpdatedAt, "galleries.updated_at", nil}, + &customFieldsFilterHandler{ + table: galleriesCustomFieldsTable.GetTable(), + fkCol: galleryIDColumn, + c: filter.CustomFields, + idCol: "galleries.id", + }, + &relatedFilterHandler{ relatedIDCol: "scenes_galleries.scene_id", relatedRepo: sceneRepository.repository, @@ -185,15 +193,15 @@ func (qb *galleryFilterHandler) urlsCriterionHandler(url *models.StringCriterion primaryFK: galleryIDColumn, joinTable: galleriesURLsTable, stringColumn: galleriesURLColumn, - addJoinTable: func(f *filterBuilder) { - galleriesURLsTableMgr.join(f, "", "galleries.id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + galleriesURLsTableMgr.join(f, joinType, "", "galleries.id") }, } return h.handler(url) } -func (qb *galleryFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { +func (qb *galleryFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder, joinType joinType)) multiCriterionHandlerBuilder { return multiCriterionHandlerBuilder{ primaryTable: galleryTable, foreignTable: foreignTable, @@ -271,6 +279,65 @@ func (qb *galleryFilterHandler) pathCriterionHandler(c *models.StringCriterionIn } } +func (qb *galleryFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if folder == nil { + return + } + + galleryRepository.addFilesTable(f) + f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id") + + criterion := *folder + switch criterion.Modifier { + case models.CriterionModifierEquals: + criterion.Modifier = models.CriterionModifierIncludes + case models.CriterionModifierNotEquals: + criterion.Modifier = models.CriterionModifierExcludes + } + + // only allow includes or excludes filters + if criterion.Modifier != models.CriterionModifierIncludes && criterion.Modifier != models.CriterionModifierExcludes { + f.setError(fmt.Errorf("invalid modifier for parent folder criterion: %s", criterion.Modifier)) + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { + return + } + + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludes + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Value, "folders", "", "parent_folder_id", "parent_folder_id", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + // combine clauses with OR to handle zip file or folder + c1 := makeClause(fmt.Sprintf("files.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) + c2 := makeClause(fmt.Sprintf("gallery_folder.parent_folder_id IN (SELECT column2 FROM (%s))", valuesClause)) + f.whereClauses = append(f.whereClauses, orClauses(c1, c2)) + } + + if len(criterion.Excludes) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, "folders", "", "parent_folder_id", "parent_folder_id", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + f.addWhere(fmt.Sprintf("files.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR folders.parent_folder_id IS NULL", valuesClause)) + f.addWhere(fmt.Sprintf("gallery_folder.parent_folder_id NOT IN (SELECT column2 FROM (%s)) OR gallery_folder.parent_folder_id IS NULL", valuesClause)) + } + } +} + func (qb *galleryFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: galleryTable, @@ -286,7 +353,7 @@ func (qb *galleryFilterHandler) missingCriterionHandler(isMissing *string) crite if isMissing != nil && *isMissing != "" { switch *isMissing { case "url": - galleriesURLsTableMgr.join(f, "", "galleries.id") + galleriesURLsTableMgr.leftJoin(f, "", "galleries.id") f.addWhere("gallery_urls.url IS NULL") case "scenes": f.addLeftJoin("scenes_galleries", "scenes_join", "scenes_join.gallery_id = galleries.id") @@ -294,14 +361,23 @@ func (qb *galleryFilterHandler) missingCriterionHandler(isMissing *string) crite case "studio": f.addWhere("galleries.studio_id IS NULL") case "performers": - galleryRepository.performers.join(f, "performers_join", "galleries.id") + galleryRepository.performers.leftJoin(f, "performers_join", "galleries.id") f.addWhere("performers_join.gallery_id IS NULL") case "date": f.addWhere("galleries.date IS NULL OR galleries.date IS \"\"") case "tags": - galleryRepository.tags.join(f, "tags_join", "galleries.id") + galleryRepository.tags.leftJoin(f, "tags_join", "galleries.id") f.addWhere("tags_join.gallery_id IS NULL") + case "cover": + f.addLeftJoin("galleries_images", "cover_join", "cover_join.gallery_id = galleries.id AND cover_join.cover = 1") + f.addWhere("cover_join.image_id IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "title", "code", "rating", "details", "photographer", + }); err != nil { + f.setError(err) + return + } f.addWhere("(galleries." + *isMissing + " IS NULL OR TRIM(galleries." + *isMissing + ") = '')") } } @@ -334,9 +410,9 @@ func (qb *galleryFilterHandler) tagCountCriterionHandler(tagCount *models.IntCri } func (qb *galleryFilterHandler) scenesCriterionHandler(scenes *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - galleryRepository.scenes.join(f, "", "galleries.id") - f.addLeftJoin("scenes", "", "scenes_galleries.scene_id = scenes.id") + addJoinsFunc := func(f *filterBuilder, joinType joinType) { + galleryRepository.scenes.join(f, joinType, "", "galleries.id") + f.addJoin(joinType, "scenes", "", "scenes_galleries.scene_id = scenes.id") } h := qb.getMultiCriterionHandlerBuilder(sceneTable, galleriesScenesTable, "scene_id", addJoinsFunc) return h.handler(scenes) @@ -350,8 +426,8 @@ func (qb *galleryFilterHandler) performersCriterionHandler(performers *models.Mu primaryFK: galleryIDColumn, foreignFK: performerIDColumn, - addJoinTable: func(f *filterBuilder) { - galleryRepository.performers.join(f, "performers_join", "galleries.id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + galleryRepository.performers.join(f, joinType, "performers_join", "galleries.id") }, } @@ -439,7 +515,7 @@ func (qb *galleryFilterHandler) performerAgeCriterionHandler(performerAge *model func (qb *galleryFilterHandler) averageResolutionCriterionHandler(resolution *models.ResolutionCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if resolution != nil && resolution.Value.IsValid() { - galleryRepository.images.join(f, "images_join", "galleries.id") + galleryRepository.images.leftJoin(f, "images_join", "galleries.id") f.addLeftJoin("images", "", "images_join.image_id = images.id") f.addLeftJoin("images_files", "", "images.id = images_files.image_id") f.addLeftJoin("image_files", "", "images_files.file_id = image_files.file_id") diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 06d7daf17..9bd0da47f 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -160,7 +160,10 @@ func Test_galleryQueryBuilder_Create(t *testing.T) { fileIDs = []models.FileID{s.Files.List()[0].Base().ID} } - if err := qb.Create(ctx, &s, fileIDs); (err != nil) != tt.wantErr { + if err := qb.Create(ctx, &models.CreateGalleryInput{ + Gallery: &s, + FileIDs: fileIDs, + }); (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.Create() error = %v, wantErr = %v", err, tt.wantErr) } @@ -360,7 +363,9 @@ func Test_galleryQueryBuilder_Update(t *testing.T) { copy := *tt.updatedObject - if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr { + if err := qb.Update(ctx, &models.UpdateGalleryInput{ + Gallery: tt.updatedObject, + }); (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr) } @@ -826,6 +831,79 @@ func Test_galleryQueryBuilder_UpdatePartialRelationships(t *testing.T) { } } +func Test_GalleryStore_UpdatePartialCustomFields(t *testing.T) { + tests := []struct { + name string + id int + partial models.GalleryPartial + expected map[string]interface{} // nil to use the partial + }{ + { + "set custom fields", + galleryIDs[galleryIdx1WithImage], + models.GalleryPartial{ + CustomFields: models.CustomFieldsInput{ + Full: testCustomFields, + }, + }, + nil, + }, + { + "clear custom fields", + galleryIDs[galleryIdx1WithImage], + models.GalleryPartial{ + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{}, + }, + }, + nil, + }, + { + "partial custom fields", + galleryIDs[galleryIdxWithTwoTags], + models.GalleryPartial{ + CustomFields: models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "string": "bbb", + "new_field": "new", + }, + }, + }, + map[string]interface{}{ + "int": int64(2), + "real": 1.2, + "string": "bbb", + "new_field": "new", + }, + }, + } + for _, tt := range tests { + qb := db.Gallery + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) + if err != nil { + t.Errorf("GalleryStore.UpdatePartial() error = %v", err) + return + } + + // ensure custom fields are correct + cf, err := qb.GetCustomFields(ctx, tt.id) + if err != nil { + t.Errorf("GalleryStore.GetCustomFields() error = %v", err) + return + } + if tt.expected == nil { + assert.Equal(tt.partial.CustomFields.Full, cf) + } else { + assert.Equal(tt.expected, cf) + } + }) + } +} + func Test_galleryQueryBuilder_Destroy(t *testing.T) { tests := []struct { name string @@ -3001,6 +3079,245 @@ func TestGallerySetAndResetCover(t *testing.T) { }) } +func TestGalleryQueryCustomFields(t *testing.T) { + tests := []struct { + name string + filter *models.GalleryFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "equals", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")}, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "not equals", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotEquals, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")}, + }, + }, + }, + nil, + []int{galleryIdxWithImage}, + false, + }, + { + "includes", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierIncludes, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")[9:]}, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "excludes", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierExcludes, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")[9:]}, + }, + }, + }, + nil, + []int{galleryIdxWithImage}, + false, + }, + { + "regex", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + []int{galleryIdxWithPerformerTag}, + nil, + false, + }, + { + "invalid regex", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "not matches regex", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithPerformerTag, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + nil, + []int{galleryIdxWithPerformerTag}, + false, + }, + { + "invalid not matches regex", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "null", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "not null", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotNull, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "between", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "not between", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierNotBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + nil, + []int{galleryIdxWithImage}, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + galleries, _, err := db.Gallery.Query(ctx, tt.filter, nil) + if (err != nil) != tt.wantErr { + t.Errorf("GalleryStore.Query() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return + } + + ids := galleriesToIDs(galleries) + include := indexesToIDs(galleryIDs, tt.includeIdxs) + exclude := indexesToIDs(galleryIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Count // TODO All // TODO Query diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index b216335b8..13a6905a5 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -131,6 +131,7 @@ var ( type GroupStore struct { blobJoinQueryBuilder + customFieldsStore tagRelationshipStore groupRelationshipStore @@ -143,6 +144,10 @@ func NewGroupStore(blobStore *BlobStore) *GroupStore { blobStore: blobStore, joinTable: groupTable, }, + customFieldsStore: customFieldsStore{ + table: groupsCustomFieldsTable, + fk: groupsCustomFieldsTable.Col(groupIDColumn), + }, tagRelationshipStore: tagRelationshipStore{ idRelationshipStore: idRelationshipStore{ joinTable: groupsTagsTableMgr, @@ -235,6 +240,10 @@ func (qb *GroupStore) UpdatePartial(ctx context.Context, id int, partial models. return nil, err } + if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { + return nil, err + } + return qb.find(ctx, id) } diff --git a/pkg/sqlite/group_filter.go b/pkg/sqlite/group_filter.go index f81783374..63d056679 100644 --- a/pkg/sqlite/group_filter.go +++ b/pkg/sqlite/group_filter.go @@ -84,6 +84,13 @@ func (qb *groupFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{groupFilter.CreatedAt, "groups.created_at", nil}, ×tampCriterionHandler{groupFilter.UpdatedAt, "groups.updated_at", nil}, + &customFieldsFilterHandler{ + table: groupsCustomFieldsTable.GetTable(), + fkCol: groupIDColumn, + c: groupFilter.CustomFields, + idCol: "groups.id", + }, + &relatedFilterHandler{ relatedIDCol: "groups_scenes.scene_id", relatedRepo: sceneRepository.repository, @@ -112,7 +119,25 @@ func (qb *groupFilterHandler) missingCriterionHandler(isMissing *string) criteri case "scenes": f.addLeftJoin("groups_scenes", "", "groups_scenes.group_id = groups.id") f.addWhere("groups_scenes.scene_id IS NULL") + case "url": + groupsURLsTableMgr.leftJoin(f, "", "groups.id") + f.addWhere("group_urls.url IS NULL") + case "studio": + f.addWhere("groups.studio_id IS NULL") + case "performers": + f.addLeftJoin("groups_scenes", "gs_perf", "groups.id = gs_perf.group_id") + f.addLeftJoin("performers_scenes", "ps_perf", "gs_perf.scene_id = ps_perf.scene_id") + f.addWhere("ps_perf.performer_id IS NULL") + case "tags": + groupRepository.tags.leftJoin(f, "tags_join", "groups.id") + f.addWhere("tags_join.group_id IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "aliases", "description", "director", "date", "rating", + }); err != nil { + f.setError(err) + return + } f.addWhere("(groups." + *isMissing + " IS NULL OR TRIM(groups." + *isMissing + ") = '')") } } @@ -125,8 +150,8 @@ func (qb *groupFilterHandler) urlsCriterionHandler(url *models.StringCriterionIn primaryFK: groupIDColumn, joinTable: groupURLsTable, stringColumn: groupURLColumn, - addJoinTable: func(f *filterBuilder) { - groupsURLsTableMgr.join(f, "", "groups.id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + groupsURLsTableMgr.join(f, joinType, "", "groups.id") }, } diff --git a/pkg/sqlite/group_test.go b/pkg/sqlite/group_test.go index db293dd92..22b551e02 100644 --- a/pkg/sqlite/group_test.go +++ b/pkg/sqlite/group_test.go @@ -566,6 +566,79 @@ func Test_groupQueryBuilder_UpdatePartial(t *testing.T) { } } +func Test_GroupStore_UpdatePartialCustomFields(t *testing.T) { + tests := []struct { + name string + id int + partial models.GroupPartial + expected map[string]interface{} // nil to use the partial + }{ + { + "set custom fields", + groupIDs[groupIdxWithChild], + models.GroupPartial{ + CustomFields: models.CustomFieldsInput{ + Full: testCustomFields, + }, + }, + nil, + }, + { + "clear custom fields", + groupIDs[groupIdxWithChild], + models.GroupPartial{ + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{}, + }, + }, + nil, + }, + { + "partial custom fields", + groupIDs[groupIdxWithTwoTags], + models.GroupPartial{ + CustomFields: models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "string": "bbb", + "new_field": "new", + }, + }, + }, + map[string]interface{}{ + "int": int64(3), + "real": 0.3, + "string": "bbb", + "new_field": "new", + }, + }, + } + for _, tt := range tests { + qb := db.Group + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) + if err != nil { + t.Errorf("GroupStore.UpdatePartial() error = %v", err) + return + } + + // ensure custom fields are correct + cf, err := qb.GetCustomFields(ctx, tt.id) + if err != nil { + t.Errorf("GroupStore.GetCustomFields() error = %v", err) + return + } + if tt.expected == nil { + assert.Equal(tt.partial.CustomFields.Full, cf) + } else { + assert.Equal(tt.expected, cf) + } + }) + } +} + func TestGroupFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { mqb := db.Group @@ -1917,6 +1990,245 @@ func TestGroupFindSubGroupIDs(t *testing.T) { } } +func TestGroupQueryCustomFields(t *testing.T) { + tests := []struct { + name string + filter *models.GroupFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "equals", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getGroupStringValue(groupIdxWithChild, "custom")}, + }, + }, + }, + []int{groupIdxWithChild}, + nil, + false, + }, + { + "not equals", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithChild, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotEquals, + Value: []any{getGroupStringValue(groupIdxWithChild, "custom")}, + }, + }, + }, + nil, + []int{groupIdxWithChild}, + false, + }, + { + "includes", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierIncludes, + Value: []any{getGroupStringValue(groupIdxWithChild, "custom")[9:]}, + }, + }, + }, + []int{groupIdxWithChild}, + nil, + false, + }, + { + "excludes", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithChild, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierExcludes, + Value: []any{getGroupStringValue(groupIdxWithChild, "custom")[9:]}, + }, + }, + }, + nil, + []int{groupIdxWithChild}, + false, + }, + { + "regex", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{".*11_custom"}, + }, + }, + }, + []int{groupIdxWithChildWithScene}, + nil, + false, + }, + { + "invalid regex", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "not matches regex", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithChildWithScene, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{".*11_custom"}, + }, + }, + }, + nil, + []int{groupIdxWithChildWithScene}, + false, + }, + { + "invalid not matches regex", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "null", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithGrandParent, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + }, + []int{groupIdxWithGrandParent}, + nil, + false, + }, + { + "not null", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithGrandParent, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotNull, + }, + }, + }, + []int{groupIdxWithGrandParent}, + nil, + false, + }, + { + "between", + &models.GroupFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + []int{groupIdxWithTag}, + nil, + false, + }, + { + "not between", + &models.GroupFilterType{ + Name: &models.StringCriterionInput{ + Value: getGroupStringValue(groupIdxWithTag, "Name"), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierNotBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + nil, + []int{groupIdxWithTag}, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + groups, _, err := db.Group.Query(ctx, tt.filter, nil) + if (err != nil) != tt.wantErr { + t.Errorf("GroupStore.Query() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return + } + + ids := groupsToIDs(groups) + include := indexesToIDs(groupIDs, tt.includeIdxs) + exclude := indexesToIDs(groupIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Update // TODO Destroy - ensure image is destroyed // TODO Find diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index bcaf3f42f..4d9ebad1b 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -123,23 +123,23 @@ type imageRepositoryType struct { files filesRepository } -func (r *imageRepositoryType) addImagesFilesTable(f *filterBuilder) { - f.addLeftJoin(imagesFilesTable, "", "images_files.image_id = images.id") +func (r *imageRepositoryType) addImagesFilesTable(f *filterBuilder, joinType joinType) { + f.addJoin(joinType, imagesFilesTable, "", "images_files.image_id = images.id") } -func (r *imageRepositoryType) addFilesTable(f *filterBuilder) { - r.addImagesFilesTable(f) - f.addLeftJoin(fileTable, "", "images_files.file_id = files.id") +func (r *imageRepositoryType) addFilesTable(f *filterBuilder, joinType joinType) { + r.addImagesFilesTable(f, joinType) + f.addJoin(joinType, fileTable, "", "images_files.file_id = files.id") } -func (r *imageRepositoryType) addFoldersTable(f *filterBuilder) { - r.addFilesTable(f) - f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") +func (r *imageRepositoryType) addFoldersTable(f *filterBuilder, joinType joinType) { + r.addFilesTable(f, joinType) + f.addJoin(joinType, folderTable, "", "files.parent_folder_id = folders.id") } -func (r *imageRepositoryType) addImageFilesTable(f *filterBuilder) { - r.addImagesFilesTable(f) - f.addLeftJoin(imageFileTable, "", "image_files.file_id = images_files.file_id") +func (r *imageRepositoryType) addImageFilesTable(f *filterBuilder, joinType joinType) { + r.addImagesFilesTable(f, joinType) + f.addJoin(joinType, imageFileTable, "", "image_files.file_id = images_files.file_id") } var ( @@ -185,6 +185,8 @@ var ( ) type ImageStore struct { + customFieldsStore + tableMgr *table oCounterManager @@ -193,6 +195,10 @@ type ImageStore struct { func NewImageStore(r *storeRepository) *ImageStore { return &ImageStore{ + customFieldsStore: customFieldsStore{ + table: imagesCustomFieldsTable, + fk: imagesCustomFieldsTable.Col(imageIDColumn), + }, tableMgr: imageTableMgr, oCounterManager: oCounterManager{imageTableMgr}, repo: r, @@ -236,18 +242,18 @@ func (qb *ImageStore) selectDataset() *goqu.SelectDataset { ) } -func (qb *ImageStore) Create(ctx context.Context, newObject *models.Image, fileIDs []models.FileID) error { +func (qb *ImageStore) Create(ctx context.Context, newObject *models.CreateImageInput) error { var r imageRow - r.fromImage(*newObject) + r.fromImage(*newObject.Image) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } - if len(fileIDs) > 0 { + if len(newObject.FileIDs) > 0 { const firstPrimary = true - if err := imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, fileIDs); err != nil { + if err := imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, newObject.FileIDs); err != nil { return err } } @@ -276,12 +282,18 @@ func (qb *ImageStore) Create(ctx context.Context, newObject *models.Image, fileI } } + if err := qb.SetCustomFields(ctx, id, models.CustomFieldsInput{ + Full: newObject.CustomFields, + }); err != nil { + return err + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } - *newObject = *updated + *newObject.Image = *updated return nil } @@ -329,6 +341,10 @@ func (qb *ImageStore) UpdatePartial(ctx context.Context, id int, partial models. } } + if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { + return nil, err + } + return qb.find(ctx, id) } @@ -821,7 +837,7 @@ func (qb *ImageStore) makeQuery(ctx context.Context, imageFilter *models.ImageFi ) filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename" - searchColumns := []string{"images.title", filepathColumn, "files_fingerprints.fingerprint"} + searchColumns := []string{"images.title", "images.details", filepathColumn, "files_fingerprints.fingerprint"} query.parseQueryString(searchColumns, *q) } @@ -910,7 +926,7 @@ func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.Ima Megapixels null.Float Size null.Float }{} - if err := imageRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + if err := imageRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil { return nil, err } diff --git a/pkg/sqlite/image_filter.go b/pkg/sqlite/image_filter.go index b56ade26d..a7351e52e 100644 --- a/pkg/sqlite/image_filter.go +++ b/pkg/sqlite/image_filter.go @@ -56,8 +56,12 @@ func (qb *imageFilterHandler) criterionHandler() criterionHandler { intCriterionHandler(imageFilter.ID, "images.id", nil), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if imageFilter.Checksum != nil { - imageRepository.addImagesFilesTable(f) - f.addInnerJoin(fingerprintTable, "fingerprints_md5", "images_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") + joinType := joinTypeInner + if imageFilter.Checksum.Modifier == models.CriterionModifierIsNull || imageFilter.Checksum.Modifier == models.CriterionModifierNotMatchesRegex { + joinType = joinTypeLeft + } + imageRepository.addImagesFilesTable(f, joinType) + f.addJoin(joinType, fingerprintTable, "fingerprints_md5", "images_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") } stringCriterionHandler(imageFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) @@ -65,8 +69,8 @@ func (qb *imageFilterHandler) criterionHandler() criterionHandler { &phashDistanceCriterionHandler{ joinFn: func(f *filterBuilder) { - imageRepository.addImagesFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_phash", "images_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") + imageRepository.addImagesFilesTable(f, joinTypeInner) + f.addInnerJoin(fingerprintTable, "fingerprints_phash", "images_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") }, criterion: imageFilter.PhashDistance, }, @@ -100,6 +104,13 @@ func (qb *imageFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{imageFilter.CreatedAt, "images.created_at", nil}, ×tampCriterionHandler{imageFilter.UpdatedAt, "images.updated_at", nil}, + &customFieldsFilterHandler{ + table: imagesCustomFieldsTable.GetTable(), + fkCol: imageIDColumn, + c: imageFilter.CustomFields, + idCol: "images.id", + }, + &relatedFilterHandler{ relatedIDCol: "galleries_images.gallery_id", relatedRepo: galleryRepository.repository, @@ -141,8 +152,8 @@ func (qb *imageFilterHandler) criterionHandler() criterionHandler { isRelated: true, }, joinFn: func(f *filterBuilder) { - imageRepository.addFilesTable(f) - imageRepository.addFoldersTable(f) + imageRepository.addFilesTable(f, joinTypeInner) + imageRepository.addFoldersTable(f, joinTypeInner) }, // don't use a subquery; join directly directJoin: true, @@ -164,18 +175,27 @@ func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criteri return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { + case "url": + imagesURLsTableMgr.leftJoin(f, "", "images.id") + f.addWhere("image_urls.url IS NULL") case "studio": f.addWhere("images.studio_id IS NULL") case "performers": - imageRepository.performers.join(f, "performers_join", "images.id") + imageRepository.performers.leftJoin(f, "performers_join", "images.id") f.addWhere("performers_join.image_id IS NULL") case "galleries": - imageRepository.galleries.join(f, "galleries_join", "images.id") + imageRepository.galleries.leftJoin(f, "galleries_join", "images.id") f.addWhere("galleries_join.image_id IS NULL") case "tags": - imageRepository.tags.join(f, "tags_join", "images.id") + imageRepository.tags.leftJoin(f, "tags_join", "images.id") f.addWhere("tags_join.image_id IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "title", "details", "photographer", "date", "code", "rating", + }); err != nil { + f.setError(err) + return + } f.addWhere("(images." + *isMissing + " IS NULL OR TRIM(images." + *isMissing + ") = '')") } } @@ -188,15 +208,15 @@ func (qb *imageFilterHandler) urlsCriterionHandler(url *models.StringCriterionIn primaryFK: imageIDColumn, joinTable: imagesURLsTable, stringColumn: imageURLColumn, - addJoinTable: func(f *filterBuilder) { - imagesURLsTableMgr.join(f, "", "images.id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + imagesURLsTableMgr.join(f, joinType, "", "images.id") }, } return h.handler(url) } -func (qb *imageFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { +func (qb *imageFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder, joinType joinType)) multiCriterionHandlerBuilder { return multiCriterionHandlerBuilder{ primaryTable: imageTable, foreignTable: foreignTable, @@ -233,7 +253,7 @@ func (qb *imageFilterHandler) tagCountCriterionHandler(tagCount *models.IntCrite } func (qb *imageFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { + addJoinsFunc := func(f *filterBuilder, joinType joinType) { if galleries.Modifier == models.CriterionModifierIncludes || galleries.Modifier == models.CriterionModifierIncludesAll { f.addInnerJoin(galleriesImagesTable, "", "galleries_images.image_id = images.id") f.addInnerJoin(galleryTable, "", "galleries_images.gallery_id = galleries.id") @@ -252,8 +272,8 @@ func (qb *imageFilterHandler) performersCriterionHandler(performers *models.Mult primaryFK: imageIDColumn, foreignFK: performerIDColumn, - addJoinTable: func(f *filterBuilder) { - imageRepository.performers.join(f, "performers_join", "images.id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + imageRepository.performers.join(f, joinType, "performers_join", "images.id") }, } diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index aa4ed3b99..85337c911 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -73,81 +73,94 @@ func Test_imageQueryBuilder_Create(t *testing.T) { tests := []struct { name string - newObject models.Image + newObject models.CreateImageInput wantErr bool }{ { "full", - models.Image{ - Title: title, - Code: code, - Rating: &rating, - Date: &date, - Details: details, - Photographer: photographer, - URLs: models.NewRelatedStrings([]string{url}), - Organized: true, - OCounter: ocounter, - StudioID: &studioIDs[studioIdxWithImage], - CreatedAt: createdAt, - UpdatedAt: updatedAt, - GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), - PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), + models.CreateImageInput{ + Image: &models.Image{ + Title: title, + Code: code, + Rating: &rating, + Date: &date, + Details: details, + Photographer: photographer, + URLs: models.NewRelatedStrings([]string{url}), + Organized: true, + OCounter: ocounter, + StudioID: &studioIDs[studioIdxWithImage], + CreatedAt: createdAt, + UpdatedAt: updatedAt, + GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), + PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), + }, + CustomFields: testCustomFields, }, false, }, { "with file", - models.Image{ - Title: title, - Code: code, - Rating: &rating, - Date: &date, - Details: details, - Photographer: photographer, - URLs: models.NewRelatedStrings([]string{url}), - Organized: true, - OCounter: ocounter, - StudioID: &studioIDs[studioIdxWithImage], - Files: models.NewRelatedFiles([]models.File{ - imageFile.(*models.ImageFile), - }), - PrimaryFileID: &imageFile.Base().ID, - Path: imageFile.Base().Path, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), - PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), + models.CreateImageInput{ + Image: &models.Image{ + Title: title, + Code: code, + Rating: &rating, + Date: &date, + Details: details, + Photographer: photographer, + URLs: models.NewRelatedStrings([]string{url}), + Organized: true, + OCounter: ocounter, + StudioID: &studioIDs[studioIdxWithImage], + Files: models.NewRelatedFiles([]models.File{ + imageFile.(*models.ImageFile), + }), + PrimaryFileID: &imageFile.Base().ID, + Path: imageFile.Base().Path, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithImage]}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithImage]}), + PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithImage], performerIDs[performerIdx1WithDupName]}), + }, }, false, }, { "invalid studio id", - models.Image{ - StudioID: &invalidID, + models.CreateImageInput{ + Image: &models.Image{ + StudioID: &invalidID, + }, }, true, }, { "invalid gallery id", - models.Image{ - GalleryIDs: models.NewRelatedIDs([]int{invalidID}), + models.CreateImageInput{ + Image: &models.Image{ + GalleryIDs: models.NewRelatedIDs([]int{invalidID}), + }, }, true, }, { "invalid tag id", - models.Image{ - TagIDs: models.NewRelatedIDs([]int{invalidID}), + models.CreateImageInput{ + Image: &models.Image{ + TagIDs: models.NewRelatedIDs([]int{invalidID}), + }, }, true, }, { "invalid performer id", - models.Image{ - PerformerIDs: models.NewRelatedIDs([]int{invalidID}), + models.CreateImageInput{ + Image: &models.Image{ + PerformerIDs: models.NewRelatedIDs([]int{invalidID}), + }, }, true, }, @@ -165,8 +178,11 @@ func Test_imageQueryBuilder_Create(t *testing.T) { fileIDs = append(fileIDs, f.Base().ID) } } - s := tt.newObject - if err := qb.Create(ctx, &s, fileIDs); (err != nil) != tt.wantErr { + s := *tt.newObject.Image + if err := qb.Create(ctx, &models.CreateImageInput{ + Image: &s, + FileIDs: fileIDs, + }); (err != nil) != tt.wantErr { t.Errorf("imageQueryBuilder.Create() error = %v, wantErr = %v", err, tt.wantErr) } @@ -177,7 +193,7 @@ func Test_imageQueryBuilder_Create(t *testing.T) { assert.NotZero(s.ID) - copy := tt.newObject + copy := *tt.newObject.Image copy.ID = s.ID // load relationships @@ -201,8 +217,6 @@ func Test_imageQueryBuilder_Create(t *testing.T) { } assert.Equal(copy, *found) - - return }) } } @@ -387,8 +401,6 @@ func Test_imageQueryBuilder_Update(t *testing.T) { } assert.Equal(copy, *s) - - return }) } } @@ -832,6 +844,79 @@ func Test_imageQueryBuilder_UpdatePartialRelationships(t *testing.T) { } } +func Test_ImageStore_UpdatePartialCustomFields(t *testing.T) { + tests := []struct { + name string + id int + partial models.ImagePartial + expected map[string]interface{} // nil to use the partial + }{ + { + "set custom fields", + imageIDs[imageIdx1WithGallery], + models.ImagePartial{ + CustomFields: models.CustomFieldsInput{ + Full: testCustomFields, + }, + }, + nil, + }, + { + "clear custom fields", + imageIDs[imageIdx1WithGallery], + models.ImagePartial{ + CustomFields: models.CustomFieldsInput{ + Full: map[string]interface{}{}, + }, + }, + nil, + }, + { + "partial custom fields", + imageIDs[imageIdxWithStudio], + models.ImagePartial{ + CustomFields: models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "string": "bbb", + "new_field": "new", + }, + }, + }, + map[string]interface{}{ + "int": int64(2), + "real": 1.2, + "string": "bbb", + "new_field": "new", + }, + }, + } + for _, tt := range tests { + qb := db.Image + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + _, err := qb.UpdatePartial(ctx, tt.id, tt.partial) + if err != nil { + t.Errorf("ImageStore.UpdatePartial() error = %v", err) + return + } + + // ensure custom fields are correct + cf, err := qb.GetCustomFields(ctx, tt.id) + if err != nil { + t.Errorf("ImageStore.GetCustomFields() error = %v", err) + return + } + if tt.expected == nil { + assert.Equal(tt.partial.CustomFields.Full, cf) + } else { + assert.Equal(tt.expected, cf) + } + }) + } +} + func Test_imageQueryBuilder_IncrementOCounter(t *testing.T) { tests := []struct { name string @@ -1511,6 +1596,20 @@ func TestImageQueryQ(t *testing.T) { }) } +func TestImageQueryQ_Details(t *testing.T) { + withTxn(func(ctx context.Context) error { + const imageIdx = 3 + + q := getImageStringValue(imageIdx, detailsField) + + sqb := db.Image + + imageQueryQ(ctx, t, sqb, q, imageIdx) + + return nil + }) +} + func queryImagesWithCount(ctx context.Context, sqb models.ImageReader, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) { result, err := sqb.Query(ctx, models.ImageQueryOptions{ QueryOptions: models.QueryOptions{ @@ -3018,6 +3117,252 @@ func TestImageQueryPagination(t *testing.T) { }) } +func TestImageQueryCustomFields(t *testing.T) { + tests := []struct { + name string + filter *models.ImageFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "equals", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")}, + }, + }, + }, + []int{imageIdx1WithGallery}, + nil, + false, + }, + { + "not equals", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx1WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotEquals, + Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")}, + }, + }, + }, + nil, + []int{imageIdx1WithGallery}, + false, + }, + { + "includes", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierIncludes, + Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")[9:]}, + }, + }, + }, + []int{imageIdx1WithGallery}, + nil, + false, + }, + { + "excludes", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx1WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierExcludes, + Value: []any{getImageStringValue(imageIdx1WithGallery, "custom")[9:]}, + }, + }, + }, + nil, + []int{imageIdx1WithGallery}, + false, + }, + { + "regex", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + []int{imageIdxWithPerformerTag}, + nil, + false, + }, + { + "invalid regex", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "not matches regex", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdxWithPerformerTag, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + nil, + []int{imageIdxWithPerformerTag}, + false, + }, + { + "invalid not matches regex", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "null", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx1WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + }, + []int{imageIdx1WithGallery}, + nil, + false, + }, + { + "not null", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx1WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotNull, + }, + }, + }, + []int{imageIdx1WithGallery}, + nil, + false, + }, + { + "between", + &models.ImageFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + []int{imageIdx2WithGallery}, + nil, + false, + }, + { + "not between", + &models.ImageFilterType{ + Title: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx2WithGallery, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierNotBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + nil, + []int{imageIdx2WithGallery}, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + result, err := db.Image.Query(ctx, models.ImageQueryOptions{ + ImageFilter: tt.filter, + }) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return + } + + images, err := result.Resolve(ctx) + if err != nil { + t.Errorf("ImageStore.Query().Resolve() error = %v", err) + } + + ids := imagesToIDs(images) + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Count // TODO SizeCount // TODO All diff --git a/pkg/sqlite/migrations/78_postmigrate.go b/pkg/sqlite/migrations/78_postmigrate.go index 15d040457..34dbe6eb3 100644 --- a/pkg/sqlite/migrations/78_postmigrate.go +++ b/pkg/sqlite/migrations/78_postmigrate.go @@ -7,8 +7,8 @@ import ( "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sqlite" - "github.com/stashapp/stash/pkg/utils" ) type schema78Migrator struct { @@ -76,7 +76,7 @@ func (m *schema78Migrator) migrateCareerLength(ctx context.Context) error { lastID = id gotSome = true - start, end, err := utils.ParseYearRangeString(careerLength) + start, end, err := models.ParseYearRangeString(careerLength) if err != nil { logger.Warnf("Could not parse career_length %q for performer %d: %v — preserving as custom field", careerLength, id, err) @@ -107,10 +107,23 @@ func (m *schema78Migrator) migrateCareerLength(ctx context.Context) error { return nil } -func (m *schema78Migrator) updateCareerFields(tx *sqlx.Tx, id int, start *int, end *int) error { +func (m *schema78Migrator) updateCareerFields(tx *sqlx.Tx, id int, start *models.Date, end *models.Date) error { + var ( + startYear, endYear *int + ) + + if start != nil { + year := start.Year() + startYear = &year + } + if end != nil { + year := end.Year() + endYear = &year + } + _, err := tx.Exec( "UPDATE performers SET career_start = ?, career_end = ? WHERE id = ?", - start, end, id, + startYear, endYear, id, ) return err } diff --git a/pkg/sqlite/migrations/81_gallery_custom_fields.up.sql b/pkg/sqlite/migrations/81_gallery_custom_fields.up.sql new file mode 100644 index 000000000..89a6e4c05 --- /dev/null +++ b/pkg/sqlite/migrations/81_gallery_custom_fields.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `gallery_custom_fields` ( + `gallery_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`gallery_id`, `field`), + foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE +); + +CREATE INDEX `index_gallery_custom_fields_field_value` ON `gallery_custom_fields` (`field`, `value`); diff --git a/pkg/sqlite/migrations/82_group_custom_fields.up.sql b/pkg/sqlite/migrations/82_group_custom_fields.up.sql new file mode 100644 index 000000000..c1f287fec --- /dev/null +++ b/pkg/sqlite/migrations/82_group_custom_fields.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `group_custom_fields` ( + `group_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`group_id`, `field`), + foreign key(`group_id`) references `groups`(`id`) on delete CASCADE +); + +CREATE INDEX `index_group_custom_fields_field_value` ON `group_custom_fields` (`field`, `value`); diff --git a/pkg/sqlite/migrations/83_image_custom_fields.up.sql b/pkg/sqlite/migrations/83_image_custom_fields.up.sql new file mode 100644 index 000000000..0aa3aa4d7 --- /dev/null +++ b/pkg/sqlite/migrations/83_image_custom_fields.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `image_custom_fields` ( + `image_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`image_id`, `field`), + foreign key(`image_id`) references `images`(`id`) on delete CASCADE +); + +CREATE INDEX `index_image_custom_fields_field_value` ON `image_custom_fields` (`field`, `value`); diff --git a/pkg/sqlite/migrations/84_folder_basename.up.sql b/pkg/sqlite/migrations/84_folder_basename.up.sql new file mode 100644 index 000000000..5cfd5c2d9 --- /dev/null +++ b/pkg/sqlite/migrations/84_folder_basename.up.sql @@ -0,0 +1,50 @@ +-- we cannot add basename column directly because we require it to be NOT NULL +-- recreate folders table with basename column +PRAGMA foreign_keys=OFF; + +CREATE TABLE `folders_new` ( + `id` integer not null primary key autoincrement, + `basename` varchar(255) NOT NULL, + `path` varchar(255) NOT NULL, + `parent_folder_id` integer, + `zip_file_id` integer REFERENCES `files`(`id`), + `mod_time` datetime not null, + `created_at` datetime not null, + `updated_at` datetime not null, + foreign key(`parent_folder_id`) references `folders`(`id`) on delete SET NULL +); + +-- copy data from old table to new table, setting basename to path temporarily +INSERT INTO `folders_new` ( + `id`, + `basename`, + `path`, + `parent_folder_id`, + `zip_file_id`, + `mod_time`, + `created_at`, + `updated_at` +) SELECT + `id`, + `path`, + `path`, + `parent_folder_id`, + `zip_file_id`, + `mod_time`, + `created_at`, + `updated_at` +FROM `folders`; + +DROP INDEX IF EXISTS `index_folders_on_parent_folder_id`; +DROP INDEX IF EXISTS `index_folders_on_path_unique`; +DROP INDEX IF EXISTS `index_folders_on_zip_file_id`; +DROP TABLE `folders`; + +ALTER TABLE `folders_new` RENAME TO `folders`; + +CREATE UNIQUE INDEX `index_folders_on_path_unique` on `folders` (`path`); +CREATE UNIQUE INDEX `index_folders_on_parent_folder_id_basename_unique` on `folders` (`parent_folder_id`, `basename`); +CREATE INDEX `index_folders_on_zip_file_id` on `folders` (`zip_file_id`) WHERE `zip_file_id` IS NOT NULL; +CREATE INDEX `index_folders_on_basename` on `folders` (`basename`); + +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/pkg/sqlite/migrations/84_migrate.go b/pkg/sqlite/migrations/84_migrate.go new file mode 100644 index 000000000..4e4276782 --- /dev/null +++ b/pkg/sqlite/migrations/84_migrate.go @@ -0,0 +1,533 @@ +package migrations + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" + "slices" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sqlite" + "gopkg.in/guregu/null.v4" +) + +func pre84(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running pre-migration for schema version 84") + + m := schema84Migrator{ + migrator: migrator{ + db: db, + }, + folderCache: make(map[string]folderInfo), + } + + rootPaths := config.GetInstance().GetStashPaths().Paths() + + if err := m.createMissingFolderHierarchies(ctx, rootPaths); err != nil { + return fmt.Errorf("creating missing folder hierarchies: %w", err) + } + + if err := m.fixIncorrectParents(ctx, rootPaths); err != nil { + return fmt.Errorf("fixing incorrect parent folders: %w", err) + } + + if err := m.deduplicateFolders(ctx); err != nil { + return fmt.Errorf("deduplicating folders: %w", err) + } + + return nil +} + +func post84(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running post-migration for schema version 84") + + m := schema84Migrator{ + migrator: migrator{ + db: db, + }, + folderCache: make(map[string]folderInfo), + } + + if err := m.migrateFolders(ctx); err != nil { + return fmt.Errorf("migrating folders: %w", err) + } + + return nil +} + +type schema84Migrator struct { + migrator + folderCache map[string]folderInfo +} + +func (m *schema84Migrator) createMissingFolderHierarchies(ctx context.Context, rootPaths []string) error { + // before we set the basenames, we need to address any folders that are missing their + // parent folders. + const ( + limit = 1000 + logEvery = 10000 + ) + + lastID := 0 + count := 0 + logged := false + + for { + gotSome := false + + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := "SELECT `folders`.`id`, `folders`.`path` FROM `folders` WHERE `folders`.`parent_folder_id` IS NULL " + + if lastID != 0 { + query += fmt.Sprintf("AND `folders`.`id` > %d ", lastID) + } + + query += fmt.Sprintf("ORDER BY `folders`.`id` LIMIT %d", limit) + + rows, err := tx.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + // log once if we find any folders with missing parent folders + if !logged { + logger.Info("Migrating folders with missing parents...") + logged = true + } + + var id int + var p string + + err := rows.Scan(&id, &p) + if err != nil { + return err + } + + lastID = id + gotSome = true + count++ + + // don't try to create parent folders for root paths + if slices.Contains(rootPaths, p) { + continue + } + + parentDir := filepath.Dir(p) + if parentDir == p { + // this can happen if the path is something like "C:\", where the parent directory is the same as the current directory + continue + } + + parentID, err := m.getOrCreateFolderHierarchy(tx, parentDir, rootPaths) + if err != nil { + return fmt.Errorf("error creating parent folder for folder %d %q: %w", id, p, err) + } + + if parentID == nil { + continue + } + + // now set the parent folder ID for the current folder + logger.Debugf("Migrating folder %d %q: setting parent folder ID to %d", id, p, *parentID) + + _, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *parentID, id) + if err != nil { + return fmt.Errorf("error setting parent folder for folder %d %q: %w", id, p, err) + } + } + + return rows.Err() + }); err != nil { + return err + } + + if !gotSome { + break + } + + if count%logEvery == 0 { + logger.Infof("Migrated %d folders", count) + } + } + + return nil +} + +func (m *schema84Migrator) findFolderByPath(tx *sqlx.Tx, path string) (*int, error) { + query := "SELECT `folders`.`id` FROM `folders` WHERE `folders`.`path` = ?" + + var id int + if err := tx.Get(&id, query, path); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + return nil, err + } + + return &id, nil +} + +// this is a copy of the GetOrCreateFolderHierarchy function from pkg/file/folder.go, +// but modified to use low-level SQL queries instead of the models.FolderFinderCreator interface, to avoid +func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string, rootPaths []string) (*int, error) { + // get or create folder hierarchy + folderID, err := m.findFolderByPath(tx, path) + if err != nil { + return nil, err + } + + if folderID == nil { + var parentID *int + + if !slices.Contains(rootPaths, path) { + parentPath := filepath.Dir(path) + + // it's possible that the parent path is the same as the current path, if there are folders outside + // of the root paths. In that case, we should just return nil for the parent ID. + if parentPath == path { + return nil, nil + } + + parentID, err = m.getOrCreateFolderHierarchy(tx, parentPath, rootPaths) + if err != nil { + return nil, err + } + } + + logger.Debugf("%s doesn't exist. Creating new folder entry...", path) + + // we need to set basename to path, which will be addressed in the next step + const insertSQL = "INSERT INTO `folders` (`path`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?)" + + var parentFolderID null.Int + if parentID != nil { + parentFolderID = null.IntFrom(int64(*parentID)) + } + + now := time.Now() + result, err := tx.Exec(insertSQL, path, parentFolderID, time.Time{}, now, now) + if err != nil { + return nil, fmt.Errorf("creating folder %s: %w", path, err) + } + + id, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("creating folder %s: %w", path, err) + } + + idInt := int(id) + folderID = &idInt + } + + return folderID, nil +} + +func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []string) error { + const ( + limit = 1000 + logEvery = 10000 + ) + + lastID := 0 + count := 0 + fixed := 0 + logged := false + + for { + gotSome := false + + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := "SELECT f.id, f.path, f.parent_folder_id, pf.path AS parent_path " + + "FROM folders f " + + "JOIN folders pf ON f.parent_folder_id = pf.id " + + if lastID != 0 { + query += fmt.Sprintf("WHERE f.id > %d ", lastID) + } + + query += fmt.Sprintf("ORDER BY f.id LIMIT %d", limit) + + rows, err := tx.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var id int + var p string + var parentFolderID int + var parentPath string + + err := rows.Scan(&id, &p, &parentFolderID, &parentPath) + if err != nil { + return err + } + + lastID = id + gotSome = true + count++ + + expectedParent := filepath.Dir(p) + if expectedParent == parentPath { + continue + } + + correctParentID, err := m.getOrCreateFolderHierarchy(tx, expectedParent, rootPaths) + if err != nil { + return fmt.Errorf("error getting/creating correct parent for folder %d %q: %w", id, p, err) + } + + if correctParentID == nil { + continue + } + + if !logged { + logger.Info("Fixing folders with incorrect parent folder assignments...") + logged = true + } + + logger.Debugf("Fixing folder %d %q: changing parent_folder_id from %d to %d", id, p, parentFolderID, *correctParentID) + + _, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *correctParentID, id) + if err != nil { + return fmt.Errorf("error fixing parent folder for folder %d %q: %w", id, p, err) + } + + fixed++ + } + + return rows.Err() + }); err != nil { + return err + } + + if !gotSome { + break + } + + if count%logEvery == 0 { + logger.Infof("Checked %d folders", count) + } + } + + if fixed > 0 { + logger.Infof("Fixed %d folders with incorrect parent assignments", fixed) + } + + return nil +} + +// deduplicateFolders finds folders that would have the same (parent_folder_id, basename) after +// migrateFolders sets basename = filepath.Base(path), and merges the duplicates. +// This can happen when the database contains entries for the same physical folder with different +// path representations (e.g., mixed separators like "\data/movies" vs "\data\movies" on Windows). +func (m *schema84Migrator) deduplicateFolders(ctx context.Context) error { + for { + n, err := m.deduplicateFoldersPass(ctx) + if err != nil { + return err + } + // repeat until no more duplicates are found, since merging child folders + // from a duplicate parent into the canonical parent may create new conflicts + if n == 0 { + break + } + } + return nil +} + +func (m *schema84Migrator) deduplicateFoldersPass(ctx context.Context) (int, error) { + type folderRow struct { + ID int `db:"id"` + Path string `db:"path"` + ParentFolderID int `db:"parent_folder_id"` + } + + var folders []folderRow + if err := m.db.SelectContext(ctx, &folders, + "SELECT id, path, parent_folder_id FROM folders WHERE parent_folder_id IS NOT NULL ORDER BY id"); err != nil { + return 0, fmt.Errorf("loading folders: %w", err) + } + + // group by (parent_folder_id, computed basename) + type groupKey struct { + parentID int + basename string + } + groups := make(map[groupKey][]folderRow) + for _, f := range folders { + key := groupKey{ + parentID: f.ParentFolderID, + basename: filepath.Base(f.Path), + } + groups[key] = append(groups[key], f) + } + + deduped := 0 + for _, group := range groups { + if len(group) <= 1 { + continue + } + + if deduped == 0 { + logger.Info("Deduplicating folders with conflicting basenames...") + } + + // prefer the folder whose path is already normalized for the current OS, + // falling back to the newest entry (highest ID) since it's most likely + // from the current filesystem + keep := group[len(group)-1] + for _, f := range group { + if f.Path == filepath.Clean(f.Path) { + keep = f + break + } + } + + for _, dup := range group { + if dup.ID == keep.ID { + continue + } + + logger.Infof("Merging duplicate folder %d %q into folder %d %q", dup.ID, dup.Path, keep.ID, keep.Path) + + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + return m.mergeFolder(tx, keep.ID, dup.ID) + }); err != nil { + return 0, fmt.Errorf("merging folder %d into %d: %w", dup.ID, keep.ID, err) + } + + deduped++ + } + } + + if deduped > 0 { + logger.Infof("Deduplicated %d folder entries", deduped) + } + + return deduped, nil +} + +func (m *schema84Migrator) mergeFolder(tx *sqlx.Tx, keepID, dupID int) error { + // Re-parent child folders from the duplicate to the canonical folder. + // At this point basenames are still full paths (unique), so this won't cause + // UNIQUE constraint violations on (parent_folder_id, basename). + if _, err := tx.Exec("UPDATE folders SET parent_folder_id = ? WHERE parent_folder_id = ?", keepID, dupID); err != nil { + return fmt.Errorf("re-parenting child folders: %w", err) + } + + // re-parent any files under the duplicate folder to the canonical folder. + if _, err := tx.Exec("UPDATE files SET parent_folder_id = ? WHERE parent_folder_id = ?", keepID, dupID); err != nil { + return fmt.Errorf("re-parenting files: %w", err) + } + + // delete the duplicate folder entry only if it is not referenced by any galleries + var count int + if err := tx.Get(&count, "SELECT COUNT(*) FROM galleries WHERE folder_id = ?", dupID); err != nil { + return fmt.Errorf("checking for gallery references: %w", err) + } + + if count > 0 { + logger.Warnf("Duplicate folder %d is still referenced by %d galleries. Orphaning instead of deleting.", dupID, count) + + // Orphan the stale duplicate folder by clearing its parent so the UNIQUE + // constraint on (parent_folder_id, basename) won't be violated when + // migrateFolders sets basenames. Any stale file entries under it are left + // untouched — the clean task will handle them on the next scan. + if _, err := tx.Exec("UPDATE folders SET parent_folder_id = NULL WHERE id = ?", dupID); err != nil { + return fmt.Errorf("orphaning duplicate folder: %w", err) + } + } else { + // delete the duplicate folder entry + if _, err := tx.Exec("DELETE FROM folders WHERE id = ?", dupID); err != nil { + return fmt.Errorf("deleting duplicate folder: %w", err) + } + } + + return nil +} + +func (m *schema84Migrator) migrateFolders(ctx context.Context) error { + const ( + limit = 1000 + logEvery = 10000 + ) + + lastID := 0 + count := 0 + logged := false + + for { + gotSome := false + + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := "SELECT `folders`.`id`, `folders`.`path` FROM `folders` " + + if lastID != 0 { + query += fmt.Sprintf("WHERE `folders`.`id` > %d ", lastID) + } + + query += fmt.Sprintf("ORDER BY `folders`.`id` LIMIT %d", limit) + + rows, err := tx.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + if !logged { + logger.Infof("Migrating folders to set basenames...") + logged = true + } + + var id int + var p string + + err := rows.Scan(&id, &p) + if err != nil { + return err + } + + lastID = id + gotSome = true + count++ + + basename := filepath.Base(p) + logger.Debugf("Migrating folder %d %q: setting basename to %q", id, p, basename) + _, err = tx.Exec("UPDATE `folders` SET `basename` = ? WHERE `id` = ?", basename, id) + if err != nil { + return fmt.Errorf("error migrating folder %d %q: %w", id, p, err) + } + } + + return rows.Err() + }); err != nil { + return err + } + + if !gotSome { + break + } + + if count%logEvery == 0 { + logger.Infof("Migrated %d folders", count) + } + } + + return nil +} + +func init() { + sqlite.RegisterPreMigration(84, pre84) + sqlite.RegisterPostMigration(84, post84) +} diff --git a/pkg/sqlite/migrations/85_performer_career_dates.up.sql b/pkg/sqlite/migrations/85_performer_career_dates.up.sql new file mode 100644 index 000000000..1ce1cc97e --- /dev/null +++ b/pkg/sqlite/migrations/85_performer_career_dates.up.sql @@ -0,0 +1,112 @@ +-- have to change the type of the career start/end columns so need to recreate the table +PRAGMA foreign_keys=OFF; + +CREATE TABLE IF NOT EXISTS "performers_new" ( + `id` integer not null primary key autoincrement, + `name` varchar(255) not null, + `disambiguation` varchar(255), + `gender` varchar(20), + `birthdate` date, + `birthdate_precision` TINYINT, + `ethnicity` varchar(255), + `country` varchar(255), + `eye_color` varchar(255), + `height` int, + `measurements` varchar(255), + `fake_tits` varchar(255), + `tattoos` varchar(255), + `piercings` varchar(255), + `favorite` boolean not null default '0', + `created_at` datetime not null, + `updated_at` datetime not null, + `details` text, + `death_date` date, + `death_date_precision` TINYINT, + `hair_color` varchar(255), + `weight` integer, + `rating` tinyint, + `ignore_auto_tag` boolean not null default '0', + `penis_length` float, + `circumcised` varchar[10], + `career_start` date, + `career_start_precision` TINYINT, + `career_end` date, + `career_end_precision` TINYINT, + `image_blob` varchar(255) REFERENCES `blobs`(`checksum`) +); + +INSERT INTO `performers_new` ( + `id`, + `name`, + `disambiguation`, + `gender`, + `birthdate`, + `ethnicity`, + `country`, + `eye_color`, + `height`, + `measurements`, + `fake_tits`, + `tattoos`, + `piercings`, + `favorite`, + `created_at`, + `updated_at`, + `details`, + `death_date`, + `hair_color`, + `weight`, + `rating`, + `ignore_auto_tag`, + `image_blob`, + `penis_length`, + `circumcised`, + `birthdate_precision`, + `death_date_precision`, + `career_start`, + `career_end` +) SELECT + `id`, + `name`, + `disambiguation`, + `gender`, + `birthdate`, + `ethnicity`, + `country`, + `eye_color`, + `height`, + `measurements`, + `fake_tits`, + `tattoos`, + `piercings`, + `favorite`, + `created_at`, + `updated_at`, + `details`, + `death_date`, + `hair_color`, + `weight`, + `rating`, + `ignore_auto_tag`, + `image_blob`, + `penis_length`, + `circumcised`, + `birthdate_precision`, + `death_date_precision`, + CAST(`career_start` AS TEXT), + CAST(`career_end` AS TEXT) +FROM `performers`; + +DROP INDEX IF EXISTS `performers_name_disambiguation_unique`; +DROP INDEX IF EXISTS `performers_name_unique`; +DROP TABLE `performers`; + +ALTER TABLE `performers_new` RENAME TO `performers`; + +UPDATE "performers" SET `career_start` = CONCAT(`career_start`, '-01-01'), "career_start_precision" = 2 WHERE "career_start" IS NOT NULL; +UPDATE "performers" SET `career_end` = CONCAT(`career_end`, '-01-01'), "career_end_precision" = 2 WHERE "career_end" IS NOT NULL; + +CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL; +CREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL; + +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 298a681fd..aacd9172f 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -30,27 +30,29 @@ const ( ) type performerRow struct { - ID int `db:"id" goqu:"skipinsert"` - Name null.String `db:"name"` // TODO: make schema non-nullable - Disambigation zero.String `db:"disambiguation"` - Gender zero.String `db:"gender"` - Birthdate NullDate `db:"birthdate"` - BirthdatePrecision null.Int `db:"birthdate_precision"` - Ethnicity zero.String `db:"ethnicity"` - Country zero.String `db:"country"` - EyeColor zero.String `db:"eye_color"` - Height null.Int `db:"height"` - Measurements zero.String `db:"measurements"` - FakeTits zero.String `db:"fake_tits"` - PenisLength null.Float `db:"penis_length"` - Circumcised zero.String `db:"circumcised"` - CareerStart null.Int `db:"career_start"` - CareerEnd null.Int `db:"career_end"` - Tattoos zero.String `db:"tattoos"` - Piercings zero.String `db:"piercings"` - Favorite bool `db:"favorite"` - CreatedAt Timestamp `db:"created_at"` - UpdatedAt Timestamp `db:"updated_at"` + ID int `db:"id" goqu:"skipinsert"` + Name null.String `db:"name"` // TODO: make schema non-nullable + Disambigation zero.String `db:"disambiguation"` + Gender zero.String `db:"gender"` + Birthdate NullDate `db:"birthdate"` + BirthdatePrecision null.Int `db:"birthdate_precision"` + Ethnicity zero.String `db:"ethnicity"` + Country zero.String `db:"country"` + EyeColor zero.String `db:"eye_color"` + Height null.Int `db:"height"` + Measurements zero.String `db:"measurements"` + FakeTits zero.String `db:"fake_tits"` + PenisLength null.Float `db:"penis_length"` + Circumcised zero.String `db:"circumcised"` + CareerStart NullDate `db:"career_start"` + CareerStartPrecision null.Int `db:"career_start_precision"` + CareerEnd NullDate `db:"career_end"` + CareerEndPrecision null.Int `db:"career_end_precision"` + Tattoos zero.String `db:"tattoos"` + Piercings zero.String `db:"piercings"` + Favorite bool `db:"favorite"` + CreatedAt Timestamp `db:"created_at"` + UpdatedAt Timestamp `db:"updated_at"` // expressed as 1-100 Rating null.Int `db:"rating"` Details zero.String `db:"details"` @@ -83,8 +85,10 @@ func (r *performerRow) fromPerformer(o models.Performer) { if o.Circumcised != nil && o.Circumcised.IsValid() { r.Circumcised = zero.StringFrom(o.Circumcised.String()) } - r.CareerStart = intFromPtr(o.CareerStart) - r.CareerEnd = intFromPtr(o.CareerEnd) + r.CareerStart = NullDateFromDatePtr(o.CareerStart) + r.CareerStartPrecision = datePrecisionFromDatePtr(o.CareerStart) + r.CareerEnd = NullDateFromDatePtr(o.CareerEnd) + r.CareerEndPrecision = datePrecisionFromDatePtr(o.CareerEnd) r.Tattoos = zero.StringFrom(o.Tattoos) r.Piercings = zero.StringFrom(o.Piercings) r.Favorite = o.Favorite @@ -112,8 +116,8 @@ func (r *performerRow) resolve() *models.Performer { Measurements: r.Measurements.String, FakeTits: r.FakeTits.String, PenisLength: nullFloatPtr(r.PenisLength), - CareerStart: nullIntPtr(r.CareerStart), - CareerEnd: nullIntPtr(r.CareerEnd), + CareerStart: r.CareerStart.DatePtr(r.CareerStartPrecision), + CareerEnd: r.CareerEnd.DatePtr(r.CareerEndPrecision), Tattoos: r.Tattoos.String, Piercings: r.Piercings.String, Favorite: r.Favorite, @@ -134,7 +138,7 @@ func (r *performerRow) resolve() *models.Performer { } if r.Circumcised.ValueOrZero() != "" { - v := models.CircumisedEnum(r.Circumcised.String) + v := models.CircumcisedEnum(r.Circumcised.String) ret.Circumcised = &v } @@ -158,8 +162,8 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setNullString("fake_tits", o.FakeTits) r.setNullFloat64("penis_length", o.PenisLength) r.setNullString("circumcised", o.Circumcised) - r.setNullInt("career_start", o.CareerStart) - r.setNullInt("career_end", o.CareerEnd) + r.setNullDate("career_start", "career_start_precision", o.CareerStart) + r.setNullDate("career_end", "career_end_precision", o.CareerEnd) r.setNullString("tattoos", o.Tattoos) r.setNullString("piercings", o.Piercings) r.setBool("favorite", o.Favorite) @@ -778,6 +782,28 @@ func (qb *PerformerStore) sortByScenesDuration(direction string) string { return " ORDER BY (" + selectPerformerScenesDurationSQL + ") " + direction } +// used for sorting by total scene file size +var selectPerformerScenesSizeSQL = utils.StrFormat( + "SELECT COALESCE(SUM({files}.size), 0) FROM {performers_scenes} s "+ + "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ + "LEFT JOIN {scenes_files} ON {scenes_files}.{scene_id} = {scenes}.id "+ + "LEFT JOIN {files} ON {files}.id = {scenes_files}.file_id "+ + "WHERE s.{performer_id} = {performers}.id", + map[string]interface{}{ + "performer_id": performerIDColumn, + "performers": performerTable, + "performers_scenes": performersScenesTable, + "scenes": sceneTable, + "scene_id": sceneIDColumn, + "scenes_files": scenesFilesTable, + "files": fileTable, + }, +) + +func (qb *PerformerStore) sortByScenesSize(direction string) string { + return " ORDER BY (" + selectPerformerScenesSizeSQL + ") " + direction +} + var performerSortOptions = sortOptions{ "birthdate", "career_start", @@ -799,6 +825,7 @@ var performerSortOptions = sortOptions{ "rating", "scenes_count", "scenes_duration", + "scenes_size", "tag_count", "updated_at", "weight", @@ -828,6 +855,8 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s sortQuery += getCountSort(performerTable, performersScenesTable, performerIDColumn, direction) case "scenes_duration": sortQuery += qb.sortByScenesDuration(direction) + case "scenes_size": + sortQuery += qb.sortByScenesSize(direction) case "images_count": sortQuery += getCountSort(performerTable, performersImagesTable, performerIDColumn, direction) case "galleries_count": diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index 5296d5a25..1e54bbf96 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -52,7 +52,7 @@ func (qb *performerFilterHandler) validate() error { careerLength := filter.CareerLength switch careerLength.Modifier { case models.CriterionModifierEquals: - start, end, err := utils.ParseYearRangeString(careerLength.Value) + start, end, err := models.ParseYearRangeString(careerLength.Value) if err != nil { return fmt.Errorf("invalid career length value: %s", careerLength.Value) } @@ -70,6 +70,28 @@ func (qb *performerFilterHandler) validate() error { } } + // validate date formats + if filter.Birthdate != nil && filter.Birthdate.Value != "" { + if _, err := models.ParseDate(filter.Birthdate.Value); err != nil { + return fmt.Errorf("invalid birthdate value: %s", filter.Birthdate.Value) + } + } + if filter.DeathDate != nil && filter.DeathDate.Value != "" { + if _, err := models.ParseDate(filter.DeathDate.Value); err != nil { + return fmt.Errorf("invalid death date value: %s", filter.DeathDate.Value) + } + } + if filter.CareerStart != nil && filter.CareerStart.Value != "" { + if _, err := models.ParseDate(filter.CareerStart.Value); err != nil { + return fmt.Errorf("invalid career start value: %s", filter.CareerStart.Value) + } + } + if filter.CareerEnd != nil && filter.CareerEnd.Value != "" { + if _, err := models.ParseDate(filter.CareerEnd.Value); err != nil { + return fmt.Errorf("invalid career end value: %s", filter.CareerEnd.Value) + } + } + return nil } @@ -156,8 +178,8 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { }), // CareerLength filter is deprecated and non-functional (column removed in schema 78) - intCriterionHandler(filter.CareerStart, tableName+".career_start", nil), - intCriterionHandler(filter.CareerEnd, tableName+".career_end", nil), + &dateCriterionHandler{filter.CareerStart, tableName + ".career_start", nil}, + &dateCriterionHandler{filter.CareerEnd, tableName + ".career_end", nil}, stringCriterionHandler(filter.Tattoos, tableName+".tattoos"), stringCriterionHandler(filter.Piercings, tableName+".piercings"), intCriterionHandler(filter.Rating100, tableName+".rating", nil), @@ -166,7 +188,7 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { intCriterionHandler(filter.Weight, tableName+".weight", nil), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if filter.StashID != nil { - performerRepository.stashIDs.join(f, "performer_stash_ids", "performers.id") + performerRepository.stashIDs.leftJoin(f, "performer_stash_ids", "performers.id") stringCriterionHandler(filter.StashID, "performer_stash_ids.stash_id")(ctx, f) } }), @@ -195,6 +217,7 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { qb.tagCountCriterionHandler(filter.TagCount), qb.sceneCountCriterionHandler(filter.SceneCount), + qb.markerCountCriterionHandler(filter.MarkerCount), qb.imageCountCriterionHandler(filter.ImageCount), qb.galleryCountCriterionHandler(filter.GalleryCount), qb.playCounterCriterionHandler(filter.PlayCount), @@ -204,6 +227,16 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{filter.CreatedAt, tableName + ".created_at", nil}, ×tampCriterionHandler{filter.UpdatedAt, tableName + ".updated_at", nil}, + &relatedFilterHandler{ + relatedIDCol: "scene_markers.id", + relatedRepo: sceneMarkerRepository.repository, + relatedHandler: &sceneMarkerFilterHandler{filter.MarkersFilter}, + joinFn: func(f *filterBuilder) { + performerRepository.scenes.innerJoin(f, "", "performers.id") + f.addInnerJoin(sceneMarkerTable, "", "scene_markers.scene_id = performers_scenes.scene_id") + }, + }, + &relatedFilterHandler{ relatedIDCol: "performers_scenes.scene_id", relatedRepo: sceneRepository.repository, @@ -255,31 +288,39 @@ func convertLegacyCareerLengthFilter(filter *models.PerformerFilterType) { careerLength := filter.CareerLength switch careerLength.Modifier { case models.CriterionModifierEquals: - start, end, _ := utils.ParseYearRangeString(careerLength.Value) + start, end, _ := models.ParseYearRangeString(careerLength.Value) if start != nil { - filter.CareerStart = &models.IntCriterionInput{ - Value: (*start) - 1, // minus one to make it exclusive + start = &models.Date{ + Time: start.AddDate(0, 0, -1), // make exclusive + Precision: models.DatePrecisionDay, + } + filter.CareerStart = &models.DateCriterionInput{ + Value: start.String(), Modifier: models.CriterionModifierGreaterThan, } } if end != nil { - filter.CareerEnd = &models.IntCriterionInput{ - Value: (*end) + 1, // plus one to make it exclusive + end = &models.Date{ + Time: end.AddDate(1, 0, 0), // make exclusive + Precision: models.DatePrecisionDay, + } + filter.CareerEnd = &models.DateCriterionInput{ + Value: end.String(), // plus one to make it exclusive Modifier: models.CriterionModifierLessThan, } } case models.CriterionModifierIsNull: - filter.CareerStart = &models.IntCriterionInput{ + filter.CareerStart = &models.DateCriterionInput{ Modifier: models.CriterionModifierIsNull, } - filter.CareerEnd = &models.IntCriterionInput{ + filter.CareerEnd = &models.DateCriterionInput{ Modifier: models.CriterionModifierIsNull, } case models.CriterionModifierNotNull: - filter.CareerStart = &models.IntCriterionInput{ + filter.CareerStart = &models.DateCriterionInput{ Modifier: models.CriterionModifierNotNull, } - filter.CareerEnd = &models.IntCriterionInput{ + filter.CareerEnd = &models.DateCriterionInput{ Modifier: models.CriterionModifierNotNull, } } @@ -292,7 +333,7 @@ func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing * if isMissing != nil && *isMissing != "" { switch *isMissing { case "url": - performersURLsTableMgr.join(f, "", "performers.id") + performersURLsTableMgr.leftJoin(f, "", "performers.id") f.addWhere("performer_urls.url IS NULL") case "scenes": // Deprecated: use `scene_count == 0` filter instead f.addLeftJoin(performersScenesTable, "scenes_join", "scenes_join.performer_id = performers.id") @@ -300,12 +341,24 @@ func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing * case "image": f.addWhere("performers.image_blob IS NULL") case "stash_id": - performersStashIDsTableMgr.join(f, "performer_stash_ids", "performers.id") + performersStashIDsTableMgr.leftJoin(f, "performer_stash_ids", "performers.id") f.addWhere("performer_stash_ids.performer_id IS NULL") case "aliases": - performersAliasesTableMgr.join(f, "", "performers.id") + performersAliasesTableMgr.leftJoin(f, "", "performers.id") f.addWhere("performer_aliases.alias IS NULL") + case "tags": + f.addLeftJoin(performersTagsTable, "tags_join", "tags_join.performer_id = performers.id") + f.addWhere("tags_join.performer_id IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "disambiguation", "gender", "birthdate", "death_date", + "ethnicity", "country", "hair_color", "eye_color", "height", "weight", + "measurements", "fake_tits", "penis_length", "circumcised", + "career_start", "career_end", "tattoos", "piercings", "details", "rating", + }); err != nil { + f.setError(err) + return + } f.addWhere("(performers." + *isMissing + " IS NULL OR TRIM(performers." + *isMissing + ") = '')") } } @@ -330,8 +383,8 @@ func (qb *performerFilterHandler) urlsCriterionHandler(url *models.StringCriteri primaryFK: performerIDColumn, joinTable: performerURLsTable, stringColumn: performerURLColumn, - addJoinTable: func(f *filterBuilder) { - performersURLsTableMgr.join(f, "", "performers.id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + performersURLsTableMgr.join(f, joinType, "", "performers.id") }, } @@ -344,8 +397,8 @@ func (qb *performerFilterHandler) aliasCriterionHandler(alias *models.StringCrit primaryFK: performerIDColumn, joinTable: performersAliasesTable, stringColumn: performerAliasColumn, - addJoinTable: func(f *filterBuilder) { - performersAliasesTableMgr.join(f, "", "performers.id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + performersAliasesTableMgr.join(f, joinType, "", "performers.id") }, } @@ -387,6 +440,22 @@ func (qb *performerFilterHandler) sceneCountCriterionHandler(count *models.IntCr return h.handler(count) } +func (qb *performerFilterHandler) markerCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if count != nil { + performerRepository.scenes.innerJoin(f, "", "performers.id") + + const query = `(SELECT COUNT(*) FROM scene_markers + INNER JOIN scenes ON scene_markers.scene_id = scenes.id + INNER JOIN performers_scenes ON performers_scenes.scene_id = scenes.id + WHERE performers_scenes.performer_id = performers.id)` + + clause, args := getIntCriterionWhereClause(query, *count) + f.addWhere(clause, args...) + } + } +} + func (qb *performerFilterHandler) imageCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 46a5febee..ebe1b9eab 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -65,9 +65,9 @@ func Test_PerformerStore_Create(t *testing.T) { measurements = "measurements" fakeTits = "fakeTits" penisLength = 1.23 - circumcised = models.CircumisedEnumCut - careerStart = 2005 - careerEnd = 2015 + circumcised = models.CircumcisedEnumCut + careerStart = models.DateFromYear(2005) + careerEnd = models.DateFromYear(2015) tattoos = "tattoos" piercings = "piercings" aliases = []string{"alias1", "alias2"} @@ -228,9 +228,9 @@ func Test_PerformerStore_Update(t *testing.T) { measurements = "measurements" fakeTits = "fakeTits" penisLength = 1.23 - circumcised = models.CircumisedEnumCut - careerStart = 2005 - careerEnd = 2015 + circumcised = models.CircumcisedEnumCut + careerStart = models.DateFromYear(2005) + careerEnd = models.DateFromYear(2015) tattoos = "tattoos" piercings = "piercings" aliases = []string{"alias1", "alias2"} @@ -424,8 +424,8 @@ func clearPerformerPartial() models.PerformerPartial { FakeTits: nullString, PenisLength: nullFloat, Circumcised: nullString, - CareerStart: nullInt, - CareerEnd: nullInt, + CareerStart: nullDate, + CareerEnd: nullDate, Tattoos: nullString, Piercings: nullString, Aliases: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, @@ -457,9 +457,9 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { measurements = "measurements" fakeTits = "fakeTits" penisLength = 1.23 - circumcised = models.CircumisedEnumCut - careerStart = 2005 - careerEnd = 2015 + circumcised = models.CircumcisedEnumCut + careerStart = models.DateFromYear(2005) + careerEnd = models.DateFromYear(2015) tattoos = "tattoos" piercings = "piercings" aliases = []string{"alias1", "alias2"} @@ -505,8 +505,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { FakeTits: models.NewOptionalString(fakeTits), PenisLength: models.NewOptionalFloat64(penisLength), Circumcised: models.NewOptionalString(circumcised.String()), - CareerStart: models.NewOptionalInt(careerStart), - CareerEnd: models.NewOptionalInt(careerEnd), + CareerStart: models.NewOptionalDate(careerStart), + CareerEnd: models.NewOptionalDate(careerEnd), Tattoos: models.NewOptionalString(tattoos), Piercings: models.NewOptionalString(piercings), Aliases: &models.UpdateStrings{ @@ -1200,7 +1200,7 @@ func TestPerformerQuery(t *testing.T) { nil, &models.PerformerFilterType{ Circumcised: &models.CircumcisionCriterionInput{ - Value: []models.CircumisedEnum{models.CircumisedEnumCut}, + Value: []models.CircumcisedEnum{models.CircumcisedEnumCut}, Modifier: models.CriterionModifierIncludes, }, }, @@ -1213,7 +1213,7 @@ func TestPerformerQuery(t *testing.T) { nil, &models.PerformerFilterType{ Circumcised: &models.CircumcisionCriterionInput{ - Value: []models.CircumisedEnum{models.CircumisedEnumCut}, + Value: []models.CircumcisedEnum{models.CircumcisedEnumCut}, Modifier: models.CriterionModifierExcludes, }, }, @@ -1778,8 +1778,8 @@ func TestPerformerQueryLegacyCareerLength(t *testing.T) { tests := []struct { name string c models.StringCriterionInput - careerStartCrit *models.IntCriterionInput - careerEndCrit *models.IntCriterionInput + careerStartCrit *models.DateCriterionInput + careerEndCrit *models.DateCriterionInput err bool }{ { @@ -1788,13 +1788,13 @@ func TestPerformerQueryLegacyCareerLength(t *testing.T) { Value: value, Modifier: models.CriterionModifierEquals, }, - careerStartCrit: &models.IntCriterionInput{ - Value: 2002, - Modifier: models.CriterionModifierEquals, + careerStartCrit: &models.DateCriterionInput{ + Value: "2001-12-31", + Modifier: models.CriterionModifierGreaterThan, }, - careerEndCrit: &models.IntCriterionInput{ - Value: 2012, - Modifier: models.CriterionModifierEquals, + careerEndCrit: &models.DateCriterionInput{ + Value: "2013-01-01", + Modifier: models.CriterionModifierLessThan, }, err: false, }, @@ -1811,10 +1811,10 @@ func TestPerformerQueryLegacyCareerLength(t *testing.T) { c: models.StringCriterionInput{ Modifier: models.CriterionModifierIsNull, }, - careerStartCrit: &models.IntCriterionInput{ + careerStartCrit: &models.DateCriterionInput{ Modifier: models.CriterionModifierIsNull, }, - careerEndCrit: &models.IntCriterionInput{ + careerEndCrit: &models.DateCriterionInput{ Modifier: models.CriterionModifierIsNull, }, err: false, @@ -1824,10 +1824,10 @@ func TestPerformerQueryLegacyCareerLength(t *testing.T) { c: models.StringCriterionInput{ Modifier: models.CriterionModifierNotNull, }, - careerStartCrit: &models.IntCriterionInput{ + careerStartCrit: &models.DateCriterionInput{ Modifier: models.CriterionModifierNotNull, }, - careerEndCrit: &models.IntCriterionInput{ + careerEndCrit: &models.DateCriterionInput{ Modifier: models.CriterionModifierNotNull, }, err: false, @@ -1865,16 +1865,16 @@ func TestPerformerQueryLegacyCareerLength(t *testing.T) { } for _, performer := range performers { - verifyIntPtr(t, performer.CareerStart, *tt.careerStartCrit) - verifyIntPtr(t, performer.CareerEnd, *tt.careerEndCrit) + verifyDatePtr(t, performer.CareerStart, *tt.careerStartCrit) + verifyDatePtr(t, performer.CareerEnd, *tt.careerEndCrit) } }) } } func TestPerformerQueryCareerStart(t *testing.T) { - const value = 2002 - criterion := models.IntCriterionInput{ + const value = "2002" + criterion := models.DateCriterionInput{ Value: value, Modifier: models.CriterionModifierEquals, } @@ -1891,7 +1891,7 @@ func TestPerformerQueryCareerStart(t *testing.T) { } for _, performer := range performers { - verifyIntPtr(t, performer.CareerStart, criterion) + verifyDatePtr(t, performer.CareerStart, criterion) } return nil @@ -1899,8 +1899,8 @@ func TestPerformerQueryCareerStart(t *testing.T) { } func TestPerformerQueryCareerEnd(t *testing.T) { - const value = 2012 - criterion := models.IntCriterionInput{ + const value = "2012" + criterion := models.DateCriterionInput{ Value: value, Modifier: models.CriterionModifierEquals, } @@ -1917,7 +1917,7 @@ func TestPerformerQueryCareerEnd(t *testing.T) { } for _, performer := range performers { - verifyIntPtr(t, performer.CareerEnd, criterion) + verifyDatePtr(t, performer.CareerEnd, criterion) } return nil diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index 99c1f4e5f..80c7fcd40 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -17,13 +17,26 @@ type queryBuilder struct { joins joins whereClauses []string havingClauses []string - args []interface{} withClauses []string recursiveWith bool + withArgs []interface{} + joinArgs []interface{} + whereArgs []interface{} + havingArgs []interface{} + sortAndPagination string } +func (qb queryBuilder) allArgs() []interface{} { + var args []interface{} + args = append(args, qb.withArgs...) + args = append(args, qb.joinArgs...) + args = append(args, qb.whereArgs...) + args = append(args, qb.havingArgs...) + return args +} + func (qb queryBuilder) body(includeSortPagination bool) string { return fmt.Sprintf("SELECT %s FROM %s%s", strings.Join(qb.columns, ", "), qb.from, qb.joins.toSQL(includeSortPagination)) } @@ -55,13 +68,13 @@ func (qb queryBuilder) toSQL(includeSortPagination bool) string { func (qb queryBuilder) findIDs(ctx context.Context) ([]int, error) { const includeSortPagination = true sql := qb.toSQL(includeSortPagination) - return qb.repository.runIdsQuery(ctx, sql, qb.args) + return qb.repository.runIdsQuery(ctx, sql, qb.allArgs()) } func (qb queryBuilder) executeFind(ctx context.Context) ([]int, int, error) { const includeSortPagination = true body := qb.body(includeSortPagination) - return qb.repository.executeFindQuery(ctx, body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses, qb.recursiveWith) + return qb.repository.executeFindQuery(ctx, body, qb.allArgs(), qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses, qb.recursiveWith) } func (qb queryBuilder) executeCount(ctx context.Context) (int, error) { @@ -79,7 +92,7 @@ func (qb queryBuilder) executeCount(ctx context.Context) (int, error) { body = qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses) countQuery := withClause + qb.repository.buildCountQuery(body) - return qb.repository.runCountQuery(ctx, countQuery, qb.args) + return qb.repository.runCountQuery(ctx, countQuery, qb.allArgs()) } func (qb *queryBuilder) addWhere(clauses ...string) { @@ -109,7 +122,11 @@ func (qb *queryBuilder) addWith(recursive bool, clauses ...string) { } func (qb *queryBuilder) addArg(args ...interface{}) { - qb.args = append(qb.args, args...) + qb.whereArgs = append(qb.whereArgs, args...) +} + +func (qb *queryBuilder) addHavingArg(args ...interface{}) { + qb.havingArgs = append(qb.havingArgs, args...) } func (qb *queryBuilder) hasJoin(alias string) bool { @@ -148,7 +165,7 @@ func (qb *queryBuilder) joinSort(table, as, onClause string) { func (qb *queryBuilder) addJoins(joins ...join) { for _, j := range joins { if qb.joins.addUnique(j) { - qb.args = append(qb.args, j.args...) + qb.joinArgs = append(qb.joinArgs, j.args...) } } } @@ -163,20 +180,16 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) error { if len(clause) > 0 { qb.addWith(f.recursiveWith, clause) } - if len(args) > 0 { - // WITH clause always comes first and thus precedes alk args - qb.args = append(args, qb.args...) + qb.withArgs = append(qb.withArgs, args...) } - // add joins here to insert args qb.addJoins(f.getAllJoins()...) clause, args = f.generateWhereClauses() if len(clause) > 0 { qb.addWhere(clause) } - if len(args) > 0 { qb.addArg(args...) } @@ -185,9 +198,8 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) error { if len(clause) > 0 { qb.addHaving(clause) } - if len(args) > 0 { - qb.addArg(args...) + qb.addHavingArg(args...) } return nil diff --git a/pkg/sqlite/record.go b/pkg/sqlite/record.go index 71622dc60..509c384e5 100644 --- a/pkg/sqlite/record.go +++ b/pkg/sqlite/record.go @@ -93,7 +93,7 @@ func (r *updateRecord) setTimestamp(destField string, v models.OptionalTime) { } } -//nolint:golint,unused +//nolint:unused func (r *updateRecord) setNullTimestamp(destField string, v models.OptionalTime) { if v.Set { r.set(destField, NullTimestampFromTimePtr(v.Ptr())) diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go index 18d501e3a..1b0c03113 100644 --- a/pkg/sqlite/repository.go +++ b/pkg/sqlite/repository.go @@ -204,7 +204,15 @@ func (r *repository) newQuery() queryBuilder { } } -func (r *repository) join(j joiner, as string, parentIDCol string) { +func (r *repository) join(j joiner, t joinType, as string, parentIDCol string) { + fn := r.innerJoin + if t == joinTypeLeft { + fn = r.leftJoin + } + fn(j, as, parentIDCol) +} + +func (r *repository) leftJoin(j joiner, as string, parentIDCol string) { t := r.tableName if as != "" { t = as diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 3049681b2..c2093431d 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -1097,7 +1097,7 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce Duration null.Float Size null.Float }{} - if err := sceneRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + if err := sceneRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil { return nil, err } diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index a9eb6b0ae..255a8e0b3 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -63,8 +63,12 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { stringCriterionHandler(sceneFilter.Director, "scenes.director"), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if sceneFilter.Oshash != nil { - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_oshash", "scenes_files.file_id = fingerprints_oshash.file_id AND fingerprints_oshash.type = 'oshash'") + joinType := joinTypeInner + if sceneFilter.Oshash.Modifier == models.CriterionModifierIsNull { + joinType = joinTypeLeft + } + qb.addSceneFilesTable(f, joinType) + f.addJoin(joinType, fingerprintTable, "fingerprints_oshash", "scenes_files.file_id = fingerprints_oshash.file_id AND fingerprints_oshash.type = 'oshash'") } stringCriterionHandler(sceneFilter.Oshash, "fingerprints_oshash.fingerprint")(ctx, f) @@ -72,8 +76,12 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if sceneFilter.Checksum != nil { - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_md5", "scenes_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") + joinType := joinTypeInner + if sceneFilter.Checksum.Modifier == models.CriterionModifierIsNull { + joinType = joinTypeLeft + } + qb.addSceneFilesTable(f, joinType) + f.addJoin(joinType, fingerprintTable, "fingerprints_md5", "scenes_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") } stringCriterionHandler(sceneFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) @@ -84,8 +92,12 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { // backwards compatibility h := phashDistanceCriterionHandler{ joinFn: func(f *filterBuilder) { - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") + joinType := joinTypeInner + if sceneFilter.Phash.Modifier == models.CriterionModifierIsNull { + joinType = joinTypeLeft + } + qb.addSceneFilesTable(f, joinType) + f.addJoin(joinType, fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") }, criterion: &models.PhashDistanceCriterionInput{ Value: sceneFilter.Phash.Value, @@ -98,8 +110,9 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { &phashDistanceCriterionHandler{ joinFn: func(f *filterBuilder) { - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") + const joinType = joinTypeInner + qb.addSceneFilesTable(f, joinType) + f.addJoin(joinType, fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") }, criterion: sceneFilter.PhashDistance, }, @@ -122,7 +135,7 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if sceneFilter.StashID != nil { - sceneRepository.stashIDs.join(f, "scene_stash_ids", "scenes.id") + sceneRepository.stashIDs.leftJoin(f, "scene_stash_ids", "scenes.id") stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")(ctx, f) } }), @@ -236,8 +249,8 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { isRelated: true, }, joinFn: func(f *filterBuilder) { - qb.addFilesTable(f) - qb.addFoldersTable(f) + qb.addFilesTable(f, joinTypeInner) + qb.addFoldersTable(f, joinTypeInner) }, // don't use a subquery; join directly directJoin: true, @@ -254,23 +267,23 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { } } -func (qb *sceneFilterHandler) addSceneFilesTable(f *filterBuilder) { - f.addLeftJoin(scenesFilesTable, "", "scenes_files.scene_id = scenes.id") +func (qb *sceneFilterHandler) addSceneFilesTable(f *filterBuilder, joinType joinType) { + f.addJoin(joinType, scenesFilesTable, "", "scenes_files.scene_id = scenes.id") } -func (qb *sceneFilterHandler) addFilesTable(f *filterBuilder) { - qb.addSceneFilesTable(f) - f.addLeftJoin(fileTable, "", "scenes_files.file_id = files.id") +func (qb *sceneFilterHandler) addFilesTable(f *filterBuilder, joinType joinType) { + qb.addSceneFilesTable(f, joinType) + f.addJoin(joinType, fileTable, "", "scenes_files.file_id = files.id") } -func (qb *sceneFilterHandler) addFoldersTable(f *filterBuilder) { - qb.addFilesTable(f) - f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") +func (qb *sceneFilterHandler) addFoldersTable(f *filterBuilder, joinType joinType) { + qb.addFilesTable(f, joinType) + f.addJoin(joinType, folderTable, "", "files.parent_folder_id = folders.id") } -func (qb *sceneFilterHandler) addVideoFilesTable(f *filterBuilder) { - qb.addSceneFilesTable(f) - f.addLeftJoin(videoFileTable, "", "video_files.file_id = scenes_files.file_id") +func (qb *sceneFilterHandler) addVideoFilesTable(f *filterBuilder, joinType joinType) { + qb.addSceneFilesTable(f, joinType) + f.addJoin(joinType, videoFileTable, "", "video_files.file_id = scenes_files.file_id") } func (qb *sceneFilterHandler) playCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { @@ -318,7 +331,7 @@ func (qb *sceneFilterHandler) duplicatedCriterionHandler(duplicatedFilter *model // Handle explicit fields if duplicatedFilter.Phash != nil { - qb.addSceneFilesTable(f) + qb.addSceneFilesTable(f, joinTypeInner) qb.applyPhashDuplication(f, *duplicatedFilter.Phash) } @@ -368,11 +381,15 @@ func (qb *sceneFilterHandler) applyURLDuplication(f *filterBuilder, duplicated b f.addInnerJoin("(SELECT scene_id FROM scene_urls INNER JOIN (SELECT url FROM scene_urls GROUP BY url HAVING COUNT(DISTINCT scene_id) "+v+" 1) dupes ON scene_urls.url = dupes.url)", "scurl", "scenes.id = scurl.scene_id") } -func (qb *sceneFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func (qb *sceneFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if codec != nil { if addJoinFn != nil { - addJoinFn(f) + joinType := joinTypeInner + if codec.Modifier == models.CriterionModifierIsNull { + joinType = joinTypeLeft + } + addJoinFn(f, joinType) } stringCriterionHandler(codec, codecColumn)(ctx, f) @@ -398,34 +415,40 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite if isMissing != nil && *isMissing != "" { switch *isMissing { case "url": - scenesURLsTableMgr.join(f, "", "scenes.id") + scenesURLsTableMgr.leftJoin(f, "", "scenes.id") f.addWhere("scene_urls.url IS NULL") case "galleries": - sceneRepository.galleries.join(f, "galleries_join", "scenes.id") + sceneRepository.galleries.leftJoin(f, "galleries_join", "scenes.id") f.addWhere("galleries_join.scene_id IS NULL") case "studio": f.addWhere("scenes.studio_id IS NULL") case "movie", "group": - sceneRepository.groups.join(f, "groups_join", "scenes.id") + sceneRepository.groups.leftJoin(f, "groups_join", "scenes.id") f.addWhere("groups_join.scene_id IS NULL") case "performers": - sceneRepository.performers.join(f, "performers_join", "scenes.id") + sceneRepository.performers.leftJoin(f, "performers_join", "scenes.id") f.addWhere("performers_join.scene_id IS NULL") case "date": f.addWhere(`scenes.date IS NULL OR scenes.date IS ""`) case "tags": - sceneRepository.tags.join(f, "tags_join", "scenes.id") + sceneRepository.tags.leftJoin(f, "tags_join", "scenes.id") f.addWhere("tags_join.scene_id IS NULL") case "stash_id": - sceneRepository.stashIDs.join(f, "scene_stash_ids", "scenes.id") + sceneRepository.stashIDs.leftJoin(f, "scene_stash_ids", "scenes.id") f.addWhere("scene_stash_ids.scene_id IS NULL") case "phash": - qb.addSceneFilesTable(f) + qb.addSceneFilesTable(f, joinTypeLeft) f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") f.addWhere("fingerprints_phash.fingerprint IS NULL") case "cover": f.addWhere("scenes.cover_blob IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "title", "code", "details", "director", "rating", + }); err != nil { + f.setError(err) + return + } f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')") } } @@ -438,15 +461,15 @@ func (qb *sceneFilterHandler) urlsCriterionHandler(url *models.StringCriterionIn primaryFK: sceneIDColumn, joinTable: scenesURLsTable, stringColumn: sceneURLColumn, - addJoinTable: func(f *filterBuilder) { - scenesURLsTableMgr.join(f, "", "scenes.id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + scenesURLsTableMgr.join(f, joinType, "", "scenes.id") }, } return h.handler(url) } -func (qb *sceneFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { +func (qb *sceneFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder, joinType joinType)) multiCriterionHandlerBuilder { return multiCriterionHandlerBuilder{ primaryTable: sceneTable, foreignTable: foreignTable, @@ -463,9 +486,9 @@ func (qb *sceneFilterHandler) captionCriterionHandler(captions *models.StringCri primaryFK: sceneIDColumn, joinTable: videoCaptionsTable, stringColumn: captionCodeColumn, - addJoinTable: func(f *filterBuilder) { - qb.addSceneFilesTable(f) - f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = scenes_files.file_id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + qb.addSceneFilesTable(f, joinTypeLeft) + f.addJoin(joinType, videoCaptionsTable, "", "video_captions.file_id = scenes_files.file_id") }, excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) { excludeClause := `scenes.id NOT IN ( @@ -525,8 +548,8 @@ func (qb *sceneFilterHandler) performersCriterionHandler(performers *models.Mult primaryFK: sceneIDColumn, foreignFK: performerIDColumn, - addJoinTable: func(f *filterBuilder) { - sceneRepository.performers.join(f, "performers_join", "scenes.id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + sceneRepository.performers.join(f, joinType, "performers_join", "scenes.id") }, } @@ -581,9 +604,9 @@ func (qb *sceneFilterHandler) performerAgeCriterionHandler(performerAge *models. // legacy handler func (qb *sceneFilterHandler) moviesCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - sceneRepository.groups.join(f, "", "scenes.id") - f.addLeftJoin("groups", "", "groups_scenes.group_id = groups.id") + addJoinsFunc := func(f *filterBuilder, joinType joinType) { + sceneRepository.groups.leftJoin(f, "", "scenes.id") + f.addJoin(joinType, "groups", "", "groups_scenes.group_id = groups.id") } h := qb.getMultiCriterionHandlerBuilder(groupTable, groupsScenesTable, "group_id", addJoinsFunc) return h.handler(movies) @@ -607,9 +630,9 @@ func (qb *sceneFilterHandler) groupsCriterionHandler(groups *models.Hierarchical } func (qb *sceneFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - sceneRepository.galleries.join(f, "", "scenes.id") - f.addLeftJoin("galleries", "", "scenes_galleries.gallery_id = galleries.id") + addJoinsFunc := func(f *filterBuilder, joinType joinType) { + sceneRepository.galleries.leftJoin(f, "", "scenes.id") + f.addJoin(joinType, "galleries", "", "scenes_galleries.gallery_id = galleries.id") } h := qb.getMultiCriterionHandlerBuilder(galleryTable, scenesGalleriesTable, "gallery_id", addJoinsFunc) return h.handler(galleries) diff --git a/pkg/sqlite/scene_marker_filter.go b/pkg/sqlite/scene_marker_filter.go index 34fa0f39b..26f5e0f8d 100644 --- a/pkg/sqlite/scene_marker_filter.go +++ b/pkg/sqlite/scene_marker_filter.go @@ -173,8 +173,8 @@ func (qb *sceneMarkerFilterHandler) performersCriterionHandler(performers *model primaryFK: sceneIDColumn, foreignFK: performerIDColumn, - addJoinTable: func(f *filterBuilder) { - f.addLeftJoin(performersScenesTable, "performers_join", "performers_join.scene_id = scene_markers.scene_id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + f.addJoin(joinType, performersScenesTable, "performers_join", "performers_join.scene_id = scene_markers.scene_id") }, } @@ -191,8 +191,8 @@ func (qb *sceneMarkerFilterHandler) performersCriterionHandler(performers *model } func (qb *sceneMarkerFilterHandler) scenesCriterionHandler(scenes *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - f.addLeftJoin(sceneTable, "markers_scenes", "markers_scenes.id = scene_markers.scene_id") + addJoinsFunc := func(f *filterBuilder, joinType joinType) { + f.addJoin(joinType, sceneTable, "markers_scenes", "markers_scenes.id = scene_markers.scene_id") } h := multiCriterionHandlerBuilder{ primaryTable: sceneMarkerTable, diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index d386175c7..67bf227a2 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -2821,6 +2821,33 @@ func verifyIntPtr(t *testing.T, value *int, criterion models.IntCriterionInput) } } +func verifyDatePtr(t *testing.T, value *models.Date, criterion models.DateCriterionInput) { + t.Helper() + assert := assert.New(t) + if criterion.Modifier == models.CriterionModifierIsNull { + assert.Nil(value, "expect is null values to be null") + } + if criterion.Modifier == models.CriterionModifierNotNull { + assert.NotNil(value, "expect not null values to be not null") + } + if criterion.Modifier == models.CriterionModifierEquals { + date, _ := models.ParseDate(criterion.Value) + assert.Equal(date, *value) + } + if criterion.Modifier == models.CriterionModifierNotEquals { + date, _ := models.ParseDate(criterion.Value) + assert.NotEqual(date, *value) + } + if criterion.Modifier == models.CriterionModifierGreaterThan { + date, _ := models.ParseDate(criterion.Value) + assert.True(value.After(date)) + } + if criterion.Modifier == models.CriterionModifierLessThan { + date, _ := models.ParseDate(criterion.Value) + assert.True(date.After(*value)) + } +} + func TestSceneQueryOCounter(t *testing.T) { const oCounter = 1 oCounterCriterion := models.IntCriterionInput{ diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 91f9f127b..4ab310ee7 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -31,7 +31,8 @@ const ( ) const ( - folderIdxWithSubFolder = iota + folderIdxRoot = iota + folderIdxWithSubFolder folderIdxWithParentFolder folderIdxWithFiles folderIdxInZip @@ -305,6 +306,7 @@ const ( pathField = "Path" checksumField = "Checksum" titleField = "Title" + detailsField = "Details" urlField = "URL" zipPath = "zipPath.zip" firstSavedFilterName = "firstSavedFilterName" @@ -359,6 +361,8 @@ func (m linkMap) reverseLookup(idx int) []int { var ( folderParentFolders = map[int]int{ + folderIdxWithSubFolder: folderIdxRoot, + folderIdxForObjectFiles: folderIdxRoot, folderIdxWithParentFolder: folderIdxWithSubFolder, folderIdxWithSceneFiles: folderIdxForObjectFiles, folderIdxWithImageFiles: folderIdxForObjectFiles, @@ -785,6 +789,10 @@ func getFolderPath(index int, parentFolderIdx *int) string { return path } +func getFolderBasename(index int, parentFolderIdx *int) string { + return filepath.Base(getFolderPath(index, parentFolderIdx)) +} + func getFolderModTime(index int) time.Time { return time.Date(2000, 1, (index%10)+1, 0, 0, 0, 0, time.UTC) } @@ -858,16 +866,24 @@ func getFileModTime(index int) time.Time { return getFolderModTime(index) } +func getFilePhash(index int) int64 { + return int64(index * 567) +} + func getFileFingerprints(index int) []models.Fingerprint { return []models.Fingerprint{ { - Type: "MD5", + Type: models.FingerprintTypeMD5, Fingerprint: getPrefixedStringValue("file", index, "md5"), }, { - Type: "OSHASH", + Type: models.FingerprintTypeOshash, Fingerprint: getPrefixedStringValue("file", index, "oshash"), }, + { + Type: models.FingerprintTypePhash, + Fingerprint: getFilePhash(index), + }, } } @@ -1247,6 +1263,18 @@ func getImageBasename(index int) string { return getImageStringValue(index, pathField) } +func getImageCustomFields(index int) map[string]interface{} { + if index%5 == 0 { + return nil + } + + return map[string]interface{}{ + "string": getImageStringValue(index, "custom"), + "int": int64(index % 5), + "real": float64(index) / 10, + } +} + func makeImageFile(i int) *models.ImageFile { return &models.ImageFile{ BaseFile: &models.BaseFile{ @@ -1278,9 +1306,10 @@ func makeImage(i int) *models.Image { tids := indexesToIDs(tagIDs, imageTags[i]) return &models.Image{ - Title: title, - Rating: getIntPtr(getRating(i)), - Date: getObjectDate(i), + Title: title, + Details: getImageStringValue(i, detailsField), + Rating: getIntPtr(getRating(i)), + Date: getObjectDate(i), URLs: models.NewRelatedStrings([]string{ getImageEmptyString(i, urlField), }), @@ -1309,7 +1338,11 @@ func createImages(ctx context.Context, n int) error { image := makeImage(i) - err := qb.Create(ctx, image, []models.FileID{f.ID}) + err := qb.Create(ctx, &models.CreateImageInput{ + Image: image, + FileIDs: []models.FileID{f.ID}, + CustomFields: getImageCustomFields(i), + }) if err != nil { return fmt.Errorf("Error creating image %v+: %s", image, err.Error()) @@ -1389,6 +1422,18 @@ func makeGallery(i int, includeScenes bool) *models.Gallery { return ret } +func getGalleryCustomFields(index int) map[string]interface{} { + if index%5 == 0 { + return nil + } + + return map[string]interface{}{ + "string": getGalleryStringValue(index, "custom"), + "int": int64(index % 5), + "real": float64(index) / 10, + } +} + func createGalleries(ctx context.Context, n int) error { gqb := db.Gallery fqb := db.File @@ -1410,7 +1455,11 @@ func createGalleries(ctx context.Context, n int) error { const includeScenes = false gallery := makeGallery(i, includeScenes) - err := gqb.Create(ctx, gallery, fileIDs) + err := gqb.Create(ctx, &models.CreateGalleryInput{ + Gallery: gallery, + FileIDs: fileIDs, + CustomFields: getGalleryCustomFields(i), + }) if err != nil { return fmt.Errorf("Error creating gallery %v+: %s", gallery, err.Error()) @@ -1441,6 +1490,18 @@ func getGroupEmptyString(index int, field string) string { return v.String } +func getGroupCustomFields(index int) map[string]interface{} { + if index%5 == 0 { + return nil + } + + return map[string]interface{}{ + "string": getGroupStringValue(index, "custom"), + "int": int64(index % 5), + "real": float64(index) / 10, + } +} + // createGroups creates n groups with plain Name and o groups with camel cased NaMe included func createGroups(ctx context.Context, mqb models.GroupReaderWriter, n int, o int) error { const namePlain = "Name" @@ -1473,6 +1534,13 @@ func createGroups(ctx context.Context, mqb models.GroupReaderWriter, n int, o in return fmt.Errorf("Error creating group [%d] %v+: %s", i, group, err.Error()) } + customFields := getGroupCustomFields(i) + if customFields != nil { + if err := mqb.SetCustomFields(ctx, group.ID, models.CustomFieldsInput{Full: customFields}); err != nil { + return fmt.Errorf("Error setting custom fields for group %d: %s", group.ID, err.Error()) + } + } + groupIDs = append(groupIDs, group.ID) groupNames = append(groupNames, group.Name) } @@ -1529,24 +1597,24 @@ func getPerformerDeathDate(index int) *models.Date { return &ret } -func getPerformerCareerStart(index int) *int { +func getPerformerCareerStart(index int) *models.Date { if index%5 == 0 { return nil } - ret := 2000 + index - return &ret + date := models.DateFromYear(2000 + index) + return &date } -func getPerformerCareerEnd(index int) *int { +func getPerformerCareerEnd(index int) *models.Date { if index%5 == 0 { return nil } // only set career_end for even indices if index%2 == 0 { - ret := 2010 + index - return &ret + date := models.DateFromYear(2010 + index) + return &date } return nil } @@ -1560,15 +1628,15 @@ func getPerformerPenisLength(index int) *float64 { return &ret } -func getPerformerCircumcised(index int) *models.CircumisedEnum { - var ret models.CircumisedEnum +func getPerformerCircumcised(index int) *models.CircumcisedEnum { + var ret models.CircumcisedEnum switch { case index%3 == 0: return nil case index%3 == 1: - ret = models.CircumisedEnumCut + ret = models.CircumcisedEnumCut default: - ret = models.CircumisedEnumUncut + ret = models.CircumcisedEnumUncut } return &ret diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 0b55af8db..87376c2c1 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -71,6 +71,16 @@ func (o sortOptions) validateSort(sort string) error { return fmt.Errorf("invalid sort: %s", sort) } +func validateIsMissing(isMissing string, allowed []string) error { + for _, v := range allowed { + if v == isMissing { + return nil + } + } + + return fmt.Errorf("invalid is_missing field: %s", isMissing) +} + func getSortDirection(direction string) string { if direction != "ASC" && direction != "DESC" { return "ASC" @@ -259,8 +269,11 @@ func getDateWhereClause(column string, modifier models.CriterionModifier, value upper = &u } - args := []interface{}{value} - betweenArgs := []interface{}{value, *upper} + valueDate, _ := models.ParseDate(value) + date := Date{Date: valueDate.Time} + + args := []interface{}{date} + betweenArgs := []interface{}{date, *upper} switch modifier { case models.CriterionModifierIsNull: diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index a866a94ab..87f905935 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -629,6 +629,16 @@ func (qb *StudioStore) sortByScenesDuration(direction string) string { ) %s`, sceneTable, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, scenesFilesTable, sceneTable, studioIDColumn, studioTable, getSortDirection(direction)) } +func (qb *StudioStore) sortByScenesSize(direction string) string { + return fmt.Sprintf(` ORDER BY ( + SELECT COALESCE(SUM(%s.size), 0) + FROM %s + LEFT JOIN %s ON %s.%s = %s.id + LEFT JOIN %s ON %s.id = %s.file_id + WHERE %s.%s = %s.id + ) %s`, fileTable, sceneTable, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, fileTable, fileTable, scenesFilesTable, sceneTable, studioIDColumn, studioTable, getSortDirection(direction)) +} + // used for sorting on performer latest scene var selectStudioLatestSceneSQL = utils.StrFormat( "SELECT MAX(date) FROM ("+ @@ -658,6 +668,7 @@ var studioSortOptions = sortOptions{ "name", "scenes_count", "scenes_duration", + "scenes_size", "random", "rating", "tag_count", @@ -688,6 +699,8 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, sortQuery += getCountSort(studioTable, sceneTable, studioIDColumn, direction) case "scenes_duration": sortQuery += qb.sortByScenesDuration(direction) + case "scenes_size": + sortQuery += qb.sortByScenesSize(direction) case "images_count": sortQuery += getCountSort(studioTable, imageTable, studioIDColumn, direction) case "galleries_count": diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index cfe3c59b6..9ad3494dc 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -63,7 +63,7 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if studioFilter.StashID != nil { - studioRepository.stashIDs.join(f, "studio_stash_ids", "studios.id") + studioRepository.stashIDs.leftJoin(f, "studio_stash_ids", "studios.id") stringCriterionHandler(studioFilter.StashID, "studio_stash_ids.stash_id")(ctx, f) } }), @@ -143,14 +143,26 @@ func (qb *studioFilterHandler) isMissingCriterionHandler(isMissing *string) crit if isMissing != nil && *isMissing != "" { switch *isMissing { case "url": - studiosURLsTableMgr.join(f, "", "studios.id") + studiosURLsTableMgr.leftJoin(f, "", "studios.id") f.addWhere("studio_urls.url IS NULL") case "image": f.addWhere("studios.image_blob IS NULL") case "stash_id": - studioRepository.stashIDs.join(f, "studio_stash_ids", "studios.id") + studioRepository.stashIDs.leftJoin(f, "studio_stash_ids", "studios.id") f.addWhere("studio_stash_ids.studio_id IS NULL") + case "aliases": + studiosAliasesTableMgr.leftJoin(f, "", "studios.id") + f.addWhere("studio_aliases.alias IS NULL") + case "tags": + f.addLeftJoin(studiosTagsTable, "tags_join", "tags_join.studio_id = studios.id") + f.addWhere("tags_join.studio_id IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "details", "rating", + }); err != nil { + f.setError(err) + return + } f.addWhere("(studios." + *isMissing + " IS NULL OR TRIM(studios." + *isMissing + ") = '')") } } @@ -212,8 +224,8 @@ func (qb *studioFilterHandler) tagCountCriterionHandler(tagCount *models.IntCrit } func (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - f.addLeftJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id") + addJoinsFunc := func(f *filterBuilder, joinType joinType) { + f.addJoin(joinType, "studios", "parent_studio", "parent_studio.id = studios.parent_id") } h := multiCriterionHandlerBuilder{ primaryTable: studioTable, @@ -232,8 +244,8 @@ func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriteri primaryFK: studioIDColumn, joinTable: studioAliasesTable, stringColumn: studioAliasColumn, - addJoinTable: func(f *filterBuilder) { - studiosAliasesTableMgr.join(f, "", "studios.id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + studiosAliasesTableMgr.join(f, joinType, "", "studios.id") }, } @@ -246,8 +258,8 @@ func (qb *studioFilterHandler) urlsCriterionHandler(url *models.StringCriterionI primaryFK: studioIDColumn, joinTable: studioURLsTable, stringColumn: studioURLColumn, - addJoinTable: func(f *filterBuilder) { - studiosURLsTableMgr.join(f, "", "studios.id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + studiosURLsTableMgr.join(f, joinType, "", "studios.id") }, } diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 790e84e94..434fe0e49 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -129,7 +129,22 @@ func (t *table) destroy(ctx context.Context, ids []int) error { return nil } -func (t *table) join(j joiner, as string, parentIDCol string) { +func (t *table) join(j joiner, jt joinType, as string, parentIDCol string) { + tableName := t.table.GetTable() + tt := tableName + if as != "" { + tt = as + } + + fn := j.addInnerJoin + if jt == joinTypeLeft { + fn = j.addLeftJoin + } + + fn(tableName, as, fmt.Sprintf("%s.%s = %s", tt, t.idColumn.GetCol(), parentIDCol)) +} + +func (t *table) leftJoin(j joiner, as string, parentIDCol string) { tableName := t.table.GetTable() tt := tableName if as != "" { @@ -1209,6 +1224,14 @@ func querySimple(ctx context.Context, query *goqu.SelectDataset, out interface{} return nil } +func querySelect(ctx context.Context, query string, args []interface{}, dest interface{}) error { + if err := dbWrapper.Select(ctx, dest, query, args...); err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("running query: %s [%v]: %w", query, args, err) + } + + return nil +} + // func cols(table exp.IdentifierExpression, cols []string) []interface{} { // var ret []interface{} // for _, c := range cols { diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 53e62b166..4c09113f0 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -14,12 +14,14 @@ var ( performersImagesJoinTable = goqu.T(performersImagesTable) imagesFilesJoinTable = goqu.T(imagesFilesTable) imagesURLsJoinTable = goqu.T(imagesURLsTable) + imagesCustomFieldsTable = goqu.T("image_custom_fields") galleriesFilesJoinTable = goqu.T(galleriesFilesTable) galleriesTagsJoinTable = goqu.T(galleriesTagsTable) performersGalleriesJoinTable = goqu.T(performersGalleriesTable) galleriesScenesJoinTable = goqu.T(galleriesScenesTable) galleriesURLsJoinTable = goqu.T(galleriesURLsTable) + galleriesCustomFieldsTable = goqu.T("gallery_custom_fields") scenesFilesJoinTable = goqu.T(scenesFilesTable) scenesTagsJoinTable = goqu.T(scenesTagsTable) @@ -46,6 +48,7 @@ var ( groupsURLsJoinTable = goqu.T(groupURLsTable) groupsTagsJoinTable = goqu.T(groupsTagsTable) groupRelationsJoinTable = goqu.T(groupRelationsTable) + groupsCustomFieldsTable = goqu.T("group_custom_fields") tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagRelationsJoinTable = goqu.T(tagRelationsTable) diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index a926dd56e..4ee69cc46 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -416,6 +416,18 @@ func (qb *TagStore) find(ctx context.Context, id int) (*models.Tag, error) { return ret, nil } +func (qb *TagStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Tag, error) { + table := qb.table() + + q := qb.selectDataset().Prepared(true).Where( + table.Col(idColumn).Eq( + sq, + ), + ) + + return qb.getMany(ctx, q) +} + // returns nil, sql.ErrNoRows if not found func (qb *TagStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Tag, error) { ret, err := qb.getMany(ctx, q) @@ -579,6 +591,27 @@ func (qb *TagStore) FindByNames(ctx context.Context, names []string, nocase bool return ret, nil } +func (qb *TagStore) FindByAlias(ctx context.Context, alias string, nocase bool) (*models.Tag, error) { + where := fmt.Sprintf("%s = ?", tagAliasColumn) + if nocase { + where += " COLLATE NOCASE" + } + sq := dialect.From(tagsAliasesJoinTable).Select( + tagsAliasesJoinTable.Col(tagIDColumn), + ).Prepared(true).Where(goqu.L(where, alias)).Limit(1) + ret, err := qb.findBySubquery(ctx, sq) + + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + + if len(ret) == 0 { + return nil, nil + } + + return ret[0], nil +} + func (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) { sq := dialect.From(tagsStashIDsJoinTable).Select(tagsStashIDsJoinTable.Col(tagIDColumn)).Where( tagsStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID), @@ -597,6 +630,36 @@ func (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) ( return ret, nil } +func (qb *TagStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Tag, error) { + table := qb.table() + sq := dialect.From(table).LeftJoin( + tagsStashIDsJoinTable, + goqu.On(table.Col(idColumn).Eq(tagsStashIDsJoinTable.Col(tagIDColumn))), + ).Select(table.Col(idColumn)) + + if hasStashID { + sq = sq.Where( + tagsStashIDsJoinTable.Col("stash_id").IsNotNull(), + tagsStashIDsJoinTable.Col("endpoint").Eq(stashboxEndpoint), + ) + } else { + sq = sq.Where( + tagsStashIDsJoinTable.Col("stash_id").IsNull(), + ) + } + + idsQuery := qb.selectDataset().Where( + table.Col(idColumn).In(sq), + ) + + ret, err := qb.getMany(ctx, idsQuery) + if err != nil { + return nil, fmt.Errorf("getting tags for stash-box endpoint %s: %w", stashboxEndpoint, err) + } + + return ret, nil +} + func (qb *TagStore) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) { return tagsParentTagsTableMgr.get(ctx, relatedID) } @@ -740,6 +803,7 @@ var tagSortOptions = sortOptions{ "scene_markers_count", "scenes_count", "scenes_duration", + "scenes_size", "updated_at", } @@ -754,6 +818,17 @@ func (qb *TagStore) sortByScenesDuration(direction string) string { ) %s`, scenesTagsTable, sceneTable, sceneTable, scenesTagsTable, sceneIDColumn, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, scenesFilesTable, scenesTagsTable, tagIDColumn, tagTable, getSortDirection(direction)) } +func (qb *TagStore) sortByScenesSize(direction string) string { + return fmt.Sprintf(` ORDER BY ( + SELECT COALESCE(SUM(%s.size), 0) + FROM %s + LEFT JOIN %s ON %s.id = %s.%s + LEFT JOIN %s ON %s.%s = %s.id + LEFT JOIN %s ON %s.id = %s.file_id + WHERE %s.%s = %s.id + ) %s`, fileTable, scenesTagsTable, sceneTable, sceneTable, scenesTagsTable, sceneIDColumn, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, fileTable, fileTable, scenesFilesTable, scenesTagsTable, tagIDColumn, tagTable, getSortDirection(direction)) +} + func (qb *TagStore) getDefaultTagSort() string { return getSort("name", "ASC", "tags") } @@ -782,6 +857,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte sortQuery += getCountSort(tagTable, scenesTagsTable, tagIDColumn, direction) case "scenes_duration": sortQuery += qb.sortByScenesDuration(direction) + case "scenes_size": + sortQuery += qb.sortByScenesSize(direction) case "scene_markers_count": sortQuery += fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM scene_markers_tags WHERE tags.id = scene_markers_tags.tag_id)+(SELECT COUNT(*) FROM scene_markers WHERE tags.id = scene_markers.primary_tag_id) %s", getSortDirection(direction)) case "images_count": diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index b3a7c1756..6cfe52006 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -161,6 +161,20 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { tagRepository.studios.innerJoin(f, "", "tags.id") }, }, + + &relatedFilterHandler{ + relatedIDCol: "markers_tags.marker_id", + relatedRepo: sceneMarkerRepository.repository, + relatedHandler: &sceneMarkerFilterHandler{tagFilter.MarkersFilter}, + joinFn: func(f *filterBuilder) { + f.addWith(`markers_tags AS ( + SELECT mt.scene_marker_id AS marker_id, mt.tag_id AS tag_id FROM scene_markers_tags mt + UNION + SELECT m.id, m.primary_tag_id FROM scene_markers m + )`) + f.addInnerJoin("markers_tags", "", "markers_tags.tag_id = tags.id") + }, + }, } } @@ -170,8 +184,8 @@ func (qb *tagFilterHandler) aliasCriterionHandler(alias *models.StringCriterionI primaryFK: tagIDColumn, joinTable: tagAliasesTable, stringColumn: tagAliasColumn, - addJoinTable: func(f *filterBuilder) { - tagRepository.aliases.join(f, "", "tags.id") + addJoinTable: func(f *filterBuilder, joinType joinType) { + tagRepository.aliases.join(f, joinType, "", "tags.id") }, } @@ -184,7 +198,19 @@ func (qb *tagFilterHandler) isMissingCriterionHandler(isMissing *string) criteri switch *isMissing { case "image": f.addWhere("tags.image_blob IS NULL") + case "aliases": + tagRepository.aliases.leftJoin(f, "", "tags.id") + f.addWhere("tag_aliases.alias IS NULL") + case "stash_id": + tagRepository.stashIDs.leftJoin(f, "tag_stash_ids", "tags.id") + f.addWhere("tag_stash_ids.tag_id IS NULL") default: + if err := validateIsMissing(*isMissing, []string{ + "description", + }); err != nil { + f.setError(err) + return + } f.addWhere("(tags." + *isMissing + " IS NULL OR TRIM(tags." + *isMissing + ") = '')") } } diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index b673de3f9..b7f61a158 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -100,6 +100,24 @@ func TestTagFindByName(t *testing.T) { }) } +func TestTagFindByAlias(t *testing.T) { + withTxn(func(ctx context.Context) error { + tqb := db.Tag + + alias := getTagStringValue(tagIdxWithScene, "Alias") + + tag, err := tqb.FindByAlias(ctx, alias, false) + + if err != nil { + t.Errorf("Error finding tags: %s", err.Error()) + } + + assert.Equal(t, tagIDs[tagIdxWithScene], tag.ID) + + return nil + }) +} + func TestTagQueryIgnoreAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { ignoreAutoTag := true @@ -1889,6 +1907,65 @@ func TestTagQueryCustomFields(t *testing.T) { } }) } + + // Test combining text search (findFilter.Q) with custom field filters. + // This verifies that positional args are bound in the correct order + // when JOINs (from custom fields) and WHERE (from text search) both + // have parameterized placeholders. + runWithRollbackTxn(t, "equals with text search", func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + tagName := getTagStringValue(tagIdxWithGallery, "Name") + q := tagName + findFilter := &models.FindFilterType{Q: &q} + + tagFilter := &models.TagFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getTagStringValue(tagIdxWithGallery, "custom")}, + }, + }, + } + + tags, _, err := db.Tag.Query(ctx, tagFilter, findFilter) + if err != nil { + t.Errorf("TagStore.Query() error = %v", err) + return + } + + ids := tagsToIDs(tags) + assert.Contains(ids, tagIDs[tagIdxWithGallery]) + assert.Len(tags, 1) + }) + + runWithRollbackTxn(t, "is_null with text search", func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + tagName := getTagStringValue(tagIdxWithGallery, "Name") + q := tagName + findFilter := &models.FindFilterType{Q: &q} + + tagFilter := &models.TagFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + } + + tags, _, err := db.Tag.Query(ctx, tagFilter, findFilter) + if err != nil { + t.Errorf("TagStore.Query() error = %v", err) + return + } + + ids := tagsToIDs(tags) + assert.Contains(ids, tagIDs[tagIdxWithGallery]) + assert.Len(tags, 1) + }) } // TODO Destroy diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index 29b702a7f..bc9a6ce89 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -128,8 +128,11 @@ func (t *StudioFragment) GetImages() []*ImageFragment { } type TagFragment struct { - Name string "json:\"name\" graphql:\"name\"" - ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" + Description *string "json:\"description,omitempty\" graphql:\"description\"" + Aliases []string "json:\"aliases\" graphql:\"aliases\"" + Category *TagFragment_Category "json:\"category,omitempty\" graphql:\"category\"" } func (t *TagFragment) GetName() string { @@ -144,6 +147,24 @@ func (t *TagFragment) GetID() string { } return t.ID } +func (t *TagFragment) GetDescription() *string { + if t == nil { + t = &TagFragment{} + } + return t.Description +} +func (t *TagFragment) GetAliases() []string { + if t == nil { + t = &TagFragment{} + } + return t.Aliases +} +func (t *TagFragment) GetCategory() *TagFragment_Category { + if t == nil { + t = &TagFragment{} + } + return t.Category +} type MeasurementsFragment struct { BandSize *int "json:\"band_size,omitempty\" graphql:\"band_size\"" @@ -516,6 +537,31 @@ func (t *StudioFragment_Parent) GetName() string { return t.Name } +type TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *TagFragment_Category) GetDescription() *string { + if t == nil { + t = &TagFragment_Category{} + } + return t.Description +} +func (t *TagFragment_Category) GetID() string { + if t == nil { + t = &TagFragment_Category{} + } + return t.ID +} +func (t *TagFragment_Category) GetName() string { + if t == nil { + t = &TagFragment_Category{} + } + return t.Name +} + type SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -534,6 +580,31 @@ func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string { return t.Name } +type SceneFragment_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *SceneFragment_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &SceneFragment_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *SceneFragment_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &SceneFragment_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *SceneFragment_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &SceneFragment_Tags_TagFragment_Category{} + } + return t.Name +} + type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -552,6 +623,31 @@ func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragme return t.Name } +type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Tags_TagFragment_Category{} + } + return t.Name +} + type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -570,6 +666,31 @@ func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) Get return t.Name } +type SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &SearchScene_SearchScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.Name +} + type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -588,6 +709,31 @@ func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) Get return t.Name } +type FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &FindSceneByID_FindScene_SceneFragment_Tags_TagFragment_Category{} + } + return t.Name +} + type FindStudio_FindStudio_StudioFragment_Parent struct { ID string "json:\"id\" graphql:\"id\"" Name string "json:\"name\" graphql:\"name\"" @@ -606,6 +752,56 @@ func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string { return t.Name } +type FindTag_FindTag_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *FindTag_FindTag_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &FindTag_FindTag_TagFragment_Category{} + } + return t.Description +} +func (t *FindTag_FindTag_TagFragment_Category) GetID() string { + if t == nil { + t = &FindTag_FindTag_TagFragment_Category{} + } + return t.ID +} +func (t *FindTag_FindTag_TagFragment_Category) GetName() string { + if t == nil { + t = &FindTag_FindTag_TagFragment_Category{} + } + return t.Name +} + +type QueryTags_QueryTags_Tags_TagFragment_Category struct { + Description *string "json:\"description,omitempty\" graphql:\"description\"" + ID string "json:\"id\" graphql:\"id\"" + Name string "json:\"name\" graphql:\"name\"" +} + +func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetDescription() *string { + if t == nil { + t = &QueryTags_QueryTags_Tags_TagFragment_Category{} + } + return t.Description +} +func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetID() string { + if t == nil { + t = &QueryTags_QueryTags_Tags_TagFragment_Category{} + } + return t.ID +} +func (t *QueryTags_QueryTags_Tags_TagFragment_Category) GetName() string { + if t == nil { + t = &QueryTags_QueryTags_Tags_TagFragment_Category{} + } + return t.Name +} + type QueryTags_QueryTags struct { Count int "json:\"count\" graphql:\"count\"" Tags []*TagFragment "json:\"tags\" graphql:\"tags\"" @@ -849,6 +1045,13 @@ fragment StudioFragment on Studio { fragment TagFragment on Tag { name id + description + aliases + category { + id + name + description + } } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -985,6 +1188,13 @@ fragment StudioFragment on Studio { fragment TagFragment on Tag { name id + description + aliases + category { + id + name + description + } } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -1279,6 +1489,13 @@ fragment StudioFragment on Studio { fragment TagFragment on Tag { name id + description + aliases + category { + id + name + description + } } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -1413,6 +1630,13 @@ const FindTagDocument = `query FindTag ($id: ID, $name: String) { fragment TagFragment on Tag { name id + description + aliases + category { + id + name + description + } } ` @@ -1445,6 +1669,13 @@ const QueryTagsDocument = `query QueryTags ($input: TagQueryInput!) { fragment TagFragment on Tag { name id + description + aliases + category { + id + name + description + } } ` diff --git a/pkg/stashbox/performer.go b/pkg/stashbox/performer.go index 231b936d6..5b25b4a59 100644 --- a/pkg/stashbox/performer.go +++ b/pkg/stashbox/performer.go @@ -232,21 +232,21 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc } if p.CareerStartYear != nil { - cs := *p.CareerStartYear + cs := strconv.Itoa(*p.CareerStartYear) sp.CareerStart = &cs } if p.CareerEndYear != nil { - ce := *p.CareerEndYear + ce := strconv.Itoa(*p.CareerEndYear) sp.CareerEnd = &ce } if p.BirthDate != nil { - sp.Birthdate = padFuzzyDate(p.BirthDate) + sp.Birthdate = p.BirthDate } if p.DeathDate != nil { - sp.DeathDate = padFuzzyDate(p.DeathDate) + sp.DeathDate = p.DeathDate } if p.Gender != nil { @@ -290,23 +290,6 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc return sp } -func padFuzzyDate(date *string) *string { - if date == nil { - return nil - } - - var paddedDate string - switch len(*date) { - case 10: - paddedDate = *date - case 7: - paddedDate = fmt.Sprintf("%s-01", *date) - case 4: - paddedDate = fmt.Sprintf("%s-01-01", *date) - } - return &paddedDate -} - // FindPerformerByID queries stash-box for a performer by ID. func (c Client) FindPerformerByID(ctx context.Context, id string) (*models.ScrapedPerformer, error) { performer, err := c.client.FindPerformerByID(ctx, id) @@ -399,10 +382,12 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf draft.Aliases = &aliases } if performer.CareerStart != nil { - draft.CareerStartYear = performer.CareerStart + year := performer.CareerStart.Year() + draft.CareerStartYear = &year } if performer.CareerEnd != nil { - draft.CareerEndYear = performer.CareerEnd + year := performer.CareerEnd.Year() + draft.CareerEndYear = &year } if len(performer.URLs.List()) > 0 { diff --git a/pkg/stashbox/tag.go b/pkg/stashbox/tag.go index df2ecbcc0..45bcf96c4 100644 --- a/pkg/stashbox/tag.go +++ b/pkg/stashbox/tag.go @@ -31,10 +31,8 @@ func (c Client) findTagByID(ctx context.Context, id string) ([]*models.ScrapedTa return nil, nil } - return []*models.ScrapedTag{{ - Name: tag.FindTag.Name, - RemoteSiteID: &tag.FindTag.ID, - }}, nil + ret := tagFragmentToScrapedTag(*tag.FindTag) + return []*models.ScrapedTag{ret}, nil } func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.ScrapedTag, error) { @@ -57,11 +55,29 @@ func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.Scr var ret []*models.ScrapedTag for _, t := range result.QueryTags.Tags { - ret = append(ret, &models.ScrapedTag{ - Name: t.Name, - RemoteSiteID: &t.ID, - }) + ret = append(ret, tagFragmentToScrapedTag(*t)) } return ret, nil } + +func tagFragmentToScrapedTag(t graphql.TagFragment) *models.ScrapedTag { + ret := &models.ScrapedTag{ + Name: t.Name, + Description: t.Description, + RemoteSiteID: &t.ID, + } + + if len(t.Aliases) > 0 { + ret.AliasList = t.Aliases + } + + if t.Category != nil { + ret.Parent = &models.ScrapedTag{ + Name: t.Category.Name, + Description: t.Category.Description, + } + } + + return ret +} diff --git a/pkg/tag/query.go b/pkg/tag/query.go index 76567434d..eb8b6acf0 100644 --- a/pkg/tag/query.go +++ b/pkg/tag/query.go @@ -6,50 +6,24 @@ import ( "github.com/stashapp/stash/pkg/models" ) -func ByName(ctx context.Context, qb models.TagQueryer, name string) (*models.Tag, error) { - f := &models.TagFilterType{ - Name: &models.StringCriterionInput{ - Value: name, - Modifier: models.CriterionModifierEquals, - }, - } - - pp := 1 - ret, count, err := qb.Query(ctx, f, &models.FindFilterType{ - PerPage: &pp, - }) +func ByName(ctx context.Context, qb models.TagNameFinder, name string) (*models.Tag, error) { + const nocase = true + ret, err := qb.FindByName(ctx, name, nocase) if err != nil { return nil, err } - if count > 0 { - return ret[0], nil - } - - return nil, nil + return ret, nil } -func ByAlias(ctx context.Context, qb models.TagQueryer, alias string) (*models.Tag, error) { - f := &models.TagFilterType{ - Aliases: &models.StringCriterionInput{ - Value: alias, - Modifier: models.CriterionModifierEquals, - }, - } - - pp := 1 - ret, count, err := qb.Query(ctx, f, &models.FindFilterType{ - PerPage: &pp, - }) +func ByAlias(ctx context.Context, qb models.TagNameFinder, alias string) (*models.Tag, error) { + const nocase = true + ret, err := qb.FindByAlias(ctx, alias, nocase) if err != nil { return nil, err } - if count > 0 { - return ret[0], nil - } - - return nil, nil + return ret, nil } diff --git a/pkg/tag/update.go b/pkg/tag/update.go index 4a3a2901a..264420af9 100644 --- a/pkg/tag/update.go +++ b/pkg/tag/update.go @@ -42,7 +42,7 @@ func (e *InvalidTagHierarchyError) Error() string { // EnsureTagNameUnique returns an error if the tag name provided // is used as a name or alias of another existing tag. -func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.TagQueryer) error { +func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.TagNameFinder) error { // ensure name is unique sameNameTag, err := ByName(ctx, qb, name) if err != nil { @@ -71,7 +71,7 @@ func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.Tag return nil } -func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb models.TagQueryer) error { +func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb models.TagNameFinder) error { for _, a := range aliases { if err := EnsureTagNameUnique(ctx, id, a, qb); err != nil { return err diff --git a/pkg/tag/validate_test.go b/pkg/tag/validate_test.go index 539086a6d..439acc082 100644 --- a/pkg/tag/validate_test.go +++ b/pkg/tag/validate_test.go @@ -1,71 +1,60 @@ package tag import ( + "context" "testing" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" ) -func nameFilter(n string) *models.TagFilterType { - return &models.TagFilterType{ - Name: &models.StringCriterionInput{ - Value: n, - Modifier: models.CriterionModifierEquals, - }, - } +type tagNameFinderMock struct { + existingTags []*models.Tag } -func aliasFilter(n string) *models.TagFilterType { - return &models.TagFilterType{ - Aliases: &models.StringCriterionInput{ - Value: n, - Modifier: models.CriterionModifierEquals, - }, +func (m tagNameFinderMock) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) { + for _, n := range m.existingTags { + if n.Name == name { + return n, nil + } } + + return nil, nil +} + +func (m tagNameFinderMock) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Tag, error) { + panic("not implemented") +} + +func (m tagNameFinderMock) FindByAlias(ctx context.Context, alias string, nocase bool) (*models.Tag, error) { + for _, n := range m.existingTags { + for _, a := range n.Aliases.List() { + if a == alias { + return n, nil + } + } + } + + return nil, nil } func TestEnsureAliasesUnique(t *testing.T) { - db := mocks.NewDatabase() - const ( name1 = "name 1" name2 = "name 2" + name3 = "name 3" alias1 = "alias 1" newAlias = "new alias" ) - existing2 := models.Tag{ - ID: 2, - Name: name2, + tagMock := tagNameFinderMock{ + existingTags: []*models.Tag{ + {Name: name1, Aliases: models.NewRelatedStrings([]string{})}, + {Name: name2, Aliases: models.NewRelatedStrings([]string{})}, + {Name: name3, Aliases: models.NewRelatedStrings([]string{newAlias})}, + }, } - pp := 1 - findFilter := &models.FindFilterType{ - PerPage: &pp, - } - - // name1 matches existing1 name - ok - // EnsureAliasesUnique calls EnsureTagNameUnique. - // EnsureTagNameUnique calls ByName then ByAlias. - - // Case 1: valid alias - // ByName "alias 1" -> nil - // ByAlias "alias 1" -> nil - db.Tag.On("Query", testCtx, nameFilter(alias1), findFilter).Return(nil, 0, nil) - db.Tag.On("Query", testCtx, aliasFilter(alias1), findFilter).Return(nil, 0, nil) - - // Case 2: alias duplicates existing2 name - // ByName "name 2" -> existing2 - db.Tag.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Tag{&existing2}, 1, nil) - - // Case 3: alias duplicates existing2 alias - // ByName "new alias" -> nil - // ByAlias "new alias" -> existing2 - db.Tag.On("Query", testCtx, nameFilter(newAlias), findFilter).Return(nil, 0, nil) - db.Tag.On("Query", testCtx, aliasFilter(newAlias), findFilter).Return([]*models.Tag{&existing2}, 1, nil) - tests := []struct { tName string id int @@ -74,12 +63,12 @@ func TestEnsureAliasesUnique(t *testing.T) { }{ {"valid alias", 1, []string{alias1}, nil}, {"alias duplicates other name", 1, []string{name2}, &NameExistsError{name2}}, - {"alias duplicates other alias", 1, []string{newAlias}, &NameUsedByAliasError{newAlias, existing2.Name}}, + {"alias duplicates other alias", 1, []string{newAlias}, &NameUsedByAliasError{newAlias, tagMock.existingTags[2].Name}}, } for _, tt := range tests { t.Run(tt.tName, func(t *testing.T) { - got := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, db.Tag) + got := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, tagMock) assert.Equal(t, tt.want, got) }) } diff --git a/pkg/utils/date.go b/pkg/utils/date.go index 4b805862a..de5566e4d 100644 --- a/pkg/utils/date.go +++ b/pkg/utils/date.go @@ -2,8 +2,6 @@ package utils import ( "fmt" - "strconv" - "strings" "time" ) @@ -27,80 +25,3 @@ func ParseDateStringAsTime(dateString string) (time.Time, error) { return time.Time{}, fmt.Errorf("ParseDateStringAsTime failed: dateString <%s>", dateString) } - -// ParseYearRangeString parses a year range string into start and end year integers. -// Supported formats: "YYYY", "YYYY - YYYY", "YYYY-YYYY", "YYYY -", "- YYYY", "YYYY-present". -// Returns nil for start/end if not present in the string. -func ParseYearRangeString(s string) (start *int, end *int, err error) { - s = strings.TrimSpace(s) - if s == "" { - return nil, nil, fmt.Errorf("empty year range string") - } - - // normalize "present" to empty end - lower := strings.ToLower(s) - lower = strings.ReplaceAll(lower, "present", "") - - // split on "-" if it contains one - var parts []string - if strings.Contains(lower, "-") { - parts = strings.SplitN(lower, "-", 2) - } else { - // single value, treat as start year - year, err := parseYear(lower) - if err != nil { - return nil, nil, fmt.Errorf("invalid year range %q: %w", s, err) - } - return &year, nil, nil - } - - startStr := strings.TrimSpace(parts[0]) - endStr := strings.TrimSpace(parts[1]) - - if startStr != "" { - y, err := parseYear(startStr) - if err != nil { - return nil, nil, fmt.Errorf("invalid start year in %q: %w", s, err) - } - start = &y - } - - if endStr != "" { - y, err := parseYear(endStr) - if err != nil { - return nil, nil, fmt.Errorf("invalid end year in %q: %w", s, err) - } - end = &y - } - - if start == nil && end == nil { - return nil, nil, fmt.Errorf("could not parse year range %q", s) - } - - return start, end, nil -} - -func parseYear(s string) (int, error) { - s = strings.TrimSpace(s) - year, err := strconv.Atoi(s) - if err != nil { - return 0, fmt.Errorf("invalid year %q: %w", s, err) - } - if year < 1900 || year > 2200 { - return 0, fmt.Errorf("year %d out of reasonable range", year) - } - return year, nil -} - -func FormatYearRange(start *int, end *int) string { - switch { - case start == nil && end == nil: - return "" - case end == nil: - return fmt.Sprintf("%d -", *start) - case start == nil: - return fmt.Sprintf("- %d", *end) - default: - return fmt.Sprintf("%d - %d", *start, *end) - } -} diff --git a/pkg/utils/date_test.go b/pkg/utils/date_test.go index a9e174094..ae077c21e 100644 --- a/pkg/utils/date_test.go +++ b/pkg/utils/date_test.go @@ -2,8 +2,6 @@ package utils import ( "testing" - - "github.com/stretchr/testify/assert" ) func TestParseDateStringAsTime(t *testing.T) { @@ -43,66 +41,3 @@ func TestParseDateStringAsTime(t *testing.T) { }) } } - -func TestParseYearRangeString(t *testing.T) { - intPtr := func(v int) *int { return &v } - - tests := []struct { - name string - input string - wantStart *int - wantEnd *int - wantErr bool - }{ - {"single year", "2005", intPtr(2005), nil, false}, - {"year range with spaces", "2005 - 2010", intPtr(2005), intPtr(2010), false}, - {"year range no spaces", "2005-2010", intPtr(2005), intPtr(2010), false}, - {"year dash open", "2005 -", intPtr(2005), nil, false}, - {"year dash open no space", "2005-", intPtr(2005), nil, false}, - {"dash year", "- 2010", nil, intPtr(2010), false}, - {"year present", "2005-present", intPtr(2005), nil, false}, - {"year Present caps", "2005 - Present", intPtr(2005), nil, false}, - {"whitespace padding", " 2005 - 2010 ", intPtr(2005), intPtr(2010), false}, - {"empty string", "", nil, nil, true}, - {"garbage", "not a year", nil, nil, true}, - {"partial garbage start", "abc - 2010", nil, nil, true}, - {"partial garbage end", "2005 - abc", nil, nil, true}, - {"year out of range", "1800", nil, nil, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - start, end, err := ParseYearRangeString(tt.input) - if tt.wantErr { - assert.Error(t, err) - return - } - assert.NoError(t, err) - assert.Equal(t, tt.wantStart, start) - assert.Equal(t, tt.wantEnd, end) - }) - } -} - -func TestFormatYearRange(t *testing.T) { - intPtr := func(v int) *int { return &v } - - tests := []struct { - name string - start *int - end *int - want string - }{ - {"both nil", nil, nil, ""}, - {"only start", intPtr(2005), nil, "2005 -"}, - {"only end", nil, intPtr(2010), "- 2010"}, - {"start and end", intPtr(2005), intPtr(2010), "2005 - 2010"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := FormatYearRange(tt.start, tt.end) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/pkg/utils/mutex.go b/pkg/utils/mutex.go index 212200214..47439e32b 100644 --- a/pkg/utils/mutex.go +++ b/pkg/utils/mutex.go @@ -1,5 +1,7 @@ package utils +import "sync" + // MutexManager manages access to mutexes using a mutex type and key. type MutexManager struct { mapChan chan map[string]<-chan struct{} @@ -62,3 +64,26 @@ func (csm *MutexManager) Claim(mutexType string, key string, done <-chan struct{ csm.mapChan <- m }() } + +type MutexField[T any] struct { + mutex sync.RWMutex + value T +} + +func (mf *MutexField[T]) Get() T { + mf.mutex.RLock() + defer mf.mutex.RUnlock() + return mf.value +} + +func (mf *MutexField[T]) Set(value T) { + mf.mutex.Lock() + defer mf.mutex.Unlock() + mf.value = value +} + +func (mf *MutexField[T]) SetFunc(f func(T) T) { + mf.mutex.Lock() + defer mf.mutex.Unlock() + mf.value = f(mf.value) +} diff --git a/ui/v2.5/graphql/data/file.graphql b/ui/v2.5/graphql/data/file.graphql index 52a4c50f8..7386adb81 100644 --- a/ui/v2.5/graphql/data/file.graphql +++ b/ui/v2.5/graphql/data/file.graphql @@ -1,5 +1,6 @@ fragment FolderData on Folder { id + basename path } @@ -86,3 +87,17 @@ fragment VisualFileData on VisualFile { } } } + +fragment SelectFolderData on Folder { + id + path + basename +} + +fragment RecursiveFolderData on Folder { + ...SelectFolderData + + parent_folders { + ...SelectFolderData + } +} diff --git a/ui/v2.5/graphql/data/gallery.graphql b/ui/v2.5/graphql/data/gallery.graphql index 89f3ed44c..349a52ad7 100644 --- a/ui/v2.5/graphql/data/gallery.graphql +++ b/ui/v2.5/graphql/data/gallery.graphql @@ -39,6 +39,8 @@ fragment GalleryData on Gallery { scenes { ...SlimSceneData } + + custom_fields } fragment SelectGalleryData on Gallery { diff --git a/ui/v2.5/graphql/data/group.graphql b/ui/v2.5/graphql/data/group.graphql index 440c420da..a9968bbae 100644 --- a/ui/v2.5/graphql/data/group.graphql +++ b/ui/v2.5/graphql/data/group.graphql @@ -39,6 +39,8 @@ fragment GroupData on Group { id title } + + custom_fields } # Lightweight fragment for list views - excludes expensive recursive counts diff --git a/ui/v2.5/graphql/data/image.graphql b/ui/v2.5/graphql/data/image.graphql index 52163b007..63ce5b458 100644 --- a/ui/v2.5/graphql/data/image.graphql +++ b/ui/v2.5/graphql/data/image.graphql @@ -37,4 +37,6 @@ fragment ImageData on Image { visual_files { ...VisualFileData } + + custom_fields } diff --git a/ui/v2.5/graphql/data/scene.graphql b/ui/v2.5/graphql/data/scene.graphql index e4a6e5cc6..b7378c1da 100644 --- a/ui/v2.5/graphql/data/scene.graphql +++ b/ui/v2.5/graphql/data/scene.graphql @@ -79,6 +79,8 @@ fragment SceneData on Scene { mime_type label } + + custom_fields } fragment SelectSceneData on Scene { diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index e58c21a20..0dae3c2d5 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -160,6 +160,13 @@ fragment ScrapedSceneStudioData on ScrapedStudio { fragment ScrapedSceneTagData on ScrapedTag { stored_id name + description + alias_list + parent { + stored_id + name + description + } remote_site_id } diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index 8347b4739..0e23a885e 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -41,6 +41,7 @@ fragment StudioData on Studio { ...SlimTagData } o_counter + custom_fields } fragment SelectStudioData on Studio { diff --git a/ui/v2.5/graphql/mutations/file.graphql b/ui/v2.5/graphql/mutations/file.graphql index 254a55126..fe920d308 100644 --- a/ui/v2.5/graphql/mutations/file.graphql +++ b/ui/v2.5/graphql/mutations/file.graphql @@ -1,3 +1,11 @@ mutation DeleteFiles($ids: [ID!]!) { deleteFiles(ids: $ids) } + +mutation RevealFileInFileManager($id: ID!) { + revealFileInFileManager(id: $id) +} + +mutation RevealFolderInFileManager($id: ID!) { + revealFolderInFileManager(id: $id) +} diff --git a/ui/v2.5/graphql/mutations/stash-box.graphql b/ui/v2.5/graphql/mutations/stash-box.graphql index 596dc4302..de5f5136c 100644 --- a/ui/v2.5/graphql/mutations/stash-box.graphql +++ b/ui/v2.5/graphql/mutations/stash-box.graphql @@ -12,6 +12,10 @@ mutation StashBoxBatchStudioTag($input: StashBoxBatchTagInput!) { stashBoxBatchStudioTag(input: $input) } +mutation StashBoxBatchTagTag($input: StashBoxBatchTagInput!) { + stashBoxBatchTagTag(input: $input) +} + mutation SubmitStashBoxSceneDraft($input: StashBoxDraftSubmissionInput!) { submitStashBoxSceneDraft(input: $input) } diff --git a/ui/v2.5/graphql/queries/folder.graphql b/ui/v2.5/graphql/queries/folder.graphql new file mode 100644 index 000000000..b1119cd61 --- /dev/null +++ b/ui/v2.5/graphql/queries/folder.graphql @@ -0,0 +1,48 @@ +query FindRootFoldersForSelect($zip_file_filter: MultiCriterionInput) { + findFolders( + filter: { per_page: -1, sort: "path", direction: ASC } + folder_filter: { + parent_folder: { modifier: IS_NULL } + zip_file: $zip_file_filter + } + ) { + count + folders { + ...SelectFolderData + } + } +} + +query FindFoldersForQuery( + $filter: FindFilterType + $folder_filter: FolderFilterType + $ids: [ID!] +) { + findFolders(filter: $filter, folder_filter: $folder_filter, ids: $ids) { + count + folders { + ...RecursiveFolderData + } + } +} + +query FindFolderHierarchyForIDs($ids: [ID!]!) { + findFolders(ids: $ids) { + count + folders { + ...SelectFolderData + + parent_folders { + ...SelectFolderData + # the parent folders will be expanded, so we need the child folders + sub_folders { + ...SelectFolderData + # get zip file so we can filter out zip folders if needed + zip_file { + id + } + } + } + } + } +} diff --git a/ui/v2.5/graphql/queries/scene.graphql b/ui/v2.5/graphql/queries/scene.graphql index d6a3afd47..0e1a9fa11 100644 --- a/ui/v2.5/graphql/queries/scene.graphql +++ b/ui/v2.5/graphql/queries/scene.graphql @@ -40,6 +40,14 @@ query FindScene($id: ID!, $checksum: String) { } } +query FindFullScenes($ids: [Int!]) { + findScenes(scene_ids: $ids) { + scenes { + ...SceneData + } + } +} + query FindSceneMarkerTags($id: ID!) { sceneMarkerTags(scene_id: $id) { tag { diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index e024a0053..be9b9dc10 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -1,8 +1,11 @@ { "name": "stash", "private": true, - "homepage": "./", "type": "module", + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", + "engines": { + "node": ">= 20" + }, "scripts": { "start": "vite", "build": "vite build", @@ -22,39 +25,39 @@ }, "dependencies": { "@ant-design/react-slick": "^1.0.0", - "@apollo/client": "^3.8.10", - "@formatjs/intl-getcanonicallocales": "^2.0.5", - "@formatjs/intl-locale": "^3.0.11", - "@formatjs/intl-numberformat": "^8.3.3", - "@formatjs/intl-pluralrules": "^5.1.8", - "@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", + "@apollo/client": "3.14", + "@blaineam/videojs-vr": "^3.1.1", + "@formatjs/intl-getcanonicallocales": "^3.2.2", + "@formatjs/intl-locale": "^5.3.1", + "@formatjs/intl-numberformat": "^8.15.6", + "@formatjs/intl-pluralrules": "^6.3.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": "^0.2.6", "@react-hook/resize-observer": "^1.2.6", - "@silvermine/videojs-airplay": "^1.2.0", - "@silvermine/videojs-chromecast": "^1.4.1", + "@silvermine/videojs-airplay": "^1.3.0", + "@silvermine/videojs-chromecast": "^1.5.0", "@types/react-router-dom": "^5.3.3", - "apollo-upload-client": "^18.0.1", + "apollo-upload-client": "18", "base64-blob": "^1.4.1", "bootstrap": "^4.6.2", - "classnames": "^2.3.2", + "classnames": "^2.5.1", "crypto-js": "^4.2.0", "event-target-polyfill": "^0.0.4", - "flag-icons": "^6.6.6", + "flag-icons": "^7.5.0", "flexbin": "^0.2.0", - "formik": "^2.4.5", + "formik": "^2.4.9", "graphql": "^16.8.1", "graphql-tag": "^2.12.6", "graphql-ws": "^5.14.3", - "i18n-iso-countries": "^7.5.0", + "i18n-iso-countries": "^7.14.0", "localforage": "^1.10.0", - "lodash-es": "^4.17.23", + "lodash-es": "^4.18.1", "moment": "^2.30.1", "mousetrap": "^1.6.5", "mousetrap-pause": "^1.0.0", - "normalize-url": "^4.5.1", "react": "^17.0.2", "react-bootstrap": "^1.6.6", "react-datepicker": "^4.10.0", @@ -70,21 +73,19 @@ "remark-gfm": "^1.0.0", "resize-observer-polyfill": "^1.5.1", "slick-carousel": "^1.8.1", - "string.prototype.replaceall": "^1.0.7", - "thehandy": "^1.0.3", - "ua-parser-js": "^1.0.34", - "universal-cookie": "^4.0.4", - "video.js": "^7.21.3", + "thehandy": "^1.1.0", + "ua-parser-js": "^2.0.9", + "universal-cookie": "^8.1.0", + "video.js": "^7.21.7", "videojs-abloop": "^1.2.0", "videojs-contrib-dash": "^5.1.1", "videojs-mobile-ui": "^0.8.0", "videojs-seek-buttons": "^3.0.1", - "videojs-vr": "1.8.0", - "videojs-vtt.js": "^0.15.4", - "yup": "^1.3.2" + "videojs-vtt.js": "^0.15.5", + "yup": "^1.7.1" }, "devDependencies": { - "@babel/core": "^7.20.12", + "@babel/core": "^7.29.0", "@graphql-codegen/cli": "^5.0.0", "@graphql-codegen/time": "^5.0.0", "@graphql-codegen/typescript": "^4.0.1", @@ -92,45 +93,44 @@ "@graphql-codegen/typescript-react-apollo": "^4.1.0", "@types/apollo-upload-client": "^18.0.0", "@types/crypto-js": "^4.2.2", - "@types/dom-screen-wake-lock": "^1.0.3", - "@types/lodash-es": "^4.17.6", - "@types/mousetrap": "^1.6.11", - "@types/node": "^18.13.0", + "@types/lodash-es": "^4.17.12", + "@types/mousetrap": "^1.6.15", + "@types/node": "^20.19.37", "@types/react": "^17.0.53", "@types/react-datepicker": "^4.10.0", "@types/react-dom": "^17.0.19", "@types/react-helmet": "^6.1.6", "@types/react-router-bootstrap": "^0.24.5", "@types/react-router-hash-link": "^2.4.5", - "@types/three": "^0.154.0", - "@types/ua-parser-js": "^0.7.36", - "@types/video.js": "^7.3.51", - "@types/videojs-mobile-ui": "^0.8.0", - "@types/videojs-seek-buttons": "^2.1.0", - "@typescript-eslint/eslint-plugin": "^5.52.0", - "@typescript-eslint/parser": "^5.52.0", - "@vitejs/plugin-legacy": "^5.4.3", - "@vitejs/plugin-react": "^5.1.0", - "eslint": "^8.34.0", + "@types/three": "^0.183.1", + "@types/ua-parser-js": "^0.7.39", + "@types/video.js": "^7.3.58", + "@types/videojs-mobile-ui": "^0.8.3", + "@types/videojs-seek-buttons": "^2.1.3", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "@vitejs/plugin-react": "^5.2.0", + "eslint": "^8.57.1", "eslint-config-airbnb": "^19.0.4", - "eslint-config-airbnb-typescript": "^17.0.0", - "eslint-config-prettier": "^8.6.0", - "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-react": "^7.32.2", - "eslint-plugin-react-hooks": "^4.6.0", + "eslint-config-airbnb-typescript": "^18.0.0", + "eslint-config-prettier": "^8.10.2", + "eslint-plugin-deprecation": "^3.0.0", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^4.6.2", "extract-react-intl-messages": "^4.1.1", - "postcss": "^8.4.31", - "postcss-scss": "^4.0.6", - "prettier": "^2.8.4", - "sass": "^1.58.1", - "stylelint": "^15.10.1", - "stylelint-order": "^6.0.2", - "terser": "^5.9.0", - "ts-node": "^10.9.1", - "typescript": "~4.8.4", - "vite": "^5.4.21", + "postcss": "^8.5.8", + "postcss-scss": "^4.0.9", + "prettier": "^2.8.8", + "sass": "^1.98.0", + "stylelint": "^17.6.0", + "stylelint-order": "^8.1.1", + "terser": "^5.46.1", + "ts-node": "~10.9.2", + "typescript": "~5.9.3", + "vite": "^7.3.2", "vite-plugin-compression": "^0.5.1", - "vite-tsconfig-paths": "^4.0.5" + "vite-tsconfig-paths": "^6.1.1" } } diff --git a/ui/v2.5/pnpm-lock.yaml b/ui/v2.5/pnpm-lock.yaml index 02033c41f..887121082 100644 --- a/ui/v2.5/pnpm-lock.yaml +++ b/ui/v2.5/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + yaml@1.10.2: ~1.10.3 + brace-expansion@1.1.12: ~1.1.13 + '@xmldom/xmldom@0.8.12': ~0.8.13 + importers: .: @@ -12,49 +17,52 @@ importers: specifier: ^1.0.0 version: 1.1.2(react@17.0.2) '@apollo/client': - specifier: ^3.8.10 + specifier: '3.14' version: 3.14.0(@types/react@17.0.89)(graphql-ws@5.16.2(graphql@16.11.0))(graphql@16.11.0)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@blaineam/videojs-vr': + specifier: ^3.1.1 + version: 3.1.1 '@formatjs/intl-getcanonicallocales': - specifier: ^2.0.5 - version: 2.5.6 + specifier: ^3.2.2 + version: 3.2.2 '@formatjs/intl-locale': - specifier: ^3.0.11 - version: 3.4.6 + specifier: ^5.3.1 + version: 5.3.1 '@formatjs/intl-numberformat': - specifier: ^8.3.3 + specifier: ^8.15.6 version: 8.15.6 '@formatjs/intl-pluralrules': - specifier: ^5.1.8 - version: 5.4.6 + specifier: ^6.3.1 + version: 6.3.1 '@fortawesome/fontawesome-svg-core': - specifier: ^7.1.0 - version: 7.1.0 + specifier: ^7.2.0 + version: 7.2.0 '@fortawesome/free-brands-svg-icons': - specifier: ^7.1.0 - version: 7.1.0 + specifier: ^7.2.0 + version: 7.2.0 '@fortawesome/free-regular-svg-icons': - specifier: ^7.1.0 - version: 7.1.0 + specifier: ^7.2.0 + version: 7.2.0 '@fortawesome/free-solid-svg-icons': - specifier: ^7.1.0 - version: 7.1.0 + specifier: ^7.2.0 + version: 7.2.0 '@fortawesome/react-fontawesome': specifier: ^0.2.6 - version: 0.2.6(@fortawesome/fontawesome-svg-core@7.1.0)(react@17.0.2) + version: 0.2.6(@fortawesome/fontawesome-svg-core@7.2.0)(react@17.0.2) '@react-hook/resize-observer': specifier: ^1.2.6 version: 1.2.6(react@17.0.2) '@silvermine/videojs-airplay': - specifier: ^1.2.0 + specifier: ^1.3.0 version: 1.3.0(video.js@7.21.7) '@silvermine/videojs-chromecast': - specifier: ^1.4.1 + specifier: ^1.5.0 version: 1.5.0(video.js@7.21.7) '@types/react-router-dom': specifier: ^5.3.3 version: 5.3.3 apollo-upload-client: - specifier: ^18.0.1 + specifier: '18' version: 18.0.1(@apollo/client@3.14.0(@types/react@17.0.89)(graphql-ws@5.16.2(graphql@16.11.0))(graphql@16.11.0)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(graphql@16.11.0) base64-blob: specifier: ^1.4.1 @@ -63,7 +71,7 @@ importers: specifier: ^4.6.2 version: 4.6.2(jquery@3.7.1)(popper.js@1.16.1) classnames: - specifier: ^2.3.2 + specifier: ^2.5.1 version: 2.5.1 crypto-js: specifier: ^4.2.0 @@ -72,14 +80,14 @@ importers: specifier: ^0.0.4 version: 0.0.4 flag-icons: - specifier: ^6.6.6 - version: 6.15.0 + specifier: ^7.5.0 + version: 7.5.0 flexbin: specifier: ^0.2.0 version: 0.2.0 formik: - specifier: ^2.4.5 - version: 2.4.6(@types/react@17.0.89)(react@17.0.2) + specifier: ^2.4.9 + version: 2.4.9(@types/react@17.0.89)(react@17.0.2) graphql: specifier: ^16.8.1 version: 16.11.0 @@ -90,14 +98,14 @@ importers: specifier: ^5.14.3 version: 5.16.2(graphql@16.11.0) i18n-iso-countries: - specifier: ^7.5.0 + specifier: ^7.14.0 version: 7.14.0 localforage: specifier: ^1.10.0 version: 1.10.0 lodash-es: - specifier: ^4.17.23 - version: 4.17.23 + specifier: ^4.18.1 + version: 4.18.1 moment: specifier: ^2.30.1 version: 2.30.1 @@ -107,9 +115,6 @@ importers: mousetrap-pause: specifier: ^1.0.0 version: 1.0.0 - normalize-url: - specifier: ^4.5.1 - version: 4.5.1 react: specifier: ^17.0.2 version: 17.0.2 @@ -127,7 +132,7 @@ importers: version: 6.1.0(react@17.0.2) react-intl: specifier: ^6.2.8 - version: 6.8.9(react@17.0.2)(typescript@4.8.4) + version: 6.8.9(react@17.0.2)(typescript@5.9.3) react-photo-gallery: specifier: ^8.0.0 version: 8.0.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2) @@ -155,20 +160,17 @@ importers: slick-carousel: specifier: ^1.8.1 version: 1.8.1(jquery@3.7.1) - string.prototype.replaceall: - specifier: ^1.0.7 - version: 1.0.11 thehandy: - specifier: ^1.0.3 + specifier: ^1.1.0 version: 1.1.0 ua-parser-js: - specifier: ^1.0.34 - version: 1.0.41 + specifier: ^2.0.9 + version: 2.0.9 universal-cookie: - specifier: ^4.0.4 - version: 4.0.4 + specifier: ^8.1.0 + version: 8.1.0 video.js: - specifier: ^7.21.3 + specifier: ^7.21.7 version: 7.21.7 videojs-abloop: specifier: ^1.2.0 @@ -182,22 +184,19 @@ importers: videojs-seek-buttons: specifier: ^3.0.1 version: 3.0.1(video.js@7.21.7) - videojs-vr: - specifier: 1.8.0 - version: 1.8.0 videojs-vtt.js: - specifier: ^0.15.4 + specifier: ^0.15.5 version: 0.15.5 yup: - specifier: ^1.3.2 + specifier: ^1.7.1 version: 1.7.1 devDependencies: '@babel/core': - specifier: ^7.20.12 - version: 7.28.4 + specifier: ^7.29.0 + version: 7.29.0 '@graphql-codegen/cli': specifier: ^5.0.0 - version: 5.0.7(@parcel/watcher@2.5.1)(@types/node@18.19.130)(graphql@16.11.0)(typescript@4.8.4) + version: 5.0.7(@parcel/watcher@2.5.1)(@types/node@20.19.37)(graphql@16.11.0)(typescript@5.9.3) '@graphql-codegen/time': specifier: ^5.0.0 version: 5.0.1(graphql@16.11.0) @@ -216,18 +215,15 @@ importers: '@types/crypto-js': specifier: ^4.2.2 version: 4.2.2 - '@types/dom-screen-wake-lock': - specifier: ^1.0.3 - version: 1.0.3 '@types/lodash-es': - specifier: ^4.17.6 + specifier: ^4.17.12 version: 4.17.12 '@types/mousetrap': - specifier: ^1.6.11 + specifier: ^1.6.15 version: 1.6.15 '@types/node': - specifier: ^18.13.0 - version: 18.19.130 + specifier: ^20.19.37 + version: 20.19.37 '@types/react': specifier: ^17.0.53 version: 17.0.89 @@ -247,95 +243,95 @@ importers: specifier: ^2.4.5 version: 2.4.9 '@types/three': - specifier: ^0.154.0 - version: 0.154.0 + specifier: ^0.183.1 + version: 0.183.1 '@types/ua-parser-js': - specifier: ^0.7.36 + specifier: ^0.7.39 version: 0.7.39 '@types/video.js': - specifier: ^7.3.51 + specifier: ^7.3.58 version: 7.3.58 '@types/videojs-mobile-ui': - specifier: ^0.8.0 + specifier: ^0.8.3 version: 0.8.3 '@types/videojs-seek-buttons': - specifier: ^2.1.0 + specifier: ^2.1.3 version: 2.1.3 '@typescript-eslint/eslint-plugin': - specifier: ^5.52.0 - version: 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1)(typescript@4.8.4) + specifier: ^7.18.0 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^5.52.0 - version: 5.62.0(eslint@8.57.1)(typescript@4.8.4) - '@vitejs/plugin-legacy': - specifier: ^5.4.3 - version: 5.4.3(terser@5.44.0)(vite@5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0)) + specifier: ^7.18.0 + version: 7.18.0(eslint@8.57.1)(typescript@5.9.3) '@vitejs/plugin-react': - specifier: ^5.1.0 - version: 5.1.0(vite@5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0)) + specifier: ^5.2.0 + version: 5.2.0(vite@7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.1)) eslint: - specifier: ^8.34.0 + specifier: ^8.57.1 version: 8.57.1 eslint-config-airbnb: specifier: ^19.0.4 - version: 19.0.4(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint@8.57.1) + version: 19.0.4(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint@8.57.1) eslint-config-airbnb-typescript: - specifier: ^17.0.0 - version: 17.1.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1)(typescript@4.8.4))(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1))(eslint@8.57.1) + specifier: ^18.0.0 + version: 18.0.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) eslint-config-prettier: - specifier: ^8.6.0 + specifier: ^8.10.2 version: 8.10.2(eslint@8.57.1) + eslint-plugin-deprecation: + specifier: ^3.0.0 + version: 3.0.0(eslint@8.57.1)(typescript@5.9.3) eslint-plugin-import: - specifier: ^2.27.5 - version: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1) + specifier: ^2.32.0 + version: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1) eslint-plugin-jsx-a11y: - specifier: ^6.7.1 + specifier: ^6.10.2 version: 6.10.2(eslint@8.57.1) eslint-plugin-react: - specifier: ^7.32.2 + specifier: ^7.37.5 version: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: - specifier: ^4.6.0 + specifier: ^4.6.2 version: 4.6.2(eslint@8.57.1) extract-react-intl-messages: specifier: ^4.1.1 version: 4.1.1 postcss: - specifier: ^8.4.31 - version: 8.5.6 + specifier: ^8.5.8 + version: 8.5.8 postcss-scss: - specifier: ^4.0.6 - version: 4.0.9(postcss@8.5.6) + specifier: ^4.0.9 + version: 4.0.9(postcss@8.5.8) prettier: - specifier: ^2.8.4 + specifier: ^2.8.8 version: 2.8.8 sass: - specifier: ^1.58.1 - version: 1.93.2 + specifier: ^1.98.0 + version: 1.98.0 stylelint: - specifier: ^15.10.1 - version: 15.11.0(typescript@4.8.4) + specifier: ^17.6.0 + version: 17.6.0(typescript@5.9.3) stylelint-order: - specifier: ^6.0.2 - version: 6.0.4(stylelint@15.11.0(typescript@4.8.4)) + specifier: ^8.1.1 + version: 8.1.1(stylelint@17.6.0(typescript@5.9.3)) terser: - specifier: ^5.9.0 - version: 5.44.0 + specifier: ^5.46.1 + version: 5.46.1 ts-node: - specifier: ^10.9.1 - version: 10.9.2(@types/node@18.19.130)(typescript@4.8.4) + specifier: ~10.9.2 + version: 10.9.2(@types/node@20.19.37)(typescript@5.9.3) typescript: - specifier: ~4.8.4 - version: 4.8.4 + specifier: ~5.9.3 + version: 5.9.3 vite: - specifier: ^5.4.21 - version: 5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0) + specifier: ^7.3.2 + version: 7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.1) vite-plugin-compression: specifier: ^0.5.1 - version: 0.5.1(vite@5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0)) + version: 0.5.1(vite@7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.1)) vite-tsconfig-paths: - specifier: ^4.0.5 - version: 4.3.2(typescript@4.8.4)(vite@5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0)) + specifier: ^6.1.1 + version: 6.1.1(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.1)) packages: @@ -374,28 +370,28 @@ packages: peerDependencies: graphql: '*' - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.28.4': - resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} - '@babel/core@7.28.4': - resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.3': - resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} '@babel/helper-create-class-features-plugin@7.28.3': @@ -404,17 +400,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-create-regexp-features-plugin@7.27.1': - resolution: {integrity: sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-define-polyfill-provider@0.6.5': - resolution: {integrity: sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} @@ -423,12 +408,12 @@ packages: resolution: {integrity: sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -437,16 +422,10 @@ packages: resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} - '@babel/helper-remap-async-to-generator@7.27.1': - resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-replace-supers@7.27.1': resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==} engines: {node: '>=6.9.0'} @@ -461,57 +440,23 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helper-wrap-function@7.28.3': - resolution: {integrity: sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==} + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.4': - resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.28.4': - resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.27.1': - resolution: {integrity: sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1': - resolution: {integrity: sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1': - resolution: {integrity: sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1': - resolution: {integrity: sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.13.0 - - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.3': - resolution: {integrity: sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/plugin-proposal-class-properties@7.18.6': resolution: {integrity: sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==} engines: {node: '>=6.9.0'} @@ -526,12 +471,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': - resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-class-properties@7.12.13': resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} peerDependencies: @@ -549,12 +488,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-jsx@7.27.1': resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} engines: {node: '>=6.9.0'} @@ -566,30 +499,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6': - resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/plugin-transform-arrow-functions@7.27.1': resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-async-generator-functions@7.28.0': - resolution: {integrity: sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-async-to-generator@7.27.1': - resolution: {integrity: sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-block-scoped-functions@7.27.1': resolution: {integrity: sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==} engines: {node: '>=6.9.0'} @@ -602,18 +517,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-class-properties@7.27.1': - resolution: {integrity: sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-class-static-block@7.28.3': - resolution: {integrity: sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.12.0 - '@babel/plugin-transform-classes@7.28.4': resolution: {integrity: sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==} engines: {node: '>=6.9.0'} @@ -632,48 +535,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-dotall-regex@7.27.1': - resolution: {integrity: sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-duplicate-keys@7.27.1': - resolution: {integrity: sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.27.1': - resolution: {integrity: sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-transform-dynamic-import@7.27.1': - resolution: {integrity: sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-explicit-resource-management@7.28.0': - resolution: {integrity: sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-exponentiation-operator@7.27.1': - resolution: {integrity: sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-export-namespace-from@7.27.1': - resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-flow-strip-types@7.27.1': resolution: {integrity: sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==} engines: {node: '>=6.9.0'} @@ -692,120 +553,36 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-json-strings@7.27.1': - resolution: {integrity: sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-literals@7.27.1': resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-logical-assignment-operators@7.27.1': - resolution: {integrity: sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-member-expression-literals@7.27.1': resolution: {integrity: sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-modules-amd@7.27.1': - resolution: {integrity: sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-modules-commonjs@7.27.1': resolution: {integrity: sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-modules-systemjs@7.27.1': - resolution: {integrity: sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-umd@7.27.1': - resolution: {integrity: sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-named-capturing-groups-regex@7.27.1': - resolution: {integrity: sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-transform-new-target@7.27.1': - resolution: {integrity: sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-nullish-coalescing-operator@7.27.1': - resolution: {integrity: sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-numeric-separator@7.27.1': - resolution: {integrity: sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-object-rest-spread@7.28.4': - resolution: {integrity: sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-object-super@7.27.1': resolution: {integrity: sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-optional-catch-binding@7.27.1': - resolution: {integrity: sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-optional-chaining@7.27.1': - resolution: {integrity: sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-parameters@7.27.7': resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-private-methods@7.27.1': - resolution: {integrity: sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-private-property-in-object@7.27.1': - resolution: {integrity: sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-property-literals@7.27.1': resolution: {integrity: sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==} engines: {node: '>=6.9.0'} @@ -836,24 +613,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-regenerator@7.28.4': - resolution: {integrity: sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-regexp-modifiers@7.27.1': - resolution: {integrity: sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/plugin-transform-reserved-words@7.27.1': - resolution: {integrity: sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-shorthand-properties@7.27.1': resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} engines: {node: '>=6.9.0'} @@ -866,101 +625,87 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-sticky-regex@7.27.1': - resolution: {integrity: sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-template-literals@7.27.1': resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-typeof-symbol@7.27.1': - resolution: {integrity: sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-unicode-escapes@7.27.1': - resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-unicode-property-regex@7.27.1': - resolution: {integrity: sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-unicode-regex@7.27.1': - resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-unicode-sets-regex@7.27.1': - resolution: {integrity: sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/preset-env@7.28.3': - resolution: {integrity: sha512-ROiDcM+GbYVPYBOeCR6uBXKkQpBExLl8k9HO1ygXEyds39j+vCCsjmj7S8GOniZQlEs81QlkdJZe76IpLSiqpg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/preset-modules@0.1.6-no-external-plugins': - resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} - peerDependencies: - '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 - - '@babel/runtime@7.28.4': - resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.4': - resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.4': - resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@blaineam/videojs-vr@3.1.1': + resolution: {integrity: sha512-3W69DgimXeU9RPa7l4x8+37GExWn/OlOVP+1QrrQ4R7yuqCuDD3slKPTNEFzITyHXN8XpVdEAwdH3oFeElYg+g==} + + '@cacheable/memory@2.0.8': + resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} + + '@cacheable/utils@2.4.1': + resolution: {integrity: sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@csstools/css-parser-algorithms@2.7.1': - resolution: {integrity: sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==} - engines: {node: ^14 || ^16 || >=18} + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-tokenizer': ^2.4.1 + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-tokenizer@2.4.1': - resolution: {integrity: sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==} - engines: {node: ^14 || ^16 || >=18} - - '@csstools/media-query-list-parser@2.1.13': - resolution: {integrity: sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==} - engines: {node: ^14 || ^16 || >=18} + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-parser-algorithms': ^2.7.1 - '@csstools/css-tokenizer': ^2.4.1 + '@csstools/css-tokenizer': ^4.0.0 - '@csstools/selector-specificity@3.1.1': - resolution: {integrity: sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==} - engines: {node: ^14 || ^16 || >=18} + '@csstools/css-syntax-patches-for-csstree@1.1.2': + resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} peerDependencies: - postcss-selector-parser: ^6.0.13 + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@csstools/media-query-list-parser@5.0.0': + resolution: {integrity: sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/selector-resolve-nested@4.0.0': + resolution: {integrity: sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/selector-specificity@6.0.0': + resolution: {integrity: sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -1015,152 +760,170 @@ packages: resolution: {integrity: sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==} engines: {node: '>=18.0.0'} - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.12.1': - resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} '@eslint/eslintrc@2.1.4': @@ -1183,8 +946,8 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - '@formatjs/ecma402-abstract@1.18.3': - resolution: {integrity: sha512-J961RbhyjHWeCIv+iOceNxpoZ/qomJOs5lH+rUJCeKNa59gME4KC0LJVMeWODjHsnv/hTH8Hvd6sevzcAzjuaQ==} + '@formatjs/bigdecimal@0.2.0': + resolution: {integrity: sha512-GeaxHZbUoYvHL9tC5eltHLs+1zU70aPw0s7LwqgktIzF5oMhNY4o4deEtusJMsq7WFJF3Ye2zQEzdG8beVk73w==} '@formatjs/ecma402-abstract@1.4.0': resolution: {integrity: sha512-Mv027hcLFjE45K8UJ8PjRpdDGfR0aManEFj1KzoN8zXNveHGEygpZGfFf/FTTMl+QEVSrPAUlyxaCApvmv47AQ==} @@ -1198,12 +961,18 @@ packages: '@formatjs/ecma402-abstract@2.3.6': resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} + '@formatjs/ecma402-abstract@3.2.0': + resolution: {integrity: sha512-dHnqHgBo6GXYGRsepaE1wmsC2etaivOWd5VaJstZd+HI2zR3DCUjbDVZRtoPGkkXZmyHvBwrdEUuqfvzhF/DtQ==} + '@formatjs/fast-memoize@2.2.3': resolution: {integrity: sha512-3jeJ+HyOfu8osl3GNSL4vVHUuWFXR03Iz9jjgI7RwjG6ysu/Ymdr0JRCPHfF5yGbTE6JCrd63EpvX1/WybYRbA==} '@formatjs/fast-memoize@2.2.7': resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} + '@formatjs/fast-memoize@3.1.1': + resolution: {integrity: sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==} + '@formatjs/icu-messageformat-parser@2.9.4': resolution: {integrity: sha512-Tbvp5a9IWuxUcpWNIW6GlMQYEc4rwNHR259uUFoKWNN1jM9obf9Ul0e+7r7MvFOBNcN+13K7NuKCKqQiAn1QEg==} @@ -1213,24 +982,14 @@ packages: '@formatjs/intl-displaynames@6.8.5': resolution: {integrity: sha512-85b+GdAKCsleS6cqVxf/Aw/uBd+20EM0wDpgaxzHo3RIR3bxF4xCJqH/Grbzx8CXurTgDDZHPdPdwJC+May41w==} - '@formatjs/intl-enumerator@1.4.6': - resolution: {integrity: sha512-O2YMcE3SuBy4jL8r6YNq/8hvFrQ92QGLawdmzFbOi8D1r3VOfEMr8ifnOMp3zt8XemfTLrma+aF6yRCVeEbVLw==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - - '@formatjs/intl-getcanonicallocales@2.3.0': - resolution: {integrity: sha512-BOXbLwqQ7nKua/l7tKqDLRN84WupDXFDhGJQMFvsMVA2dKuOdRaWTxWpL3cJ7qPkoNw11Jf+Xpj4OSPBBvW0eQ==} - - '@formatjs/intl-getcanonicallocales@2.5.6': - resolution: {integrity: sha512-CnBbc4St61RL06gDXlCZG08Gt41uiySgsdZNBExh8/c0FBONJCrAlQ9FsyalUdq9ze0nCeknJtMmO8JnB9xHgQ==} + '@formatjs/intl-getcanonicallocales@3.2.2': + resolution: {integrity: sha512-pjF2zC26yXXLv9FlPes6zzOme9MwgqyeWYu8r5bP7qEdoDcaGsuQO3mk6pQb57t9bxdbIwrAhZFy0hH760SeWw==} '@formatjs/intl-listformat@7.7.5': resolution: {integrity: sha512-Wzes10SMNeYgnxYiKsda4rnHP3Q3II4XT2tZyOgnH5fWuHDtIkceuWlRQNsvrI3uiwP4hLqp2XdQTCsfkhXulg==} - '@formatjs/intl-locale@3.4.6': - resolution: {integrity: sha512-2TI0sBmIBhtM/BI/ePWuQhoqmMWveeKF4bUphs9YLHmFf4XmmlpWKzbPV8jR/fTK/KFidEuZsF+IgbOAL/OVGQ==} - - '@formatjs/intl-localematcher@0.5.4': - resolution: {integrity: sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==} + '@formatjs/intl-locale@5.3.1': + resolution: {integrity: sha512-txs0HyscmgtmSzrQFzsrM6iYajsAuonoRtuackvAlmW9VX6H4FTL4TzEJoL/Qn6FFAXaQ+wxB5YqlNFR1PtlbQ==} '@formatjs/intl-localematcher@0.5.8': resolution: {integrity: sha512-I+WDNWWJFZie+jkfkiK5Mp4hEDyRSEvmyfYadflOno/mmKJKcB17fEpEH0oJu/OWhhCJ8kJBDz2YMd/6cDl7Mg==} @@ -1238,14 +997,20 @@ packages: '@formatjs/intl-localematcher@0.6.2': resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@formatjs/intl-localematcher@0.8.2': + resolution: {integrity: sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==} + '@formatjs/intl-numberformat@5.7.6': resolution: {integrity: sha512-ZlZfYtvbVHYZY5OG3RXizoCwxKxEKOrzEe2YOw9wbzoxF3PmFn0SAgojCFGLyNXkkR6xVxlylhbuOPf1dkIVNg==} '@formatjs/intl-numberformat@8.15.6': resolution: {integrity: sha512-htynTNKm5WOnbR521tNSMkWzX3yO6Z77qjOxvRujh5/A/UBKeoNElyuKCJltizdx3X33QNWQZC4aWjLKcccyeQ==} - '@formatjs/intl-pluralrules@5.4.6': - resolution: {integrity: sha512-2HlOq+c7KsSps829SJ3B5987coX5mzKx9NbPcNwQ07eq8FBHgB3HfMoxt5HvLsdk4oQwCjAEnocbtd+wVwZ2Kg==} + '@formatjs/intl-pluralrules@6.3.1': + resolution: {integrity: sha512-2sn5PxsN1X0NfHlXSPfVZ5Kh5K0sMgdlgij9B5R2BZqSNbwW5eLtQ9V4xHSjwkiBauMFM75xyqt+MsVtptjwtA==} + + '@formatjs/intl-supportedvaluesof@2.3.0': + resolution: {integrity: sha512-Fjw2ur3N+GrMLxjT6jLPCuAKFRNcWB1GDHB77pN8Lktx2z7MHJnlCA8q4NEZbJsDdVDsCeaesudHBe6el3khkg==} '@formatjs/intl@2.10.15': resolution: {integrity: sha512-i6+xVqT+6KCz7nBfk4ybMXmbKO36tKvbMKtgFz9KV+8idYFyFbfwKooYk8kGjyA5+T5f1kEPQM5IDLXucTAQ9g==} @@ -1263,24 +1028,24 @@ packages: ts-jest: optional: true - '@fortawesome/fontawesome-common-types@7.1.0': - resolution: {integrity: sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==} + '@fortawesome/fontawesome-common-types@7.2.0': + resolution: {integrity: sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==} engines: {node: '>=6'} - '@fortawesome/fontawesome-svg-core@7.1.0': - resolution: {integrity: sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==} + '@fortawesome/fontawesome-svg-core@7.2.0': + resolution: {integrity: sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==} engines: {node: '>=6'} - '@fortawesome/free-brands-svg-icons@7.1.0': - resolution: {integrity: sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==} + '@fortawesome/free-brands-svg-icons@7.2.0': + resolution: {integrity: sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==} engines: {node: '>=6'} - '@fortawesome/free-regular-svg-icons@7.1.0': - resolution: {integrity: sha512-0e2fdEyB4AR+e6kU4yxwA/MonnYcw/CsMEP9lH82ORFi9svA6/RhDyhxIv5mlJaldmaHLLYVTb+3iEr+PDSZuQ==} + '@fortawesome/free-regular-svg-icons@7.2.0': + resolution: {integrity: sha512-iycmlN51EULlQ4D/UU9WZnHiN0CvjJ2TuuCrAh+1MVdzD+4ViKYH2deNAll4XAAYlZa8WAefHR5taSK8hYmSMw==} engines: {node: '>=6'} - '@fortawesome/free-solid-svg-icons@7.1.0': - resolution: {integrity: sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==} + '@fortawesome/free-solid-svg-icons@7.2.0': + resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==} engines: {node: '>=6'} '@fortawesome/react-fontawesome@0.2.6': @@ -1615,6 +1380,15 @@ packages: '@juggle/resize-observer@3.4.0': resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@mapbox/hast-util-table-cell-style@0.2.1': resolution: {integrity: sha512-LyQz4XJIdCdY/+temIhD/Ed0x/p4GAOUycpFSEK2Ads1CPKZy6b7V/2ROEtQiLLQ8soIs0xe/QAoR6kwpyW/yw==} engines: {node: '>=12'} @@ -1660,36 +1434,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -1744,116 +1524,144 @@ packages: peerDependencies: react: '>=16.8.0' - '@rolldown/pluginutils@1.0.0-beta.43': - resolution: {integrity: sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - '@rollup/rollup-android-arm-eabi@4.53.1': - resolution: {integrity: sha512-bxZtughE4VNVJlL1RdoSE545kc4JxL7op57KKoi59/gwuU5rV6jLWFXXc8jwgFoT6vtj+ZjO+Z2C5nrY0Cl6wA==} + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.53.1': - resolution: {integrity: sha512-44a1hreb02cAAfAKmZfXVercPFaDjqXCK+iKeVOlJ9ltvnO6QqsBHgKVPTu+MJHSLLeMEUbeG2qiDYgbFPU48g==} + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.53.1': - resolution: {integrity: sha512-usmzIgD0rf1syoOZ2WZvy8YpXK5G1V3btm3QZddoGSa6mOgfXWkkv+642bfUUldomgrbiLQGrPryb7DXLovPWQ==} + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.53.1': - resolution: {integrity: sha512-is3r/k4vig2Gt8mKtTlzzyaSQ+hd87kDxiN3uDSDwggJLUV56Umli6OoL+/YZa/KvtdrdyNfMKHzL/P4siOOmg==} + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.53.1': - resolution: {integrity: sha512-QJ1ksgp/bDJkZB4daldVmHaEQkG4r8PUXitCOC2WRmRaSaHx5RwPoI3DHVfXKwDkB+Sk6auFI/+JHacTekPRSw==} + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.53.1': - resolution: {integrity: sha512-J6ma5xgAzvqsnU6a0+jgGX/gvoGokqpkx6zY4cWizRrm0ffhHDpJKQgC8dtDb3+MqfZDIqs64REbfHDMzxLMqQ==} + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.53.1': - resolution: {integrity: sha512-JzWRR41o2U3/KMNKRuZNsDUAcAVUYhsPuMlx5RUldw0E4lvSIXFUwejtYz1HJXohUmqs/M6BBJAUBzKXZVddbg==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.53.1': - resolution: {integrity: sha512-L8kRIrnfMrEoHLHtHn+4uYA52fiLDEDyezgxZtGUTiII/yb04Krq+vk3P2Try+Vya9LeCE9ZHU8CXD6J9EhzHQ==} + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] + libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.53.1': - resolution: {integrity: sha512-ysAc0MFRV+WtQ8li8hi3EoFi7us6d1UzaS/+Dp7FYZfg3NdDljGMoVyiIp6Ucz7uhlYDBZ/zt6XI0YEZbUO11Q==} + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.53.1': - resolution: {integrity: sha512-UV6l9MJpDbDZZ/fJvqNcvO1PcivGEf1AvKuTcHoLjVZVFeAMygnamCTDikCVMRnA+qJe+B3pSbgX2+lBMqgBhA==} + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.53.1': - resolution: {integrity: sha512-UDUtelEprkA85g95Q+nj3Xf0M4hHa4DiJ+3P3h4BuGliY4NReYYqwlc0Y8ICLjN4+uIgCEvaygYlpf0hUj90Yg==} + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-ppc64-gnu@4.53.1': - resolution: {integrity: sha512-vrRn+BYhEtNOte/zbc2wAUQReJXxEx2URfTol6OEfY2zFEUK92pkFBSXRylDM7aHi+YqEPJt9/ABYzmcrS4SgQ==} + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-riscv64-gnu@4.53.1': - resolution: {integrity: sha512-gto/1CxHyi4A7YqZZNznQYrVlPSaodOBPKM+6xcDSCMVZN/Fzb4K+AIkNz/1yAYz9h3Ng+e2fY9H6bgawVq17w==} + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.53.1': - resolution: {integrity: sha512-KZ6Vx7jAw3aLNjFR8eYVcQVdFa/cvBzDNRFM3z7XhNNunWjA03eUrEwJYPk0G8V7Gs08IThFKcAPS4WY/ybIrQ==} + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.53.1': - resolution: {integrity: sha512-HvEixy2s/rWNgpwyKpXJcHmE7om1M89hxBTBi9Fs6zVuLU4gOrEMQNbNsN/tBVIMbLyysz/iwNiGtMOpLAOlvA==} + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.53.1': - resolution: {integrity: sha512-E/n8x2MSjAQgjj9IixO4UeEUeqXLtiA7pyoXCFYLuXpBA/t2hnbIdxHfA7kK9BFsYAoNU4st1rHYdldl8dTqGA==} + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.53.1': - resolution: {integrity: sha512-IhJ087PbLOQXCN6Ui/3FUkI9pWNZe/Z7rEIVOzMsOs1/HSAECCvSZ7PkIbkNqL/AZn6WbZvnoVZw/qwqYMo4/w==} + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] + libc: [musl] - '@rollup/rollup-openharmony-arm64@4.53.1': - resolution: {integrity: sha512-0++oPNgLJHBblreu0SFM7b3mAsBJBTY0Ksrmu9N6ZVrPiTkRgda52mWR7TKhHAsUb9noCjFvAw9l6ZO1yzaVbA==} + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.53.1': - resolution: {integrity: sha512-VJXivz61c5uVdbmitLkDlbcTk9Or43YC2QVLRkqp86QoeFSqI81bNgjhttqhKNMKnQMWnecOCm7lZz4s+WLGpQ==} + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.53.1': - resolution: {integrity: sha512-NmZPVTUOitCXUH6erJDzTQ/jotYw4CnkMDjCYRxNHVD9bNyfrGoIse684F9okwzKCV4AIHRbUkeTBc9F2OOH5Q==} + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.53.1': - resolution: {integrity: sha512-2SNj7COIdAf6yliSpLdLG8BEsp5lgzRehgfkP0Av8zKfQFKku6JcvbobvHASPJu4f3BFxej5g+HuQPvqPhHvpQ==} + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.53.1': - resolution: {integrity: sha512-rLarc1Ofcs3DHtgSzFO31pZsCh8g05R2azN1q3fF+H423Co87My0R+tazOEvYVKXSLh8C4LerMK41/K7wlklcg==} + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} cpu: [x64] os: [win32] @@ -1870,6 +1678,10 @@ packages: peerDependencies: video.js: '>= 6 < 9' + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@theguild/federation-composition@0.20.1': resolution: {integrity: sha512-lwYYKCeHmstOtbMtzxC0BQKWsUPYbEVRVdJ3EqR4jSpcF4gvNf3MOJv6yuvq6QsKqgYZURKRBszmg7VEDoi5Aw==} engines: {node: '>=18'} @@ -1888,8 +1700,8 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@tweenjs/tween.js@18.6.4': - resolution: {integrity: sha512-lB9lMjuqjtuJrx7/kOkqQBtllspPIN+96OvTCeJ2j5FEzinoAXTdAMFnDAQT1KVPRlnYfBrqxtqP66vDM40xxQ==} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} '@types/apollo-upload-client@18.0.1': resolution: {integrity: sha512-qumgUkhs9pqJAxlDtzmn3WTrJ9oAHBb6i9A7aR1HQyjLpX9+LRL5V84aErv5ZwcCSR2zEgG8cFsuBVYfZHFSRA==} @@ -1906,15 +1718,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/cookie@0.3.3': - resolution: {integrity: sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==} - '@types/crypto-js@4.2.2': resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} - '@types/dom-screen-wake-lock@1.0.3': - resolution: {integrity: sha512-3Iten7X3Zgwvk6kh6/NRdwN7WbZ760YgFCsF5AxDifltUQzW1RaW+WRmcVtgwFzLjaNu64H+0MPJ13yRa8g3Dw==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1959,8 +1765,8 @@ packages: '@types/mousetrap@1.6.15': resolution: {integrity: sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw==} - '@types/node@18.19.130': - resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@20.19.37': + resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -2009,14 +1815,11 @@ packages: resolution: {integrity: sha512-454hrj5gz/FXcUE20ygfEiN4DxZ1sprUo0V1gqIqkNZ/CzoEzAZEll2uxMsuyz6BYjiQan4Aa65xbTemfzW9hQ==} deprecated: This is a stub types definition. schema-utils provides its own type definitions, so you do not need this installed. - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/stats.js@0.17.4': resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} - '@types/three@0.154.0': - resolution: {integrity: sha512-IioqpGhch6FdLDh4zazRn3rXHj6Vn2nVOziJdXVbJFi9CaI65LtP9qqUtpzbsHK2Ezlox8NtsLNHSw3AQzucjA==} + '@types/three@0.183.1': + resolution: {integrity: sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==} '@types/ua-parser-js@0.7.39': resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==} @@ -2042,63 +1845,63 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@5.62.0': - resolution: {integrity: sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/eslint-plugin@7.18.0': + resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} + engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: - '@typescript-eslint/parser': ^5.0.0 - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 typescript: '*' peerDependenciesMeta: typescript: optional: true - '@typescript-eslint/parser@5.62.0': - resolution: {integrity: sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} + engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^8.56.0 typescript: '*' peerDependenciesMeta: typescript: optional: true - '@typescript-eslint/scope-manager@5.62.0': - resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} + engines: {node: ^18.18.0 || >=20.0.0} - '@typescript-eslint/type-utils@5.62.0': - resolution: {integrity: sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/type-utils@7.18.0': + resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} + engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: - eslint: '*' + eslint: ^8.56.0 typescript: '*' peerDependenciesMeta: typescript: optional: true - '@typescript-eslint/types@5.62.0': - resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} + engines: {node: ^18.18.0 || >=20.0.0} - '@typescript-eslint/typescript-estree@5.62.0': - resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} + engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: typescript: '*' peerDependenciesMeta: typescript: optional: true - '@typescript-eslint/utils@5.62.0': - resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/utils@7.18.0': + resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} + engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^8.56.0 - '@typescript-eslint/visitor-keys@5.62.0': - resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} + engines: {node: ^18.18.0 || >=20.0.0} '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -2116,18 +1919,14 @@ packages: '@videojs/xhr@2.6.0': resolution: {integrity: sha512-7J361GiN1tXpm+gd0xz2QWr3xNWBE+rytvo8J3KuggFaLg+U37gZQ2BuPLcnkfGffy2e+ozY70RHC8jt7zjA6Q==} - '@vitejs/plugin-legacy@5.4.3': - resolution: {integrity: sha512-wsyXK9mascyplcqvww1gA1xYiy29iRHfyciw+a0t7qRNdzX6PdfSWmOoCi74epr87DujM+5J+rnnSv+4PazqVg==} - engines: {node: ^18.0.0 || >=20.0.0} - peerDependencies: - terser: ^5.4.0 - vite: ^5.0.0 - - '@vitejs/plugin-react@5.1.0': - resolution: {integrity: sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} 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 + + '@webgpu/types@0.1.69': + resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} '@whatwg-node/disposablestack@0.0.6': resolution: {integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==} @@ -2161,8 +1960,8 @@ packages: resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==} engines: {node: '>=8'} - '@xmldom/xmldom@0.8.11': - resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} acorn-jsx@5.3.2: @@ -2174,8 +1973,8 @@ packages: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true @@ -2195,11 +1994,11 @@ packages: peerDependencies: ajv: ^6.9.1 - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} @@ -2209,6 +2008,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -2314,21 +2117,6 @@ packages: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} - babel-plugin-polyfill-corejs2@0.4.14: - resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - babel-plugin-polyfill-corejs3@0.13.0: - resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - - babel-plugin-polyfill-regenerator@0.6.5: - resolution: {integrity: sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==} - peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - babel-plugin-react-intl@7.9.4: resolution: {integrity: sha512-cMKrHEXrw43yT4M89Wbgq8A8N8lffSquj1Piwov/HVukR7jwOw8gf9btXNsQhT27ccyqEwy+M286JQYy0jby2g==} deprecated: this package has been renamed to babel-plugin-formatjs @@ -2347,17 +2135,15 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@2.0.0: - resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} - base64-blob@1.4.1: resolution: {integrity: sha512-n5Ov4cPTbLBTX1PiFbaB5AmK7LMigO9HWh5Lzx+Kcx/yx1MppeeLYtAH8aLv1m++WNoHQnr+xbGSqcZinopwlw==} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.8.19: - resolution: {integrity: sha512-zoKGUdu6vb2jd3YOq0nnhEDQVbPcHhco3UImJrv5dSkvxTc2pl2WjOPsjZXDwPDSl5eghIMuY3R6J9NDKF3KcQ==} + baseline-browser-mapping@2.10.12: + resolution: {integrity: sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==} + engines: {node: '>=6.0.0'} hasBin: true bcp-47-match@1.0.3: @@ -2379,25 +2165,18 @@ packages: jquery: 1.9.1 - 3 popper.js: ^1.16.1 - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist-to-esbuild@2.1.1: - resolution: {integrity: sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - browserslist: '*' - - browserslist@4.26.3: - resolution: {integrity: sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==} + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2410,6 +2189,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + cacheable@2.3.4: + resolution: {integrity: sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2433,20 +2215,12 @@ packages: resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==} engines: {node: '>=8'} - camelcase-keys@7.0.2: - resolution: {integrity: sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==} - engines: {node: '>=12'} - camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - - caniuse-lite@1.0.30001751: - resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==} + caniuse-lite@1.0.30001782: + resolution: {integrity: sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==} capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} @@ -2558,15 +2332,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie@0.4.2: - resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} - engines: {node: '>= 0.6'} - - core-js-compat@3.46.0: - resolution: {integrity: sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==} - - core-js@3.46.0: - resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} @@ -2581,6 +2349,15 @@ packages: typescript: optional: true + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -2598,12 +2375,12 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} - css-functions-list@3.2.3: - resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} - engines: {node: '>=12 || >=16'} + css-functions-list@3.3.3: + resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} + engines: {node: '>=12'} - css-tree@2.3.1: - resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} cssesc@3.0.0: @@ -2680,10 +2457,6 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} - decamelize@5.0.1: - resolution: {integrity: sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==} - engines: {node: '>=10'} - decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -2713,6 +2486,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-europe-js@0.1.2: + resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -2722,6 +2498,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + diacritics@1.3.0: resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==} @@ -2762,8 +2542,8 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.238: - resolution: {integrity: sha512-khBdc+w/Gv+cS8e/Pbnaw/FXcBUeKrRVik9IxfXtgREOWyJhR4tj43n3amkVogJ/yeQUqzkrZcFhtIxIdqmmcQ==} + electron-to-chromium@1.5.329: + resolution: {integrity: sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2771,6 +2551,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2809,9 +2593,9 @@ packages: es6-promise@4.2.8: resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} hasBin: true escalade@3.2.0: @@ -2833,13 +2617,12 @@ packages: eslint: ^7.32.0 || ^8.2.0 eslint-plugin-import: ^2.25.2 - eslint-config-airbnb-typescript@17.1.0: - resolution: {integrity: sha512-GPxI5URre6dDpJ0CtcthSZVBAfI+Uw7un5OYNVxP2EYi3H81Jw701yFP7AU+/vCE7xBtFmjge7kfhhk4+RAiig==} + eslint-config-airbnb-typescript@18.0.0: + resolution: {integrity: sha512-oc+Lxzgzsu8FQyFVa4QFaVKiitTYiiW3frB9KYW5OWdPrqFc7FzxgB20hP4cHMlr+MBzGcLl3jnCOVOydL9mIg==} peerDependencies: - '@typescript-eslint/eslint-plugin': ^5.13.0 || ^6.0.0 - '@typescript-eslint/parser': ^5.0.0 || ^6.0.0 - eslint: ^7.32.0 || ^8.2.0 - eslint-plugin-import: ^2.25.3 + '@typescript-eslint/eslint-plugin': ^7.0.0 + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 eslint-config-airbnb@19.0.4: resolution: {integrity: sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==} @@ -2881,6 +2664,12 @@ packages: eslint-import-resolver-webpack: optional: true + eslint-plugin-deprecation@3.0.0: + resolution: {integrity: sha512-JuVLdNg/uf0Adjg2tpTyYoYaMbwQNn/c78P1HcccokvhtRphgnRjZDKmhlxbxYptppex03zO76f97DD/yQHv7A==} + peerDependencies: + eslint: ^8.0.0 + typescript: ^4.2.4 || ^5.0.0 + eslint-plugin-import@2.32.0: resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} engines: {node: '>=4'} @@ -2909,10 +2698,6 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - eslint-scope@7.2.2: resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2936,18 +2721,14 @@ packages: engines: {node: '>=4'} hasBin: true - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -2994,8 +2775,8 @@ packages: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} @@ -3006,25 +2787,33 @@ packages: fbjs@3.0.5: resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fetch-blob@3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} - fflate@0.6.10: - resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} + file-entry-cache@11.1.2: + resolution: {integrity: sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} - file-entry-cache@7.0.2: - resolution: {integrity: sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==} - engines: {node: '>=12.0.0'} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -3040,19 +2829,22 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - flag-icons@6.15.0: - resolution: {integrity: sha512-ARo9Q+aATZEjyjveeec9e+orx+xLWUBdOX9baOKoGqDzMbvZ65ghPhaHbVt5T7ZB+Q4OFsB4Hr+eQnpV8Q+dLA==} + flag-icons@7.5.0: + resolution: {integrity: sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==} flat-cache@3.2.0: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} + flat-cache@6.1.22: + resolution: {integrity: sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==} + flat@5.0.2: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} hasBin: true - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} flexbin@0.2.0: resolution: {integrity: sha512-dgCeT6/oVljr0eao0f7Eg2VXutK/+rp02J6Nkw22uTTFE4HSC7zfYRzjuy2/r0dhr/sUBRMJM2tMyOCi+HeU+A==} @@ -3065,8 +2857,8 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} - formik@2.4.6: - resolution: {integrity: sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==} + formik@2.4.9: + resolution: {integrity: sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==} peerDependencies: react: '>=16.8.0' @@ -3108,6 +2900,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -3133,7 +2929,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-modules@2.0.0: resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} @@ -3158,6 +2954,10 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + globby@16.2.0: + resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} + engines: {node: '>=20'} + globjoin@0.1.4: resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} @@ -3236,6 +3036,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} @@ -3251,6 +3055,10 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -3267,19 +3075,21 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.1.1: + resolution: {integrity: sha512-AHb76R16GB5EsPBE2J7Ko5kiEyXwviB9P5SMrAKcuAu4vJPZttViAbj9+tZeaQE5zjDme+1vcHP78Yj/WoAveA==} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} - hosted-git-info@4.1.0: - resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} - engines: {node: '>=10'} - html-entities@1.4.0: resolution: {integrity: sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==} - html-tags@3.3.1: - resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} - engines: {node: '>=8'} + html-tags@5.1.0: + resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} + engines: {node: '>=20.10'} http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} @@ -3304,6 +3114,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} @@ -3311,8 +3125,8 @@ packages: resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==} engines: {node: '>=0.8.0'} - immutable@5.1.4: - resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + immutable@5.1.5: + resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} @@ -3322,9 +3136,8 @@ packages: resolution: {integrity: sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==} engines: {node: '>=12.2'} - import-lazy@4.0.0: - resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} - engines: {node: '>=8'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} imsc@1.1.5: resolution: {integrity: sha512-V8je+CGkcvGhgl2C1GlhqFFiUOIEdwXbXLiu1Fcubvvbo+g9inauqT3l0pNYXGoLPBj3jxtZz9t+wCopMkwadQ==} @@ -3337,10 +3150,6 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - indent-string@5.0.0: - resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} - engines: {node: '>=12'} - individual@2.0.0: resolution: {integrity: sha512-pWt8hBCqJsUWI/HtcfWod7+N9SgAqyPEaF7JQjwzjn5vGrpg6aQ5qeAFQ7dx//UH4J1O+7xqew+gCeeFt6xN/g==} @@ -3484,6 +3293,10 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + is-plain-obj@1.1.0: resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} engines: {node: '>=0.10.0'} @@ -3516,6 +3329,9 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-standalone-pwa@0.1.1: + resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -3597,8 +3413,8 @@ packages: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsesc@3.1.0: @@ -3650,13 +3466,13 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - known-css-properties@0.29.0: - resolution: {integrity: sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==} - language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -3671,8 +3487,79 @@ packages: lie@3.1.1: resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} - lil-gui@0.17.0: - resolution: {integrity: sha512-MVBHmgY+uEbmJNApAaPbtvNh1RCAeMnKym82SBjtp5rODTYKWtM+MXHCifLe2H2Ti1HuBGBtK/5SyG4ShQ3pUQ==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -3701,11 +3588,8 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash-es@4.17.23: - resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} - - lodash.debounce@4.0.8: - resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3750,16 +3634,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - m3u8-parser@4.8.0: resolution: {integrity: sha512-UqA2a/Pw3liR6Df3gwxrqghCP17OpPlQj6RBPLYygf/ZSQ4MoSgvdvhvt35qV+3NaaA0FSZx93Ix+2brT1U7cA==} - magic-string@0.30.19: - resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} - make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -3786,8 +3663,8 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mathml-tag-names@2.1.3: - resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + mathml-tag-names@4.0.0: + resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==} mdast-util-definitions@4.0.0: resolution: {integrity: sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==} @@ -3822,8 +3699,8 @@ packages: mdast-util-to-string@2.0.0: resolution: {integrity: sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==} - mdn-data@2.0.30: - resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} mdurl@1.0.1: resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} @@ -3831,13 +3708,9 @@ packages: memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} - meow@10.1.5: - resolution: {integrity: sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - meow@13.2.0: - resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} - engines: {node: '>=18'} + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} meow@6.1.1: resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==} @@ -3856,8 +3729,8 @@ packages: '@types/node': optional: true - meshoptimizer@0.18.1: - resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} + meshoptimizer@1.0.1: + resolution: {integrity: sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==} micromark-extension-gfm-autolink-literal@0.5.7: resolution: {integrity: sha512-ePiDGH0/lhcngCe8FtH4ARFoxKTUelMp4L7Gg2pujYD5CSMb9PbblnyL+AAMud/SNMyusbS2XDSiPIRcQoNFAw==} @@ -3888,18 +3761,18 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - min-document@2.19.0: - resolution: {integrity: sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==} + min-document@2.19.2: + resolution: {integrity: sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==} min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} minimist-options@4.1.0: @@ -3943,9 +3816,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - natural-compare-lite@1.4.0: - resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} - natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3976,16 +3846,12 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.26: - resolution: {integrity: sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==} + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} - normalize-package-data@3.0.3: - resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} - engines: {node: '>=10'} - normalize-path@2.1.1: resolution: {integrity: sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==} engines: {node: '>=0.10.0'} @@ -3994,10 +3860,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - normalize-url@4.5.1: - resolution: {integrity: sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==} - engines: {node: '>=8'} - nosleep.js@0.7.0: resolution: {integrity: sha512-Z4B1HgvzR+en62ghwZf6BwAR6x4/pjezsiMcbF9KMLh7xoscpoYhaSXfY3lLkqC68AtW+/qLJ1lzvBIj0FGaTA==} @@ -4142,10 +4004,14 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pify@5.0.0: resolution: {integrity: sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==} engines: {node: '>=10'} @@ -4162,14 +4028,11 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss-resolve-nested-selector@0.1.6: - resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} - - postcss-safe-parser@6.0.0: - resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} - engines: {node: '>=12.0'} + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} peerDependencies: - postcss: ^8.3.3 + postcss: ^8.4.31 postcss-scss@4.0.9: resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} @@ -4177,20 +4040,20 @@ packages: peerDependencies: postcss: ^8.4.29 - postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} - postcss-sorting@8.0.2: - resolution: {integrity: sha512-M9dkSrmU00t/jK7rF6BZSZauA5MAaBW4i5EnJXspMwt4iqTh/L9j6fgMnbElEOfyRyfLfVbIHj/R52zHzAPe1Q==} + postcss-sorting@10.0.0: + resolution: {integrity: sha512-TXbU+h6vVRW+86c/+ewhWq9k7pr7ijASTnepVhCQiC87zAOTkvB1v2dHyWP+ggstSTX/PNvjzS+IOqzejndz9w==} peerDependencies: postcss: ^8.4.20 postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -4230,6 +4093,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qified@0.9.0: + resolution: {integrity: sha512-4q61YgkHbY6gmwkqm0BsxyLDO3UYdrdiJTJ7JiaZb3xpW1duxn135SB7KqUEkCiuu5O4W+TtwEWP2VjmSRanvA==} + engines: {node: '>=20'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4237,10 +4104,6 @@ packages: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} - quick-lru@5.1.1: - resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} - engines: {node: '>=10'} - react-bootstrap@1.6.8: resolution: {integrity: sha512-yD6uN78XlFOkETQp6GRuVe0s5509x3XYx8PfPbirwFTYCj5/RfmSs9YZGCwkUrhZNFzj7tZPdpb+3k50mK1E4g==} peerDependencies: @@ -4370,18 +4233,10 @@ packages: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} engines: {node: '>=8'} - read-pkg-up@8.0.0: - resolution: {integrity: sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==} - engines: {node: '>=12'} - read-pkg@5.2.0: resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} engines: {node: '>=8'} - read-pkg@6.0.0: - resolution: {integrity: sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==} - engines: {node: '>=12'} - readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -4394,39 +4249,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} - redent@4.0.0: - resolution: {integrity: sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==} - engines: {node: '>=12'} - reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} - regenerate-unicode-properties@10.2.2: - resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} - engines: {node: '>=4'} - - regenerate@1.4.2: - resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} - - regenerator-runtime@0.14.1: - resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} - regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} - regexpu-core@6.4.0: - resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} - engines: {node: '>=4'} - - regjsgen@0.8.0: - resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} - - regjsparser@0.13.0: - resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} - hasBin: true - rehackt@0.1.0: resolution: {integrity: sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==} peerDependencies: @@ -4516,8 +4346,8 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rollup@4.53.1: - resolution: {integrity: sha512-n2I0V0lN3E9cxxMqBCT3opWOiQBzRN7UG60z/WDKqdX2zHUS/39lezBcsckZFsV6fUTSnfqI7kHf60jDAPGKug==} + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -4555,8 +4385,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sass@1.93.2: - resolution: {integrity: sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==} + sass@1.98.0: + resolution: {integrity: sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==} engines: {node: '>=14.0.0'} hasBin: true @@ -4581,8 +4411,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true @@ -4649,6 +4479,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + slice-ansi@3.0.0: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} @@ -4719,6 +4553,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -4730,10 +4568,6 @@ packages: string.prototype.repeat@1.0.0: resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} - string.prototype.replaceall@1.0.11: - resolution: {integrity: sha512-MtmYTo9i6i3Jpc0xuGVYd5GraPTml7vlZh4030YXRiBktXwYKYU7IDGJeMi008Dk8QKlgJUi/Q+oNnGKB++/fQ==} - engines: {node: '>= 0.4'} - string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -4753,6 +4587,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -4765,40 +4603,38 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} - strip-indent@4.1.1: - resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} - engines: {node: '>=12'} - strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - style-search@0.1.0: - resolution: {integrity: sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==} - style-to-object@0.3.0: resolution: {integrity: sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==} - stylelint-order@6.0.4: - resolution: {integrity: sha512-0UuKo4+s1hgQ/uAxlYU4h0o0HS4NiQDud0NAUNI0aa8FJdmYHA5ZZTFHiV5FpmE3071e9pZx5j0QpVJW5zOCUA==} + stylelint-order@8.1.1: + resolution: {integrity: sha512-LqsEB6VggJuu5v10RtkrQsBObcdwBE7GuAOlwfc/LR3VL/w8UqKX2BOLIjhyGt0Gne/njo7gRNGiJAKhfmPMNw==} + engines: {node: '>=20.19.0'} peerDependencies: - stylelint: ^14.0.0 || ^15.0.0 || ^16.0.1 + stylelint: ^16.18.0 || ^17.0.0 - stylelint@15.11.0: - resolution: {integrity: sha512-78O4c6IswZ9TzpcIiQJIN49K3qNoXTM8zEJzhaTE/xRTCZswaovSEVIa/uwbOltZrk16X4jAxjaOhzz/hTm1Kw==} - engines: {node: ^14.13.1 || >=16.0.0} + stylelint@17.6.0: + resolution: {integrity: sha512-tokrsMIVAR9vAQ/q3UVEr7S0dGXCi7zkCezPRnS2kqPUulvUh5Vgfwngrk4EoAoW7wnrThqTdnTFN5Ra7CaxIg==} + engines: {node: '>=20.19.0'} hasBin: true stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-hyperlinks@3.2.0: - resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} - engines: {node: '>=14.18'} + supports-hyperlinks@4.4.0: + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} + engines: {node: '>=20'} supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} @@ -4818,15 +4654,12 @@ packages: resolution: {integrity: sha512-c7AfkZ9udatCuAy9RSfiGPpeOKKUAUK5e1cXadLOGUjasdxqYqAK0jTNkM/FSEyJ3a5Ra27j/tw/PS0qLmaF/A==} engines: {node: '>=18'} - systemjs@6.15.1: - resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==} - table@6.9.0: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} - terser@5.44.0: - resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} + terser@5.46.1: + resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==} engines: {node: '>=10'} hasBin: true @@ -4836,8 +4669,8 @@ packages: thehandy@1.1.0: resolution: {integrity: sha512-ZifUw47kq6cKNiKLNgrnVPkBFbG+yR6tScgWy2INDnGT4XePhjRaQNni67rWn52nAOkotq9VyaK20OZoorHqTA==} - three@0.93.0: - resolution: {integrity: sha512-Ys9+UBBsd6FxTZZl4BH7B4b2F+B2uR0cOwY7OQ/aCzU/VgO4Wmmr1LbWPH1fsTvSVik9KAuwxwOHlSC4IMGOLA==} + three@0.125.2: + resolution: {integrity: sha512-7rIRO23jVKWcAPFdW/HREU2NZMGWPBZ4XwEMt0Ak0jwLUKVJhcKM55eCBWyGZq/KiQbeo1IeuAoo/9l2dzhTXA==} throttle-debounce@5.0.2: resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} @@ -4859,6 +4692,10 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + title-case@3.0.3: resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} @@ -4876,13 +4713,15 @@ packages: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} - trim-newlines@4.1.1: - resolution: {integrity: sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==} - engines: {node: '>=12'} - trough@1.0.5: resolution: {integrity: sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==} + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + ts-invariant@0.10.3: resolution: {integrity: sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==} engines: {node: '>=8'} @@ -4917,9 +4756,6 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tslib@2.4.1: resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} @@ -4929,12 +4765,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsutils@3.21.0: - resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} - 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' - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4959,10 +4789,6 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} - type-fest@1.4.0: - resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} - engines: {node: '>=10'} - type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} @@ -4986,15 +4812,27 @@ packages: typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} - typescript@4.8.4: - resolution: {integrity: sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==} + typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} engines: {node: '>=4.2.0'} hasBin: true + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-is-frozen@0.1.2: + resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} + ua-parser-js@1.0.41: resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} hasBin: true + ua-parser-js@2.0.9: + resolution: {integrity: sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==} + hasBin: true + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -5008,24 +4846,12 @@ packages: peerDependencies: react: '>=15.0.0' - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - unicode-canonical-property-names-ecmascript@2.0.1: - resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} - engines: {node: '>=4'} - - unicode-match-property-ecmascript@2.0.0: - resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} - engines: {node: '>=4'} - - unicode-match-property-value-ecmascript@2.2.1: - resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} - engines: {node: '>=4'} - - unicode-property-aliases-ecmascript@2.2.0: - resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} - engines: {node: '>=4'} + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} unified@9.2.2: resolution: {integrity: sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==} @@ -5060,8 +4886,8 @@ packages: unist-util-visit@2.0.3: resolution: {integrity: sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==} - universal-cookie@4.0.4: - resolution: {integrity: sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==} + universal-cookie@8.1.0: + resolution: {integrity: sha512-65+kikQAWq7gsJbirwB7dk6e8xeug1hx3++x2dQoymdXcV7fYv0yChOgHCg01ZwP3fE3sYeq6EWCSpFv3HLl9g==} universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} @@ -5071,8 +4897,8 @@ packages: resolution: {integrity: sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg==} engines: {node: '>=0.10.0'} - update-browserslist-db@1.1.3: - resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -5143,9 +4969,6 @@ packages: peerDependencies: video.js: ^6 || ^7 - videojs-vr@1.8.0: - resolution: {integrity: sha512-776gXqt8g6/rLeV56nn/aUcO0sRy+mgFITCw8cIqzTzl93SE1PEK/QE3YNqtppUfU5igayrx7WKsWhDOpsXMpw==} - videojs-vtt.js@0.15.5: resolution: {integrity: sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==} @@ -5154,30 +4977,32 @@ packages: peerDependencies: vite: '>=2.0.0' - vite-tsconfig-paths@4.3.2: - resolution: {integrity: sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==} + vite-tsconfig-paths@6.1.1: + resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} peerDependencies: vite: '*' - peerDependenciesMeta: - vite: - optional: true - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true + jiti: + optional: true less: optional: true lightningcss: @@ -5192,6 +5017,10 @@ packages: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} @@ -5272,9 +5101,9 @@ packages: write-file-atomic@3.0.3: resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} - write-file-atomic@5.0.1: - resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} write-json-file@4.3.0: resolution: {integrity: sha512-PxiShnxf0IlnQuMYOPPhPkhExoCQuTUNPOa/2JWCYTmBquU9njyyDuwRKN26IZBlp4yn1nt+Agh2HOOBl+55HQ==} @@ -5306,14 +5135,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yaml-ast-parser@0.0.43: resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} - yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} yaml@2.8.1: @@ -5325,10 +5151,6 @@ packages: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} - yargs-parser@20.2.9: - resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} - engines: {node: '>=10'} - yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -5365,7 +5187,7 @@ snapshots: '@ant-design/react-slick@1.1.2(react@17.0.2)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 classnames: 2.5.1 json2mq: 0.2.0 react: 17.0.2 @@ -5397,13 +5219,13 @@ snapshots: '@ardatan/relay-compiler@12.0.0(graphql@16.11.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/generator': 7.28.3 - '@babel/parser': 7.28.4 - '@babel/runtime': 7.28.4 - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 - babel-preset-fbjs: 3.4.0(@babel/core@7.28.4) + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/runtime': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + babel-preset-fbjs: 3.4.0(@babel/core@7.29.0) chalk: 4.1.2 fb-watchman: 2.0.2 fbjs: 3.0.5 @@ -5421,9 +5243,9 @@ snapshots: '@ardatan/relay-compiler@12.0.3(graphql@16.11.0)': dependencies: - '@babel/generator': 7.28.3 - '@babel/parser': 7.28.4 - '@babel/runtime': 7.28.4 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/runtime': 7.29.2 chalk: 4.1.2 fb-watchman: 2.0.2 graphql: 16.11.0 @@ -5435,25 +5257,25 @@ snapshots: transitivePeerDependencies: - encoding - '@babel/code-frame@7.27.1': + '@babel/code-frame@7.29.0': dependencies: - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.28.4': {} + '@babel/compat-data@7.29.0': {} - '@babel/core@7.28.4': + '@babel/core@7.29.0': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.4 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@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.4.3 @@ -5463,724 +5285,370 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.28.3': + '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.29.0 - '@babel/helper-compilation-targets@7.27.2': + '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.28.4 + '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.26.3 + browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.3(@babel/core@7.28.4)': + '@babel/helper-create-class-features-plugin@7.28.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 + '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-member-expression-to-functions': 7.27.1 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.4) + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.0) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.28.4 + '@babel/traverse': 7.29.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-annotate-as-pure': 7.27.3 - regexpu-core: 6.4.0 - semver: 6.3.1 - - '@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 - debug: 4.4.3 - lodash.debounce: 4.0.8 - resolve: 1.22.11 - transitivePeerDependencies: - - supports-color - '@babel/helper-globals@7.28.0': {} '@babel/helper-member-expression-to-functions@7.27.1': dependencies: - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.27.1': + '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.28.4 + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.29.0 - '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.28.4)': + '@babel/helper-replace-supers@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-wrap-function': 7.28.3 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/helper-replace-supers@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 + '@babel/core': 7.29.0 '@babel/helper-member-expression-to-functions': 7.27.1 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.28.4 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-option@7.27.1': {} - '@babel/helper-wrap-function@7.28.3': + '@babel/helpers@7.29.2': dependencies: - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/helpers@7.28.4': + '@babel/plugin-proposal-object-rest-spread@7.20.7(@babel/core@7.29.0)': dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 + '@babel/compat-data': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) - '@babel/parser@7.28.4': + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': dependencies: - '@babel/types': 7.28.4 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-syntax-flow@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-syntax-import-assertions@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.3(@babel/core@7.28.4)': + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.28.4)': + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-proposal-object-rest-spread@7.20.7(@babel/core@7.28.4)': + '@babel/plugin-transform-block-scoping@7.28.4(@babel/core@7.29.0)': dependencies: - '@babel/compat-data': 7.28.4 - '@babel/core': 7.28.4 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.4) + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.28.4)': + '@babel/plugin-transform-classes@7.28.4(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-flow@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-assertions@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-async-generator-functions@7.28.0(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.28.4) - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-async-to-generator@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-block-scoping@7.28.4(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-class-static-block@7.28.3(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-classes@7.28.4(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 + '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-globals': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.4) - '@babel/traverse': 7.28.4 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-computed-properties@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-computed-properties@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/template': 7.27.2 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 - '@babel/plugin-transform-destructuring@7.28.0(@babel/core@7.28.4)': + '@babel/plugin-transform-destructuring@7.28.0(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.4 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-dotall-regex@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-explicit-resource-management@7.28.0(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-destructuring': 7.28.0(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-exponentiation-operator@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.28.4) - - '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.28.4 + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-json-strings@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-literals@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-logical-assignment-operators@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.0) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-named-capturing-groups-regex@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-nullish-coalescing-operator@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-numeric-separator@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-object-rest-spread@7.28.4(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-destructuring': 7.28.0(@babel/core@7.28.4) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.4) - '@babel/traverse': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-optional-catch-binding@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-optional-chaining@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-private-methods@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-private-property-in-object@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 + '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.28.4)': + '@babel/plugin-transform-spread@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.4) - '@babel/types': 7.28.4 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-regenerator@7.28.4(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-regexp-modifiers@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-spread@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.28.4)': + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.28.4)': + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 - '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.28.4)': + '@babel/traverse@7.29.0': dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-unicode-property-regex@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-unicode-sets-regex@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-create-regexp-features-plugin': 7.27.1(@babel/core@7.28.4) - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/preset-env@7.28.3(@babel/core@7.28.4)': - dependencies: - '@babel/compat-data': 7.28.4 - '@babel/core': 7.28.4 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.3(@babel/core@7.28.4) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.28.4) - '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.28.4) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-async-generator-functions': 7.28.0(@babel/core@7.28.4) - '@babel/plugin-transform-async-to-generator': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-block-scoping': 7.28.4(@babel/core@7.28.4) - '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-class-static-block': 7.28.3(@babel/core@7.28.4) - '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.28.4) - '@babel/plugin-transform-computed-properties': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-destructuring': 7.28.0(@babel/core@7.28.4) - '@babel/plugin-transform-dotall-regex': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-explicit-resource-management': 7.28.0(@babel/core@7.28.4) - '@babel/plugin-transform-exponentiation-operator': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-json-strings': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-logical-assignment-operators': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-modules-systemjs': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-named-capturing-groups-regex': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-numeric-separator': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-object-rest-spread': 7.28.4(@babel/core@7.28.4) - '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-optional-catch-binding': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.4) - '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-regenerator': 7.28.4(@babel/core@7.28.4) - '@babel/plugin-transform-regexp-modifiers': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-spread': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-unicode-property-regex': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-unicode-sets-regex': 7.27.1(@babel/core@7.28.4) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.28.4) - babel-plugin-polyfill-corejs2: 0.4.14(@babel/core@7.28.4) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.28.4) - babel-plugin-polyfill-regenerator: 0.6.5(@babel/core@7.28.4) - core-js-compat: 3.46.0 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/types': 7.28.4 - esutils: 2.0.3 - - '@babel/runtime@7.28.4': {} - - '@babel/template@7.27.2': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 - - '@babel/traverse@7.28.4': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.4 - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.28.4': + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@blaineam/videojs-vr@3.1.1': + dependencies: + '@babel/runtime': 7.29.2 + global: 4.4.0 + three: 0.125.2 + video.js: 7.21.7 + webvr-polyfill: 0.10.12 + + '@cacheable/memory@2.0.8': + dependencies: + '@cacheable/utils': 2.4.1 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.4.1': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1)': + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/css-tokenizer': 2.4.1 + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-tokenizer@2.4.1': {} - - '@csstools/media-query-list-parser@2.1.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1)': + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) - '@csstools/css-tokenizer': 2.4.1 + '@csstools/css-tokenizer': 4.0.0 - '@csstools/selector-specificity@3.1.1(postcss-selector-parser@6.1.2)': + '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@csstools/media-query-list-parser@5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - postcss-selector-parser: 6.1.2 + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/selector-resolve-nested@4.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@dimforge/rapier3d-compat@0.12.0': {} '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.27.1 - '@babel/runtime': 7.28.4 + '@babel/helper-module-imports': 7.28.6 + '@babel/runtime': 7.29.2 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 '@emotion/serialize': 1.3.3 @@ -6207,7 +5675,7 @@ snapshots: '@emotion/react@11.14.0(@types/react@17.0.89)(react@17.0.2)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@emotion/babel-plugin': 11.13.5 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 @@ -6258,92 +5726,101 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 - '@esbuild/aix-ppc64@0.21.5': + '@esbuild/aix-ppc64@0.27.7': optional: true - '@esbuild/android-arm64@0.21.5': + '@esbuild/android-arm64@0.27.7': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/android-arm@0.27.7': optional: true - '@esbuild/android-x64@0.21.5': + '@esbuild/android-x64@0.27.7': optional: true - '@esbuild/darwin-arm64@0.21.5': + '@esbuild/darwin-arm64@0.27.7': optional: true - '@esbuild/darwin-x64@0.21.5': + '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.21.5': + '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-x64@0.21.5': + '@esbuild/freebsd-x64@0.27.7': optional: true - '@esbuild/linux-arm64@0.21.5': + '@esbuild/linux-arm64@0.27.7': optional: true - '@esbuild/linux-arm@0.21.5': + '@esbuild/linux-arm@0.27.7': optional: true - '@esbuild/linux-ia32@0.21.5': + '@esbuild/linux-ia32@0.27.7': optional: true - '@esbuild/linux-loong64@0.21.5': + '@esbuild/linux-loong64@0.27.7': optional: true - '@esbuild/linux-mips64el@0.21.5': + '@esbuild/linux-mips64el@0.27.7': optional: true - '@esbuild/linux-ppc64@0.21.5': + '@esbuild/linux-ppc64@0.27.7': optional: true - '@esbuild/linux-riscv64@0.21.5': + '@esbuild/linux-riscv64@0.27.7': optional: true - '@esbuild/linux-s390x@0.21.5': + '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/linux-x64@0.21.5': + '@esbuild/linux-x64@0.27.7': optional: true - '@esbuild/netbsd-x64@0.21.5': + '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.21.5': + '@esbuild/netbsd-x64@0.27.7': optional: true - '@esbuild/sunos-x64@0.21.5': + '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/win32-arm64@0.21.5': + '@esbuild/openbsd-x64@0.27.7': optional: true - '@esbuild/win32-ia32@0.21.5': + '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/win32-x64@0.21.5': + '@esbuild/sunos-x64@0.27.7': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)': + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': dependencies: eslint: 8.57.1 eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.12.1': {} + '@eslint-community/regexpp@4.12.2': {} '@eslint/eslintrc@2.1.4': dependencies: - ajv: 6.12.6 + ajv: 6.14.0 debug: 4.4.3 espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 - minimatch: 3.1.2 + js-yaml: 4.1.1 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -6363,10 +5840,7 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@formatjs/ecma402-abstract@1.18.3': - dependencies: - '@formatjs/intl-localematcher': 0.5.4 - tslib: 2.8.1 + '@formatjs/bigdecimal@0.2.0': {} '@formatjs/ecma402-abstract@1.4.0': dependencies: @@ -6389,6 +5863,12 @@ snapshots: decimal.js: 10.6.0 tslib: 2.8.1 + '@formatjs/ecma402-abstract@3.2.0': + dependencies: + '@formatjs/bigdecimal': 0.2.0 + '@formatjs/fast-memoize': 3.1.1 + '@formatjs/intl-localematcher': 0.8.2 + '@formatjs/fast-memoize@2.2.3': dependencies: tslib: 2.8.1 @@ -6397,6 +5877,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@formatjs/fast-memoize@3.1.1': {} + '@formatjs/icu-messageformat-parser@2.9.4': dependencies: '@formatjs/ecma402-abstract': 2.2.4 @@ -6414,17 +5896,7 @@ snapshots: '@formatjs/intl-localematcher': 0.5.8 tslib: 2.8.1 - '@formatjs/intl-enumerator@1.4.6': - dependencies: - tslib: 2.8.1 - - '@formatjs/intl-getcanonicallocales@2.3.0': - dependencies: - tslib: 2.8.1 - - '@formatjs/intl-getcanonicallocales@2.5.6': - dependencies: - tslib: 2.8.1 + '@formatjs/intl-getcanonicallocales@3.2.2': {} '@formatjs/intl-listformat@7.7.5': dependencies: @@ -6432,16 +5904,11 @@ snapshots: '@formatjs/intl-localematcher': 0.5.8 tslib: 2.8.1 - '@formatjs/intl-locale@3.4.6': + '@formatjs/intl-locale@5.3.1': dependencies: - '@formatjs/ecma402-abstract': 1.18.3 - '@formatjs/intl-enumerator': 1.4.6 - '@formatjs/intl-getcanonicallocales': 2.3.0 - tslib: 2.8.1 - - '@formatjs/intl-localematcher@0.5.4': - dependencies: - tslib: 2.8.1 + '@formatjs/ecma402-abstract': 3.2.0 + '@formatjs/intl-getcanonicallocales': 3.2.2 + '@formatjs/intl-supportedvaluesof': 2.3.0 '@formatjs/intl-localematcher@0.5.8': dependencies: @@ -6451,6 +5918,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@formatjs/intl-localematcher@0.8.2': + dependencies: + '@formatjs/fast-memoize': 3.1.1 + '@formatjs/intl-numberformat@5.7.6': dependencies: '@formatjs/ecma402-abstract': 1.4.0 @@ -6463,14 +5934,18 @@ snapshots: decimal.js: 10.6.0 tslib: 2.8.1 - '@formatjs/intl-pluralrules@5.4.6': + '@formatjs/intl-pluralrules@6.3.1': dependencies: - '@formatjs/ecma402-abstract': 2.3.6 - '@formatjs/intl-localematcher': 0.6.2 - decimal.js: 10.6.0 - tslib: 2.8.1 + '@formatjs/bigdecimal': 0.2.0 + '@formatjs/ecma402-abstract': 3.2.0 + '@formatjs/intl-localematcher': 0.8.2 - '@formatjs/intl@2.10.15(typescript@4.8.4)': + '@formatjs/intl-supportedvaluesof@2.3.0': + dependencies: + '@formatjs/ecma402-abstract': 3.2.0 + '@formatjs/fast-memoize': 3.1.1 + + '@formatjs/intl@2.10.15(typescript@5.9.3)': dependencies: '@formatjs/ecma402-abstract': 2.2.4 '@formatjs/fast-memoize': 2.2.3 @@ -6480,35 +5955,35 @@ snapshots: intl-messageformat: 10.7.7 tslib: 2.8.1 optionalDependencies: - typescript: 4.8.4 + typescript: 5.9.3 '@formatjs/ts-transformer@2.13.0': dependencies: intl-messageformat-parser: 6.1.2 tslib: 2.8.1 - typescript: 4.8.4 + typescript: 4.9.5 - '@fortawesome/fontawesome-common-types@7.1.0': {} + '@fortawesome/fontawesome-common-types@7.2.0': {} - '@fortawesome/fontawesome-svg-core@7.1.0': + '@fortawesome/fontawesome-svg-core@7.2.0': dependencies: - '@fortawesome/fontawesome-common-types': 7.1.0 + '@fortawesome/fontawesome-common-types': 7.2.0 - '@fortawesome/free-brands-svg-icons@7.1.0': + '@fortawesome/free-brands-svg-icons@7.2.0': dependencies: - '@fortawesome/fontawesome-common-types': 7.1.0 + '@fortawesome/fontawesome-common-types': 7.2.0 - '@fortawesome/free-regular-svg-icons@7.1.0': + '@fortawesome/free-regular-svg-icons@7.2.0': dependencies: - '@fortawesome/fontawesome-common-types': 7.1.0 + '@fortawesome/fontawesome-common-types': 7.2.0 - '@fortawesome/free-solid-svg-icons@7.1.0': + '@fortawesome/free-solid-svg-icons@7.2.0': dependencies: - '@fortawesome/fontawesome-common-types': 7.1.0 + '@fortawesome/fontawesome-common-types': 7.2.0 - '@fortawesome/react-fontawesome@0.2.6(@fortawesome/fontawesome-svg-core@7.1.0)(react@17.0.2)': + '@fortawesome/react-fontawesome@0.2.6(@fortawesome/fontawesome-svg-core@7.2.0)(react@17.0.2)': dependencies: - '@fortawesome/fontawesome-svg-core': 7.1.0 + '@fortawesome/fontawesome-svg-core': 7.2.0 prop-types: 15.8.1 react: 17.0.2 @@ -6518,32 +5993,32 @@ snapshots: graphql: 16.11.0 tslib: 2.6.3 - '@graphql-codegen/cli@5.0.7(@parcel/watcher@2.5.1)(@types/node@18.19.130)(graphql@16.11.0)(typescript@4.8.4)': + '@graphql-codegen/cli@5.0.7(@parcel/watcher@2.5.1)(@types/node@20.19.37)(graphql@16.11.0)(typescript@5.9.3)': dependencies: - '@babel/generator': 7.28.3 - '@babel/template': 7.27.2 - '@babel/types': 7.28.4 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 '@graphql-codegen/client-preset': 4.8.3(graphql@16.11.0) '@graphql-codegen/core': 4.0.2(graphql@16.11.0) '@graphql-codegen/plugin-helpers': 5.1.1(graphql@16.11.0) '@graphql-tools/apollo-engine-loader': 8.0.22(graphql@16.11.0) '@graphql-tools/code-file-loader': 8.1.22(graphql@16.11.0) '@graphql-tools/git-loader': 8.0.26(graphql@16.11.0) - '@graphql-tools/github-loader': 8.0.22(@types/node@18.19.130)(graphql@16.11.0) + '@graphql-tools/github-loader': 8.0.22(@types/node@20.19.37)(graphql@16.11.0) '@graphql-tools/graphql-file-loader': 8.1.2(graphql@16.11.0) '@graphql-tools/json-file-loader': 8.0.20(graphql@16.11.0) '@graphql-tools/load': 8.1.2(graphql@16.11.0) - '@graphql-tools/prisma-loader': 8.0.17(@types/node@18.19.130)(graphql@16.11.0) - '@graphql-tools/url-loader': 8.0.33(@types/node@18.19.130)(graphql@16.11.0) + '@graphql-tools/prisma-loader': 8.0.17(@types/node@20.19.37)(graphql@16.11.0) + '@graphql-tools/url-loader': 8.0.33(@types/node@20.19.37)(graphql@16.11.0) '@graphql-tools/utils': 10.9.1(graphql@16.11.0) '@whatwg-node/fetch': 0.10.11 chalk: 4.1.2 - cosmiconfig: 8.3.6(typescript@4.8.4) + cosmiconfig: 8.3.6(typescript@5.9.3) debounce: 1.2.1 detect-indent: 6.1.0 graphql: 16.11.0 - graphql-config: 5.1.5(@types/node@18.19.130)(graphql@16.11.0)(typescript@4.8.4) - inquirer: 8.2.7(@types/node@18.19.130) + graphql-config: 5.1.5(@types/node@20.19.37)(graphql@16.11.0)(typescript@5.9.3) + inquirer: 8.2.7(@types/node@20.19.37) is-glob: 4.0.3 jiti: 1.21.7 json-to-pretty-yaml: 1.2.2 @@ -6574,8 +6049,8 @@ snapshots: '@graphql-codegen/client-preset@4.8.3(graphql@16.11.0)': dependencies: - '@babel/helper-plugin-utils': 7.27.1 - '@babel/template': 7.27.2 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 '@graphql-codegen/add': 5.0.3(graphql@16.11.0) '@graphql-codegen/gql-tag-operations': 4.0.17(graphql@16.11.0) '@graphql-codegen/plugin-helpers': 5.1.1(graphql@16.11.0) @@ -6798,7 +6273,7 @@ snapshots: - uWebSockets.js - utf-8-validate - '@graphql-tools/executor-http@1.3.3(@types/node@18.19.130)(graphql@16.11.0)': + '@graphql-tools/executor-http@1.3.3(@types/node@20.19.37)(graphql@16.11.0)': dependencies: '@graphql-hive/signal': 1.0.0 '@graphql-tools/executor-common': 0.0.4(graphql@16.11.0) @@ -6808,7 +6283,7 @@ snapshots: '@whatwg-node/fetch': 0.10.11 '@whatwg-node/promise-helpers': 1.3.2 graphql: 16.11.0 - meros: 1.3.2(@types/node@18.19.130) + meros: 1.3.2(@types/node@20.19.37) tslib: 2.8.1 transitivePeerDependencies: - '@types/node' @@ -6847,9 +6322,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@graphql-tools/github-loader@8.0.22(@types/node@18.19.130)(graphql@16.11.0)': + '@graphql-tools/github-loader@8.0.22(@types/node@20.19.37)(graphql@16.11.0)': dependencies: - '@graphql-tools/executor-http': 1.3.3(@types/node@18.19.130)(graphql@16.11.0) + '@graphql-tools/executor-http': 1.3.3(@types/node@20.19.37)(graphql@16.11.0) '@graphql-tools/graphql-tag-pluck': 8.3.21(graphql@16.11.0) '@graphql-tools/utils': 10.9.1(graphql@16.11.0) '@whatwg-node/fetch': 0.10.11 @@ -6874,11 +6349,11 @@ snapshots: '@graphql-tools/graphql-tag-pluck@8.3.21(graphql@16.11.0)': dependencies: - '@babel/core': 7.28.4 - '@babel/parser': 7.28.4 - '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.28.4) - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 '@graphql-tools/utils': 10.9.1(graphql@16.11.0) graphql: 16.11.0 tslib: 2.8.1 @@ -6925,11 +6400,11 @@ snapshots: '@graphql-tools/optimize@2.0.0(graphql@16.11.0)': dependencies: graphql: 16.11.0 - tslib: 2.6.3 + tslib: 2.8.1 - '@graphql-tools/prisma-loader@8.0.17(@types/node@18.19.130)(graphql@16.11.0)': + '@graphql-tools/prisma-loader@8.0.17(@types/node@20.19.37)(graphql@16.11.0)': dependencies: - '@graphql-tools/url-loader': 8.0.33(@types/node@18.19.130)(graphql@16.11.0) + '@graphql-tools/url-loader': 8.0.33(@types/node@20.19.37)(graphql@16.11.0) '@graphql-tools/utils': 10.9.1(graphql@16.11.0) '@types/js-yaml': 4.0.9 '@whatwg-node/fetch': 0.10.11 @@ -6941,7 +6416,7 @@ snapshots: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 jose: 5.10.0 - js-yaml: 4.1.0 + js-yaml: 4.1.1 lodash: 4.17.21 scuid: 1.1.0 tslib: 2.8.1 @@ -6971,7 +6446,7 @@ snapshots: '@ardatan/relay-compiler': 12.0.3(graphql@16.11.0) '@graphql-tools/utils': 10.9.1(graphql@16.11.0) graphql: 16.11.0 - tslib: 2.6.3 + tslib: 2.8.1 transitivePeerDependencies: - encoding @@ -6982,10 +6457,10 @@ snapshots: graphql: 16.11.0 tslib: 2.8.1 - '@graphql-tools/url-loader@8.0.33(@types/node@18.19.130)(graphql@16.11.0)': + '@graphql-tools/url-loader@8.0.33(@types/node@20.19.37)(graphql@16.11.0)': dependencies: '@graphql-tools/executor-graphql-ws': 2.0.7(graphql@16.11.0) - '@graphql-tools/executor-http': 1.3.3(@types/node@18.19.130)(graphql@16.11.0) + '@graphql-tools/executor-http': 1.3.3(@types/node@20.19.37)(graphql@16.11.0) '@graphql-tools/executor-legacy-ws': 1.1.19(graphql@16.11.0) '@graphql-tools/utils': 10.9.1(graphql@16.11.0) '@graphql-tools/wrap': 10.1.4(graphql@16.11.0) @@ -7037,7 +6512,7 @@ snapshots: dependencies: '@humanwhocodes/object-schema': 2.0.3 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -7045,12 +6520,12 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} - '@inquirer/external-editor@1.0.2(@types/node@18.19.130)': + '@inquirer/external-editor@1.0.2(@types/node@20.19.37)': dependencies: chardet: 2.1.0 iconv-lite: 0.7.0 optionalDependencies: - '@types/node': 18.19.130 + '@types/node': 20.19.37 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -7083,6 +6558,14 @@ snapshots: '@juggle/resize-observer@3.4.0': {} + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + '@mapbox/hast-util-table-cell-style@0.2.1': dependencies: unist-util-visit: 1.4.1 @@ -7097,7 +6580,7 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 + fastq: 1.20.1 '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -7188,72 +6671,81 @@ snapshots: dequal: 2.0.3 react: 17.0.2 - '@rolldown/pluginutils@1.0.0-beta.43': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rollup/rollup-android-arm-eabi@4.53.1': + '@rollup/rollup-android-arm-eabi@4.60.2': optional: true - '@rollup/rollup-android-arm64@4.53.1': + '@rollup/rollup-android-arm64@4.60.2': optional: true - '@rollup/rollup-darwin-arm64@4.53.1': + '@rollup/rollup-darwin-arm64@4.60.2': optional: true - '@rollup/rollup-darwin-x64@4.53.1': + '@rollup/rollup-darwin-x64@4.60.2': optional: true - '@rollup/rollup-freebsd-arm64@4.53.1': + '@rollup/rollup-freebsd-arm64@4.60.2': optional: true - '@rollup/rollup-freebsd-x64@4.53.1': + '@rollup/rollup-freebsd-x64@4.60.2': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.53.1': + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.53.1': + '@rollup/rollup-linux-arm-musleabihf@4.60.2': optional: true - '@rollup/rollup-linux-arm64-gnu@4.53.1': + '@rollup/rollup-linux-arm64-gnu@4.60.2': optional: true - '@rollup/rollup-linux-arm64-musl@4.53.1': + '@rollup/rollup-linux-arm64-musl@4.60.2': optional: true - '@rollup/rollup-linux-loong64-gnu@4.53.1': + '@rollup/rollup-linux-loong64-gnu@4.60.2': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.53.1': + '@rollup/rollup-linux-loong64-musl@4.60.2': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.53.1': + '@rollup/rollup-linux-ppc64-gnu@4.60.2': optional: true - '@rollup/rollup-linux-riscv64-musl@4.53.1': + '@rollup/rollup-linux-ppc64-musl@4.60.2': optional: true - '@rollup/rollup-linux-s390x-gnu@4.53.1': + '@rollup/rollup-linux-riscv64-gnu@4.60.2': optional: true - '@rollup/rollup-linux-x64-gnu@4.53.1': + '@rollup/rollup-linux-riscv64-musl@4.60.2': optional: true - '@rollup/rollup-linux-x64-musl@4.53.1': + '@rollup/rollup-linux-s390x-gnu@4.60.2': optional: true - '@rollup/rollup-openharmony-arm64@4.53.1': + '@rollup/rollup-linux-x64-gnu@4.60.2': optional: true - '@rollup/rollup-win32-arm64-msvc@4.53.1': + '@rollup/rollup-linux-x64-musl@4.60.2': optional: true - '@rollup/rollup-win32-ia32-msvc@4.53.1': + '@rollup/rollup-openbsd-x64@4.60.2': optional: true - '@rollup/rollup-win32-x64-gnu@4.53.1': + '@rollup/rollup-openharmony-arm64@4.60.2': optional: true - '@rollup/rollup-win32-x64-msvc@4.53.1': + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true '@rtsao/scc@1.1.0': {} @@ -7267,6 +6759,8 @@ snapshots: video.js: 7.21.7 webcomponents.js: https://codeload.github.com/webcomponents/webcomponentsjs/tar.gz/8a2e40557b177e2cca0def2553f84c8269c8f93e + '@sindresorhus/merge-streams@4.0.0': {} + '@theguild/federation-composition@0.20.1(graphql@16.11.0)': dependencies: constant-case: 3.0.4 @@ -7285,7 +6779,7 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@tweenjs/tween.js@18.6.4': {} + '@tweenjs/tween.js@23.1.3': {} '@types/apollo-upload-client@18.0.1(@types/react@17.0.89)(graphql-ws@5.16.2(graphql@16.11.0))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)': dependencies: @@ -7301,38 +6795,34 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.29.0 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.4 - - '@types/cookie@0.3.3': {} + '@babel/types': 7.29.0 '@types/crypto-js@4.2.2': {} - '@types/dom-screen-wake-lock@1.0.3': {} - '@types/estree@1.0.8': {} '@types/extract-files@13.0.2': {} '@types/fs-extra@9.0.13': dependencies: - '@types/node': 18.19.130 + '@types/node': 20.19.37 '@types/history@4.7.11': {} @@ -7363,9 +6853,9 @@ snapshots: '@types/mousetrap@1.6.15': {} - '@types/node@18.19.130': + '@types/node@20.19.37': dependencies: - undici-types: 5.26.5 + undici-types: 6.21.0 '@types/normalize-package-data@2.4.4': {} @@ -7429,18 +6919,17 @@ snapshots: dependencies: schema-utils: 2.7.1 - '@types/semver@7.7.1': {} - '@types/stats.js@0.17.4': {} - '@types/three@0.154.0': + '@types/three@0.183.1': dependencies: - '@tweenjs/tween.js': 18.6.4 + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 '@types/stats.js': 0.17.4 '@types/webxr': 0.5.24 - fflate: 0.6.10 - lil-gui: 0.17.0 - meshoptimizer: 0.18.1 + '@webgpu/types': 0.1.69 + fflate: 0.8.2 + meshoptimizer: 1.0.1 '@types/ua-parser-js@0.7.39': {} @@ -7462,97 +6951,94 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 18.19.130 + '@types/node': 20.19.37 - '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1)(typescript@4.8.4)': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': dependencies: - '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.8.4) - '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/type-utils': 5.62.0(eslint@8.57.1)(typescript@4.8.4) - '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@4.8.4) - debug: 4.4.3 + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.3.2 - natural-compare-lite: 1.4.0 - semver: 7.7.3 - tsutils: 3.21.0(typescript@4.8.4) + natural-compare: 1.4.0 + ts-api-utils: 1.4.3(typescript@5.9.3) optionalDependencies: - typescript: 4.8.4 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4)': + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@4.8.4) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 7.18.0 debug: 4.4.3 eslint: 8.57.1 optionalDependencies: - typescript: 4.8.4 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@5.62.0': + '@typescript-eslint/scope-manager@7.18.0': dependencies: - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/visitor-keys': 5.62.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 - '@typescript-eslint/type-utils@5.62.0(eslint@8.57.1)(typescript@4.8.4)': + '@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: - '@typescript-eslint/typescript-estree': 5.62.0(typescript@4.8.4) - '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@4.8.4) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) debug: 4.4.3 eslint: 8.57.1 - tsutils: 3.21.0(typescript@4.8.4) + ts-api-utils: 1.4.3(typescript@5.9.3) optionalDependencies: - typescript: 4.8.4 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@5.62.0': {} + '@typescript-eslint/types@7.18.0': {} - '@typescript-eslint/typescript-estree@5.62.0(typescript@4.8.4)': + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/visitor-keys': 5.62.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.7.3 - tsutils: 3.21.0(typescript@4.8.4) + minimatch: 9.0.9 + semver: 7.7.4 + ts-api-utils: 1.4.3(typescript@5.9.3) optionalDependencies: - typescript: 4.8.4 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@4.8.4)': + '@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) - '@types/json-schema': 7.0.15 - '@types/semver': 7.7.1 - '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@4.8.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) eslint: 8.57.1 - eslint-scope: 5.1.1 - semver: 7.7.3 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/visitor-keys@5.62.0': + '@typescript-eslint/visitor-keys@7.18.0': dependencies: - '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/types': 7.18.0 eslint-visitor-keys: 3.4.3 '@ungap/structured-clone@1.3.0': {} '@videojs/http-streaming@2.16.3(video.js@7.21.7)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@videojs/vhs-utils': 3.0.5 aes-decrypter: 3.1.3 global: 4.4.0 @@ -7563,42 +7049,29 @@ snapshots: '@videojs/vhs-utils@3.0.5': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 global: 4.4.0 url-toolkit: 2.2.5 '@videojs/xhr@2.6.0': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 global: 4.4.0 is-function: 1.0.2 - '@vitejs/plugin-legacy@5.4.3(terser@5.44.0)(vite@5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.1))': dependencies: - '@babel/core': 7.28.4 - '@babel/preset-env': 7.28.3(@babel/core@7.28.4) - browserslist: 4.26.3 - browserslist-to-esbuild: 2.1.1(browserslist@4.26.3) - core-js: 3.46.0 - magic-string: 0.30.19 - regenerator-runtime: 0.14.1 - systemjs: 6.15.1 - terser: 5.44.0 - vite: 5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0) + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.1.0(vite@5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0))': - dependencies: - '@babel/core': 7.28.4 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.4) - '@rolldown/pluginutils': 1.0.0-beta.43 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0) - transitivePeerDependencies: - - supports-color + '@webgpu/types@0.1.69': {} '@whatwg-node/disposablestack@0.0.6': dependencies: @@ -7637,21 +7110,21 @@ snapshots: dependencies: tslib: 2.8.1 - '@xmldom/xmldom@0.8.11': {} + '@xmldom/xmldom@0.8.13': {} - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 acorn-walk@8.3.4: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn@8.15.0: {} + acorn@8.16.0: {} aes-decrypter@3.1.3: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@videojs/vhs-utils': 3.0.5 global: 4.4.0 pkcs7: 1.0.4 @@ -7663,18 +7136,18 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ajv-keywords@3.5.2(ajv@6.12.6): + ajv-keywords@3.5.2(ajv@6.14.0): dependencies: - ajv: 6.12.6 + ajv: 6.14.0 - ajv@6.12.6: + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.0 @@ -7687,6 +7160,8 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -7802,39 +7277,15 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 cosmiconfig: 7.1.0 resolve: 1.22.11 - babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.28.4): - dependencies: - '@babel/compat-data': 7.28.4 - '@babel/core': 7.28.4 - '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.28.4) - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.28.4): - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.28.4) - core-js-compat: 3.46.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-polyfill-regenerator@0.6.5(@babel/core@7.28.4): - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.28.4) - transitivePeerDependencies: - - supports-color - babel-plugin-react-intl@7.9.4: dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/types': 7.28.4 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/types': 7.29.0 '@formatjs/ts-transformer': 2.13.0 '@types/babel__core': 7.20.5 '@types/fs-extra': 9.0.13 @@ -7848,35 +7299,35 @@ snapshots: babel-plugin-syntax-trailing-function-commas@7.0.0-beta.0: {} - babel-preset-fbjs@3.4.0(@babel/core@7.28.4): + babel-preset-fbjs@3.4.0(@babel/core@7.29.0): dependencies: - '@babel/core': 7.28.4 - '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.28.4) - '@babel/plugin-proposal-object-rest-spread': 7.20.7(@babel/core@7.28.4) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.4) - '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.4) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-block-scoping': 7.28.4(@babel/core@7.28.4) - '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.28.4) - '@babel/plugin-transform-computed-properties': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-destructuring': 7.28.0(@babel/core@7.28.4) - '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.4) - '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.28.4) - '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-spread': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.28.4) + '@babel/core': 7.29.0 + '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.29.0) + '@babel/plugin-proposal-object-rest-spread': 7.20.7(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.4(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) babel-plugin-syntax-trailing-function-commas: 7.0.0-beta.0 transitivePeerDependencies: - supports-color @@ -7885,15 +7336,13 @@ snapshots: balanced-match@1.0.2: {} - balanced-match@2.0.0: {} - base64-blob@1.4.1: dependencies: b64-to-blob: 1.2.19 base64-js@1.5.1: {} - baseline-browser-mapping@2.8.19: {} + baseline-browser-mapping@2.10.12: {} bcp-47-match@1.0.3: {} @@ -7919,12 +7368,12 @@ snapshots: jquery: 3.7.1 popper.js: 1.16.1 - brace-expansion@1.1.12: + brace-expansion@1.1.14: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@2.0.3: dependencies: balanced-match: 1.0.2 @@ -7932,18 +7381,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist-to-esbuild@2.1.1(browserslist@4.26.3): + browserslist@4.28.1: dependencies: - browserslist: 4.26.3 - meow: 13.2.0 - - browserslist@4.26.3: - dependencies: - baseline-browser-mapping: 2.8.19 - caniuse-lite: 1.0.30001751 - electron-to-chromium: 1.5.238 - node-releases: 2.0.26 - update-browserslist-db: 1.1.3(browserslist@4.26.3) + baseline-browser-mapping: 2.10.12 + caniuse-lite: 1.0.30001782 + electron-to-chromium: 1.5.329 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) bser@2.1.1: dependencies: @@ -7956,6 +7400,14 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + cacheable@2.3.4: + dependencies: + '@cacheable/memory': 2.0.8 + '@cacheable/utils': 2.4.1 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.9.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -7986,18 +7438,9 @@ snapshots: map-obj: 4.3.0 quick-lru: 4.0.1 - camelcase-keys@7.0.2: - dependencies: - camelcase: 6.3.0 - map-obj: 4.3.0 - quick-lru: 5.1.1 - type-fest: 1.4.0 - camelcase@5.3.1: {} - camelcase@6.3.0: {} - - caniuse-lite@1.0.30001751: {} + caniuse-lite@1.0.30001782: {} capital-case@1.0.4: dependencies: @@ -8121,13 +7564,7 @@ snapshots: convert-source-map@2.0.0: {} - cookie@0.4.2: {} - - core-js-compat@3.46.0: - dependencies: - browserslist: 4.26.3 - - core-js@3.46.0: {} + cookie@1.1.1: {} cosmiconfig@7.1.0: dependencies: @@ -8135,16 +7572,25 @@ snapshots: import-fresh: 3.3.1 parse-json: 5.2.0 path-type: 4.0.0 - yaml: 1.10.2 + yaml: 1.10.3 - cosmiconfig@8.3.6(typescript@4.8.4): + cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: - typescript: 4.8.4 + typescript: 5.9.3 + + cosmiconfig@9.0.1(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 create-require@1.1.1: {} @@ -8166,11 +7612,11 @@ snapshots: crypto-js@4.2.0: {} - css-functions-list@3.2.3: {} + css-functions-list@3.3.3: {} - css-tree@2.3.1: + css-tree@3.2.1: dependencies: - mdn-data: 2.0.30 + mdn-data: 2.27.1 source-map-js: 1.2.1 cssesc@3.0.0: {} @@ -8216,7 +7662,7 @@ snapshots: date-fns@2.30.0: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 debounce@1.2.1: {} @@ -8239,8 +7685,6 @@ snapshots: decamelize@1.2.0: {} - decamelize@5.0.1: {} - decimal.js@10.6.0: {} deep-is@0.1.4: {} @@ -8267,11 +7711,16 @@ snapshots: dequal@2.0.3: {} + detect-europe-js@0.1.2: {} + detect-indent@6.1.0: {} detect-libc@1.0.3: optional: true + detect-libc@2.1.2: + optional: true + diacritics@1.3.0: {} diff@4.0.2: {} @@ -8290,7 +7739,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 csstype: 3.1.3 dom-walk@0.1.2: {} @@ -8310,12 +7759,14 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.238: {} + electron-to-chromium@1.5.329: {} emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} + env-paths@2.2.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -8423,31 +7874,34 @@ snapshots: es6-promise@4.2.8: {} - esbuild@0.21.5: + esbuild@0.27.7: optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 escalade@3.2.0: {} @@ -8455,28 +7909,29 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1))(eslint@8.57.1): + eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: confusing-browser-globals: 1.0.11 eslint: 8.57.1 - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1) object.assign: 4.1.7 object.entries: 1.1.9 semver: 6.3.1 - eslint-config-airbnb-typescript@17.1.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1)(typescript@4.8.4))(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1))(eslint@8.57.1): + eslint-config-airbnb-typescript@18.0.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: - '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1)(typescript@4.8.4) - '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.8.4) + '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 - eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1) + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + transitivePeerDependencies: + - eslint-plugin-import - eslint-config-airbnb@19.0.4(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint@8.57.1): + eslint-config-airbnb@19.0.4(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1))(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint@8.57.1): dependencies: eslint: 8.57.1 - eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1) + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) @@ -8495,17 +7950,27 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.8.4) + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint@8.57.1): + eslint-plugin-deprecation@3.0.0(eslint@8.57.1)(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + tslib: 2.8.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -8516,11 +7981,11 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.8.4))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 @@ -8528,7 +7993,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.8.4) + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -8548,7 +8013,7 @@ snapshots: hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 @@ -8569,7 +8034,7 @@ snapshots: estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 + minimatch: 3.1.5 object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 @@ -8579,11 +8044,6 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - eslint-scope@7.2.2: dependencies: esrecurse: 4.3.0 @@ -8593,15 +8053,15 @@ snapshots: eslint@8.57.1: dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) - '@eslint-community/regexpp': 4.12.1 + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 '@eslint/eslintrc': 2.1.4 '@eslint/js': 8.57.1 '@humanwhocodes/config-array': 0.13.0 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 '@ungap/structured-clone': 1.3.0 - ajv: 6.12.6 + ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -8610,7 +8070,7 @@ snapshots: eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 espree: 9.6.1 - esquery: 1.6.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 6.0.1 @@ -8622,11 +8082,11 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 - js-yaml: 4.1.0 + js-yaml: 4.1.1 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 strip-ansi: 6.0.1 @@ -8636,13 +8096,13 @@ snapshots: espree@9.6.1: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 3.4.3 esprima@4.0.1: {} - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -8650,8 +8110,6 @@ snapshots: dependencies: estraverse: 5.3.0 - estraverse@4.3.0: {} - estraverse@5.3.0: {} esutils@2.0.3: {} @@ -8666,7 +8124,7 @@ snapshots: extract-react-intl-messages@4.1.1: dependencies: - '@babel/core': 7.28.4 + '@babel/core': 7.29.0 babel-plugin-react-intl: 7.9.4 flat: 5.0.2 glob: 7.2.3 @@ -8705,7 +8163,7 @@ snapshots: fastest-levenshtein@1.0.16: {} - fastq@1.19.1: + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -8727,22 +8185,26 @@ snapshots: transitivePeerDependencies: - encoding + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 - fflate@0.6.10: {} + fflate@0.8.2: {} figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 - file-entry-cache@6.0.1: + file-entry-cache@11.1.2: dependencies: - flat-cache: 3.2.0 + flat-cache: 6.1.22 - file-entry-cache@7.0.2: + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -8762,17 +8224,23 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flag-icons@6.15.0: {} + flag-icons@7.5.0: {} flat-cache@3.2.0: dependencies: - flatted: 3.3.3 + flatted: 3.4.2 keyv: 4.5.4 rimraf: 3.0.2 + flat-cache@6.1.22: + dependencies: + cacheable: 2.3.4 + flatted: 3.4.2 + hookified: 1.15.1 + flat@5.0.2: {} - flatted@3.3.3: {} + flatted@3.4.2: {} flexbin@0.2.0: {} @@ -8784,13 +8252,13 @@ snapshots: dependencies: fetch-blob: 3.2.0 - formik@2.4.6(@types/react@17.0.89)(react@17.0.2): + formik@2.4.9(@types/react@17.0.89)(react@17.0.2): dependencies: '@types/hoist-non-react-statics': 3.3.7(@types/react@17.0.89) deepmerge: 2.2.1 hoist-non-react-statics: 3.3.2 lodash: 4.17.21 - lodash-es: 4.17.23 + lodash-es: 4.18.1 react: 17.0.2 react-fast-compare: 2.0.4 tiny-warning: 1.0.3 @@ -8835,6 +8303,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8874,7 +8344,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.2 + minimatch: 3.1.5 once: 1.4.0 path-is-absolute: 1.0.1 @@ -8890,7 +8360,7 @@ snapshots: global@4.4.0: dependencies: - min-document: 2.19.0 + min-document: 2.19.2 process: 0.11.10 globals@13.24.0: @@ -8911,6 +8381,15 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + globby@16.2.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + globjoin@0.1.4: {} globrex@0.1.2: {} @@ -8921,18 +8400,18 @@ snapshots: graphemer@1.4.0: {} - graphql-config@5.1.5(@types/node@18.19.130)(graphql@16.11.0)(typescript@4.8.4): + graphql-config@5.1.5(@types/node@20.19.37)(graphql@16.11.0)(typescript@5.9.3): dependencies: '@graphql-tools/graphql-file-loader': 8.1.2(graphql@16.11.0) '@graphql-tools/json-file-loader': 8.0.20(graphql@16.11.0) '@graphql-tools/load': 8.1.2(graphql@16.11.0) '@graphql-tools/merge': 9.1.1(graphql@16.11.0) - '@graphql-tools/url-loader': 8.0.33(@types/node@18.19.130)(graphql@16.11.0) + '@graphql-tools/url-loader': 8.0.33(@types/node@20.19.37)(graphql@16.11.0) '@graphql-tools/utils': 10.9.1(graphql@16.11.0) - cosmiconfig: 8.3.6(typescript@4.8.4) + cosmiconfig: 8.3.6(typescript@5.9.3) graphql: 16.11.0 jiti: 2.6.1 - minimatch: 9.0.5 + minimatch: 9.0.9 string-env-interpolation: 1.0.1 tslib: 2.8.1 transitivePeerDependencies: @@ -8976,6 +8455,8 @@ snapshots: has-flag@4.0.0: {} + has-flag@5.0.1: {} + has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 @@ -8990,6 +8471,10 @@ snapshots: dependencies: has-symbols: 1.1.0 + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -9011,7 +8496,7 @@ snapshots: history@4.10.1: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 loose-envify: 1.4.0 resolve-pathname: 3.0.0 tiny-invariant: 1.3.3 @@ -9022,15 +8507,15 @@ snapshots: dependencies: react-is: 16.13.1 - hosted-git-info@2.8.9: {} + hookified@1.15.1: {} - hosted-git-info@4.1.0: - dependencies: - lru-cache: 6.0.0 + hookified@2.1.1: {} + + hosted-git-info@2.8.9: {} html-entities@1.4.0: {} - html-tags@3.3.1: {} + html-tags@5.1.0: {} http-proxy-agent@7.0.2: dependencies: @@ -9058,11 +8543,13 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + immediate@3.0.6: {} immutable@3.7.6: {} - immutable@5.1.4: {} + immutable@5.1.5: {} import-fresh@3.3.1: dependencies: @@ -9071,7 +8558,7 @@ snapshots: import-from@4.0.0: {} - import-lazy@4.0.0: {} + import-meta-resolve@4.2.0: {} imsc@1.1.5: dependencies: @@ -9081,8 +8568,6 @@ snapshots: indent-string@4.0.0: {} - indent-string@5.0.0: {} - individual@2.0.0: {} inflight@1.0.6: @@ -9096,9 +8581,9 @@ snapshots: inline-style-parser@0.1.1: {} - inquirer@8.2.7(@types/node@18.19.130): + inquirer@8.2.7(@types/node@20.19.37): dependencies: - '@inquirer/external-editor': 1.0.2(@types/node@18.19.130) + '@inquirer/external-editor': 1.0.2(@types/node@20.19.37) ansi-escapes: 4.3.2 chalk: 4.1.2 cli-cursor: 3.1.0 @@ -9243,6 +8728,8 @@ snapshots: is-path-inside@3.0.3: {} + is-path-inside@4.0.0: {} + is-plain-obj@1.1.0: {} is-plain-obj@2.1.0: {} @@ -9268,6 +8755,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-standalone-pwa@0.1.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -9342,7 +8831,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -9392,9 +8881,11 @@ snapshots: dependencies: json-buffer: 3.0.1 - kind-of@6.0.3: {} + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 - known-css-properties@0.29.0: {} + kind-of@6.0.3: {} language-subtag-registry@0.3.23: {} @@ -9411,7 +8902,55 @@ snapshots: dependencies: immediate: 3.0.6 - lil-gui@0.17.0: {} + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + optional: true lines-and-columns@1.2.4: {} @@ -9445,9 +8984,7 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash-es@4.17.23: {} - - lodash.debounce@4.0.8: {} + lodash-es@4.18.1: {} lodash.merge@4.6.2: {} @@ -9491,20 +9028,12 @@ snapshots: dependencies: yallist: 3.1.1 - lru-cache@6.0.0: - dependencies: - yallist: 4.0.0 - m3u8-parser@4.8.0: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@videojs/vhs-utils': 3.0.5 global: 4.4.0 - magic-string@0.30.19: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - make-dir@3.1.0: dependencies: semver: 6.3.1 @@ -9523,7 +9052,7 @@ snapshots: math-intrinsics@1.1.0: {} - mathml-tag-names@2.1.3: {} + mathml-tag-names@4.0.0: {} mdast-util-definitions@4.0.0: dependencies: @@ -9598,28 +9127,13 @@ snapshots: mdast-util-to-string@2.0.0: {} - mdn-data@2.0.30: {} + mdn-data@2.27.1: {} mdurl@1.0.1: {} memoize-one@6.0.0: {} - meow@10.1.5: - dependencies: - '@types/minimist': 1.2.5 - camelcase-keys: 7.0.2 - decamelize: 5.0.1 - decamelize-keys: 1.1.1 - hard-rejection: 2.1.0 - minimist-options: 4.1.0 - normalize-package-data: 3.0.3 - read-pkg-up: 8.0.0 - redent: 4.0.0 - trim-newlines: 4.1.1 - type-fest: 1.4.0 - yargs-parser: 20.2.9 - - meow@13.2.0: {} + meow@14.1.0: {} meow@6.1.1: dependencies: @@ -9637,11 +9151,11 @@ snapshots: merge2@1.4.1: {} - meros@1.3.2(@types/node@18.19.130): + meros@1.3.2(@types/node@20.19.37): optionalDependencies: - '@types/node': 18.19.130 + '@types/node': 20.19.37 - meshoptimizer@0.18.1: {} + meshoptimizer@1.0.1: {} micromark-extension-gfm-autolink-literal@0.5.7: dependencies: @@ -9690,23 +9204,23 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mimic-fn@2.1.0: {} - min-document@2.19.0: + min-document@2.19.2: dependencies: dom-walk: 0.1.2 min-indent@1.0.1: {} - minimatch@3.1.2: + minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.14 - minimatch@9.0.5: + minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.3 minimist-options@4.1.0: dependencies: @@ -9726,9 +9240,9 @@ snapshots: mpd-parser@0.22.1: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@videojs/vhs-utils': 3.0.5 - '@xmldom/xmldom': 0.8.11 + '@xmldom/xmldom': 0.8.13 global: 4.4.0 ms@2.1.3: {} @@ -9737,13 +9251,11 @@ snapshots: mux.js@6.0.1: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 global: 4.4.0 nanoid@3.3.11: {} - natural-compare-lite@1.4.0: {} - natural-compare@1.4.0: {} no-case@3.0.4: @@ -9768,7 +9280,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.26: {} + node-releases@2.0.36: {} normalize-package-data@2.5.0: dependencies: @@ -9777,21 +9289,12 @@ snapshots: semver: 5.7.2 validate-npm-package-license: 3.0.4 - normalize-package-data@3.0.3: - dependencies: - hosted-git-info: 4.1.0 - is-core-module: 2.16.1 - semver: 7.7.3 - validate-npm-package-license: 3.0.4 - normalize-path@2.1.1: dependencies: remove-trailing-separator: 1.1.0 normalize-path@3.0.0: {} - normalize-url@4.5.1: {} - nosleep.js@0.7.0: {} nullthrows@1.1.1: {} @@ -9928,7 +9431,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.0 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -9967,40 +9470,40 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} + + picomatch@4.0.4: {} pify@5.0.0: {} pkcs7@1.0.4: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 popper.js@1.16.1: {} possible-typed-array-names@1.1.0: {} - postcss-resolve-nested-selector@0.1.6: {} - - postcss-safe-parser@6.0.0(postcss@8.5.6): + postcss-safe-parser@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-scss@4.0.9(postcss@8.5.6): + postcss-scss@4.0.9(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-selector-parser@6.1.2: + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-sorting@8.0.2(postcss@8.5.6): + postcss-sorting@10.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser@4.2.0: {} - postcss@8.5.6: + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -10042,15 +9545,17 @@ snapshots: punycode@2.3.1: {} + qified@0.9.0: + dependencies: + hookified: 2.1.1 + queue-microtask@1.2.3: {} quick-lru@4.0.1: {} - quick-lru@5.1.1: {} - react-bootstrap@1.6.8(react-dom@17.0.2(react@17.0.2))(react@17.0.2): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@restart/context': 2.1.4(react@17.0.2) '@restart/hooks': 0.4.16(react@17.0.2) '@types/invariant': 2.2.37 @@ -10100,11 +9605,11 @@ snapshots: react-fast-compare: 3.2.2 react-side-effect: 2.1.2(react@17.0.2) - react-intl@6.8.9(react@17.0.2)(typescript@4.8.4): + react-intl@6.8.9(react@17.0.2)(typescript@5.9.3): dependencies: '@formatjs/ecma402-abstract': 2.2.4 '@formatjs/icu-messageformat-parser': 2.9.4 - '@formatjs/intl': 2.10.15(typescript@4.8.4) + '@formatjs/intl': 2.10.15(typescript@5.9.3) '@formatjs/intl-displaynames': 6.8.5 '@formatjs/intl-listformat': 7.7.5 '@types/hoist-non-react-statics': 3.3.7(@types/react@17.0.89) @@ -10114,7 +9619,7 @@ snapshots: react: 17.0.2 tslib: 2.8.1 optionalDependencies: - typescript: 4.8.4 + typescript: 5.9.3 react-is@16.13.1: {} @@ -10127,7 +9632,7 @@ snapshots: react-overlays@5.2.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@popperjs/core': 2.11.8 '@restart/hooks': 0.4.16(react@17.0.2) '@types/warning': 3.0.3 @@ -10173,7 +9678,7 @@ snapshots: react-router-dom@5.3.4(react@17.0.2): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 history: 4.10.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -10190,7 +9695,7 @@ snapshots: react-router@5.3.4(react@17.0.2): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 history: 4.10.1 hoist-non-react-statics: 3.3.2 loose-envify: 1.4.0 @@ -10203,7 +9708,7 @@ snapshots: react-select@5.10.2(@types/react@17.0.89)(react-dom@17.0.2(react@17.0.2))(react@17.0.2): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@emotion/cache': 11.14.0 '@emotion/react': 11.14.0(@types/react@17.0.89)(react@17.0.2) '@floating-ui/dom': 1.7.4 @@ -10224,7 +9729,7 @@ snapshots: react-transition-group@4.4.5(react-dom@17.0.2(react@17.0.2))(react@17.0.2): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -10247,12 +9752,6 @@ snapshots: read-pkg: 5.2.0 type-fest: 0.8.1 - read-pkg-up@8.0.0: - dependencies: - find-up: 5.0.0 - read-pkg: 6.0.0 - type-fest: 1.4.0 - read-pkg@5.2.0: dependencies: '@types/normalize-package-data': 2.4.4 @@ -10260,13 +9759,6 @@ snapshots: parse-json: 5.2.0 type-fest: 0.6.0 - read-pkg@6.0.0: - dependencies: - '@types/normalize-package-data': 2.4.4 - normalize-package-data: 3.0.3 - parse-json: 5.2.0 - type-fest: 1.4.0 - readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -10280,11 +9772,6 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 - redent@4.0.0: - dependencies: - indent-string: 5.0.0 - strip-indent: 4.1.1 - reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -10296,14 +9783,6 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 - regenerate-unicode-properties@10.2.2: - dependencies: - regenerate: 1.4.2 - - regenerate@1.4.2: {} - - regenerator-runtime@0.14.1: {} - regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -10313,21 +9792,6 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 - regexpu-core@6.4.0: - dependencies: - regenerate: 1.4.2 - regenerate-unicode-properties: 10.2.2 - regjsgen: 0.8.0 - regjsparser: 0.13.0 - unicode-match-property-ecmascript: 2.0.0 - unicode-match-property-value-ecmascript: 2.2.1 - - regjsgen@0.8.0: {} - - regjsparser@0.13.0: - dependencies: - jsesc: 3.1.0 - rehackt@0.1.0(@types/react@17.0.89)(react@17.0.2): optionalDependencies: '@types/react': 17.0.89 @@ -10340,7 +9804,7 @@ snapshots: relay-runtime@12.0.0: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 fbjs: 3.0.5 invariant: 2.2.4 transitivePeerDependencies: @@ -10410,32 +9874,35 @@ snapshots: dependencies: glob: 7.2.3 - rollup@4.53.1: + rollup@4.60.2: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.53.1 - '@rollup/rollup-android-arm64': 4.53.1 - '@rollup/rollup-darwin-arm64': 4.53.1 - '@rollup/rollup-darwin-x64': 4.53.1 - '@rollup/rollup-freebsd-arm64': 4.53.1 - '@rollup/rollup-freebsd-x64': 4.53.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.53.1 - '@rollup/rollup-linux-arm-musleabihf': 4.53.1 - '@rollup/rollup-linux-arm64-gnu': 4.53.1 - '@rollup/rollup-linux-arm64-musl': 4.53.1 - '@rollup/rollup-linux-loong64-gnu': 4.53.1 - '@rollup/rollup-linux-ppc64-gnu': 4.53.1 - '@rollup/rollup-linux-riscv64-gnu': 4.53.1 - '@rollup/rollup-linux-riscv64-musl': 4.53.1 - '@rollup/rollup-linux-s390x-gnu': 4.53.1 - '@rollup/rollup-linux-x64-gnu': 4.53.1 - '@rollup/rollup-linux-x64-musl': 4.53.1 - '@rollup/rollup-openharmony-arm64': 4.53.1 - '@rollup/rollup-win32-arm64-msvc': 4.53.1 - '@rollup/rollup-win32-ia32-msvc': 4.53.1 - '@rollup/rollup-win32-x64-gnu': 4.53.1 - '@rollup/rollup-win32-x64-msvc': 4.53.1 + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 fsevents: 2.3.3 run-async@2.4.1: {} @@ -10479,10 +9946,10 @@ snapshots: safer-buffer@2.1.2: {} - sass@1.93.2: + sass@1.98.0: dependencies: chokidar: 4.0.3 - immutable: 5.1.4 + immutable: 5.1.5 source-map-js: 1.2.1 optionalDependencies: '@parcel/watcher': 2.5.1 @@ -10497,8 +9964,8 @@ snapshots: schema-utils@2.7.1: dependencies: '@types/json-schema': 7.0.15 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) + ajv: 6.14.0 + ajv-keywords: 3.5.2(ajv@6.14.0) scuid@1.1.0: {} @@ -10506,7 +9973,7 @@ snapshots: semver@6.3.1: {} - semver@7.7.3: {} + semver@7.7.4: {} sentence-case@3.0.4: dependencies: @@ -10584,6 +10051,8 @@ snapshots: slash@3.0.0: {} + slash@5.1.0: {} + slice-ansi@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -10657,6 +10126,11 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -10684,17 +10158,6 @@ snapshots: define-properties: 1.2.1 es-abstract: 1.24.0 - string.prototype.replaceall@1.0.11: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - is-regex: 1.2.1 - string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 @@ -10726,6 +10189,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-bom@4.0.0: {} @@ -10734,78 +10201,72 @@ snapshots: dependencies: min-indent: 1.0.1 - strip-indent@4.1.1: {} - strip-json-comments@3.1.1: {} - style-search@0.1.0: {} - style-to-object@0.3.0: dependencies: inline-style-parser: 0.1.1 - stylelint-order@6.0.4(stylelint@15.11.0(typescript@4.8.4)): + stylelint-order@8.1.1(stylelint@17.6.0(typescript@5.9.3)): dependencies: - postcss: 8.5.6 - postcss-sorting: 8.0.2(postcss@8.5.6) - stylelint: 15.11.0(typescript@4.8.4) + postcss: 8.5.8 + postcss-sorting: 10.0.0(postcss@8.5.8) + stylelint: 17.6.0(typescript@5.9.3) - stylelint@15.11.0(typescript@4.8.4): + stylelint@17.6.0(typescript@5.9.3): dependencies: - '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) - '@csstools/css-tokenizer': 2.4.1 - '@csstools/media-query-list-parser': 2.1.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) - '@csstools/selector-specificity': 3.1.1(postcss-selector-parser@6.1.2) - balanced-match: 2.0.0 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) colord: 2.9.3 - cosmiconfig: 8.3.6(typescript@4.8.4) - css-functions-list: 3.2.3 - css-tree: 2.3.1 + cosmiconfig: 9.0.1(typescript@5.9.3) + css-functions-list: 3.3.3 + css-tree: 3.2.1 debug: 4.4.3 fast-glob: 3.3.3 fastest-levenshtein: 1.0.16 - file-entry-cache: 7.0.2 + file-entry-cache: 11.1.2 global-modules: 2.0.0 - globby: 11.1.0 + globby: 16.2.0 globjoin: 0.1.4 - html-tags: 3.3.1 - ignore: 5.3.2 - import-lazy: 4.0.0 - imurmurhash: 0.1.4 + html-tags: 5.1.0 + ignore: 7.0.5 + import-meta-resolve: 4.2.0 is-plain-object: 5.0.0 - known-css-properties: 0.29.0 - mathml-tag-names: 2.1.3 - meow: 10.1.5 + mathml-tag-names: 4.0.0 + meow: 14.1.0 micromatch: 4.0.8 normalize-path: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.6 - postcss-resolve-nested-selector: 0.1.6 - postcss-safe-parser: 6.0.0(postcss@8.5.6) - postcss-selector-parser: 6.1.2 + postcss: 8.5.8 + postcss-safe-parser: 7.0.1(postcss@8.5.8) + postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - resolve-from: 5.0.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - style-search: 0.1.0 - supports-hyperlinks: 3.2.0 + string-width: 8.2.0 + supports-hyperlinks: 4.4.0 svg-tags: 1.0.0 table: 6.9.0 - write-file-atomic: 5.0.1 + write-file-atomic: 7.0.1 transitivePeerDependencies: - supports-color - typescript stylis@4.2.0: {} + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 - supports-hyperlinks@3.2.0: + supports-hyperlinks@4.4.0: dependencies: - has-flag: 4.0.0 - supports-color: 7.2.0 + has-flag: 5.0.1 + supports-color: 10.2.2 supports-preserve-symlinks-flag@1.0.0: {} @@ -10823,20 +10284,18 @@ snapshots: timeout-signal: 2.0.0 whatwg-mimetype: 4.0.0 - systemjs@6.15.1: {} - table@6.9.0: dependencies: - ajv: 8.17.1 + ajv: 8.18.0 lodash.truncate: 4.4.2 slice-ansi: 4.0.0 string-width: 4.2.3 strip-ansi: 6.0.1 - terser@5.44.0: + terser@5.46.1: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.16.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -10844,7 +10303,7 @@ snapshots: thehandy@1.1.0: {} - three@0.93.0: {} + three@0.125.2: {} throttle-debounce@5.0.2: {} @@ -10858,6 +10317,11 @@ snapshots: tiny-warning@1.0.3: {} + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + title-case@3.0.3: dependencies: tslib: 2.8.1 @@ -10872,37 +10336,39 @@ snapshots: trim-newlines@3.0.1: {} - trim-newlines@4.1.1: {} - trough@1.0.5: {} + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + ts-invariant@0.10.3: dependencies: tslib: 2.8.1 ts-log@2.2.7: {} - ts-node@10.9.2(@types/node@18.19.130)(typescript@4.8.4): + ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 18.19.130 - acorn: 8.15.0 + '@types/node': 20.19.37 + acorn: 8.16.0 acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 4.8.4 + typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - tsconfck@3.1.6(typescript@4.8.4): + tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: - typescript: 4.8.4 + typescript: 5.9.3 tsconfig-paths@3.15.0: dependencies: @@ -10911,19 +10377,12 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tslib@1.14.1: {} - tslib@2.4.1: {} tslib@2.6.3: {} tslib@2.8.1: {} - tsutils@3.21.0(typescript@4.8.4): - dependencies: - tslib: 1.14.1 - typescript: 4.8.4 - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -10938,8 +10397,6 @@ snapshots: type-fest@0.8.1: {} - type-fest@1.4.0: {} - type-fest@2.19.0: {} typed-array-buffer@1.0.3: @@ -10979,10 +10436,20 @@ snapshots: dependencies: is-typedarray: 1.0.0 - typescript@4.8.4: {} + typescript@4.9.5: {} + + typescript@5.9.3: {} + + ua-is-frozen@0.1.2: {} ua-parser-js@1.0.41: {} + ua-parser-js@2.0.9: + dependencies: + detect-europe-js: 0.1.2 + is-standalone-pwa: 0.1.1 + ua-is-frozen: 0.1.2 + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -10994,24 +10461,15 @@ snapshots: uncontrollable@7.2.1(react@17.0.2): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@types/react': 17.0.89 invariant: 2.2.4 react: 17.0.2 react-lifecycles-compat: 3.0.4 - undici-types@5.26.5: {} + undici-types@6.21.0: {} - unicode-canonical-property-names-ecmascript@2.0.1: {} - - unicode-match-property-ecmascript@2.0.0: - dependencies: - unicode-canonical-property-names-ecmascript: 2.0.1 - unicode-property-aliases-ecmascript: 2.2.0 - - unicode-match-property-value-ecmascript@2.2.1: {} - - unicode-property-aliases-ecmascript@2.2.0: {} + unicorn-magic@0.4.0: {} unified@9.2.2: dependencies: @@ -11056,10 +10514,9 @@ snapshots: unist-util-is: 4.1.0 unist-util-visit-parents: 3.1.1 - universal-cookie@4.0.4: + universal-cookie@8.1.0: dependencies: - '@types/cookie': 0.3.3 - cookie: 0.4.2 + cookie: 1.1.1 universalify@2.0.1: {} @@ -11067,9 +10524,9 @@ snapshots: dependencies: normalize-path: 2.1.1 - update-browserslist-db@1.1.3(browserslist@4.26.3): + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: - browserslist: 4.26.3 + browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 @@ -11120,7 +10577,7 @@ snapshots: video.js@7.21.7: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@videojs/http-streaming': 2.16.3(video.js@7.21.7) '@videojs/vhs-utils': 3.0.5 '@videojs/xhr': 2.6.0 @@ -11154,48 +10611,45 @@ snapshots: global: 4.4.0 video.js: 7.21.7 - videojs-vr@1.8.0: - dependencies: - '@babel/runtime': 7.28.4 - global: 4.4.0 - three: 0.93.0 - video.js: 7.21.7 - webvr-polyfill: 0.10.12 - videojs-vtt.js@0.15.5: dependencies: global: 4.4.0 - vite-plugin-compression@0.5.1(vite@5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0)): + vite-plugin-compression@0.5.1(vite@7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.1)): dependencies: chalk: 4.1.2 debug: 4.4.3 fs-extra: 10.1.0 - vite: 5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0) + vite: 7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color - vite-tsconfig-paths@4.3.2(typescript@4.8.4)(vite@5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0)): + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.1)): dependencies: debug: 4.4.3 globrex: 0.1.2 - tsconfck: 3.1.6(typescript@4.8.4) - optionalDependencies: - vite: 5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0) + tsconfck: 3.1.6(typescript@5.9.3) + vite: 7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color - typescript - vite@5.4.21(@types/node@18.19.130)(sass@1.93.2)(terser@5.44.0): + vite@7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.1): dependencies: - esbuild: 0.21.5 - postcss: 8.5.6 - rollup: 4.53.1 + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.2 + tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 18.19.130 + '@types/node': 20.19.37 fsevents: 2.3.3 - sass: 1.93.2 - terser: 5.44.0 + jiti: 2.6.1 + lightningcss: 1.32.0 + sass: 1.98.0 + terser: 5.46.1 + yaml: 2.8.1 warning@4.0.3: dependencies: @@ -11300,9 +10754,8 @@ snapshots: signal-exit: 3.0.7 typedarray-to-buffer: 3.1.5 - write-file-atomic@5.0.1: + write-file-atomic@7.0.1: dependencies: - imurmurhash: 0.1.4 signal-exit: 4.1.0 write-json-file@4.3.0: @@ -11324,11 +10777,9 @@ snapshots: yallist@3.1.1: {} - yallist@4.0.0: {} - yaml-ast-parser@0.0.43: {} - yaml@1.10.2: {} + yaml@1.10.3: {} yaml@2.8.1: {} @@ -11337,8 +10788,6 @@ snapshots: camelcase: 5.3.1 decamelize: 1.2.0 - yargs-parser@20.2.9: {} - yargs-parser@21.1.1: {} yargs@15.4.1: diff --git a/ui/v2.5/pnpm-workspace.yaml b/ui/v2.5/pnpm-workspace.yaml index 2b12183a9..0e4687ae8 100644 --- a/ui/v2.5/pnpm-workspace.yaml +++ b/ui/v2.5/pnpm-workspace.yaml @@ -2,3 +2,7 @@ onlyBuiltDependencies: - '@parcel/watcher' - core-js - esbuild +overrides: + "yaml@1.10.2": "~1.10.3" + "brace-expansion@1.1.12": "~1.1.13" + "@xmldom/xmldom@0.8.12": "~0.8.13" \ No newline at end of file diff --git a/ui/v2.5/src/@types/string.prototype.replaceall.d.ts b/ui/v2.5/src/@types/string.prototype.replaceall.d.ts deleted file mode 100644 index fa87eec06..000000000 --- a/ui/v2.5/src/@types/string.prototype.replaceall.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -declare module "string.prototype.replaceall" { - function replaceAll( - searchValue: string | RegExp, - replaceValue: string - ): string; - function replaceAll( - searchValue: string | RegExp, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - replacer: (substring: string, ...args: any[]) => string - ): string; - - namespace replaceAll { - function getPolyfill(): typeof replaceAll; - function implementation(): typeof replaceAll; - function shim(): void; - } - - export default replaceAll; -} diff --git a/ui/v2.5/src/@types/videojs-contrib-dash.d.ts b/ui/v2.5/src/@types/videojs-contrib-dash.d.ts deleted file mode 100644 index b791d1edb..000000000 --- a/ui/v2.5/src/@types/videojs-contrib-dash.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ - -declare module "videojs-contrib-dash" { - class Html5DashJS { - /** - * Get a list of hooks for a specific lifecycle. - * - * @param type the lifecycle to get hooks from - * @param hook optionally add a hook to the lifecycle - * @return an array of hooks or empty if none - */ - static hooks(type: string, hook: Function | Function[]): Function[]; - - /** - * Add a function hook to a specific dash lifecycle. - * - * @param type the lifecycle to hook the function to - * @param hook the function or array of functions to attach - */ - static hook(type: string, hook: Function | Function[]): void; - - /** - * Remove a hook from a specific dash lifecycle. - * - * @param type the lifecycle that the function hooked to - * @param hook the hooked function to remove - * @return true if the function was removed, false if not found - */ - static removeHook(type: string, hook: Function): boolean; - } -} diff --git a/ui/v2.5/src/@types/videojs-vr.d.ts b/ui/v2.5/src/@types/videojs-vr.d.ts index e5afd8bf3..8dc0acb8c 100644 --- a/ui/v2.5/src/@types/videojs-vr.d.ts +++ b/ui/v2.5/src/@types/videojs-vr.d.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ -declare module "videojs-vr" { +declare module "@blaineam/videojs-vr" { import videojs from "video.js"; // we don't want to depend on THREE.js directly, these are just typedefs for videojs-vr // eslint-disable-next-line import/no-extraneous-dependencies @@ -36,61 +36,50 @@ declare module "videojs-vr" { // Used for Equi-Angular Cubemap videos | "EAC" // Used for side-by-side Equi-Angular Cubemap videos - | "EAC_LR"; + | "EAC_LR" + // flat screen side-by-side + | "SBS_MONO"; + interface mediaItem { + title: string; + thumbnail: string; + url: string; + duration?: number; + } + type mediaItems = mediaItem[]; + + type orientationOffset = { + x: number; + y: number; + z: number; + }; + + // options are taken verbaitum from the README interface Options { - /** - * Force the cardboard button to display on all devices even if we don't think they support it. - * - * @default false - */ - forceCardboard?: boolean; + // Projection mode + projection?: ProjectionType; // see ProjectionType + sphereDetails?: number; // Sphere mesh detail (higher = smoother) - /** - * Whether motion/gyro controls should be enabled. - * - * @default true on iOS and Android - */ - motionControls?: boolean; + // VR HUD options + enableVRHud?: boolean; // Enable in-VR controls + enableVRGallery?: boolean; // Enable in-VR video gallery + showHUDOnStart?: boolean; // Show HUD when entering VR + hudAutoHideDelay?: number; // Auto-hide HUD after ms (0 to disable) + hudDistance?: number; // Distance of HUD from viewer + hudHeight?: number; // Height of HUD + hudScale?: number; // Scale of HUD elements - /** - * Defines the projection type. - * - * @default "AUTO" - */ - projection?: ProjectionType; + // Behavior options + forceCardboard?: boolean; // Force cardboard button on all devices + motionControls?: boolean; // Enable gyroscope/device orientation + disableTogglePlay?: boolean; // Disable click-to-play - /** - * This alters the number of segments in the spherical mesh onto which equirectangular videos are projected. - * The default is 32 but in some circumstances you may notice artifacts and need to increase this number. - * - * @default 32 - */ - sphereDetail?: number; + // Spatial audio (requires Omnitone library) + omnitone?: Object; // Pass Omnitone library object + omnitoneOptions?: Record; // Omnitone configuration - /** - * Enable debug logging for this plugin - * - * @default false - */ - debug?: boolean; - - /** - * Use this property to pass the Omnitone library object to the plugin. Please be aware of, the Omnitone library is not included in the build files. - */ - omnitone?: object; - - /** - * Default options for the Omnitone library. Please check available options on https://github.com/GoogleChrome/omnitone - */ - omnitoneOptions?: object; - - /** - * Feature to disable the togglePlay manually. This functionality is useful in live events so that users cannot stop the live, but still have a controlBar available. - * - * @default false - */ - disableTogglePlay?: boolean; + // Media gallery items + mediaItems?: mediaItems; // Array of media items for gallery } interface PlayerMediaInfo { @@ -106,11 +95,33 @@ declare module "videojs-vr" { init(): void; reset(): void; - cameraVector: THREE.Vector3; + // VR HUD + showHUD(): void; // Show the VR HUD + hideHUD(): void; // Hide the VR HUD + toggleHUD(): void; // Toggle HUD visibility + + // VR Gallery + showGallery(): void; // Show the gallery panel + hideGallery(): void; // Hide the gallery panel + toggleGallery(): void; // Toggle gallery visibility + setGalleryItems(mediaItems): void; // Update gallery media items + + // Favorite state + setFavoriteState(boolean): void; // Set favorite button state + getFavoriteState(): boolean; // Get current favorite state + + // Orientation + setOrientationOffset(orientationOffset): void; // Tilt view + resetOrientationOffset(): void; // Reset to default orientation + recenter(): void; // Recenter VR view + + // Status + isPresenting(): boolean; // Check if currently in VR mode camera: THREE.Camera; scene: THREE.Scene; renderer: THREE.Renderer; + cameraVector: THREE.Vector3; } } diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index 7e4207dce..df7517f7d 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -35,6 +35,7 @@ import V0270 from "src/docs/en/Changelog/v0270.md"; import V0280 from "src/docs/en/Changelog/v0280.md"; import V0290 from "src/docs/en/Changelog/v0290.md"; import V0300 from "src/docs/en/Changelog/v0300.md"; +import V0310 from "src/docs/en/Changelog/v0310.md"; import V0290ReleaseNotes from "src/docs/en/ReleaseNotes/v0290.md"; @@ -75,9 +76,9 @@ const Changelog: React.FC = () => { // after new release: // add entry to releases, using the current* fields // then update the current fields. - const currentVersion = stashVersion || "v0.30.0"; + const currentVersion = stashVersion || "v0.31.0"; const currentDate = buildDate; - const currentPage = V0300; + const currentPage = V0310; const releases: IStashRelease[] = [ { @@ -86,6 +87,12 @@ const Changelog: React.FC = () => { page: currentPage, defaultOpen: true, }, + { + version: "v0.30.1", + date: "2025-12-18", + page: V0300, + releaseNotes: V0290ReleaseNotes, + }, { version: "v0.29.3", date: "2025-11-06", diff --git a/ui/v2.5/src/components/Changelog/styles.scss b/ui/v2.5/src/components/Changelog/styles.scss index 07c88f698..9d79e7d3b 100644 --- a/ui/v2.5/src/components/Changelog/styles.scss +++ b/ui/v2.5/src/components/Changelog/styles.scss @@ -20,11 +20,6 @@ padding: 0; } - ul { - list-style-type: none; - padding-left: 0.5rem; - } - &-version { &-body { padding: 1rem 2rem; diff --git a/ui/v2.5/src/components/FrontPage/FilteredRecommendationRow.tsx b/ui/v2.5/src/components/FrontPage/FilteredRecommendationRow.tsx new file mode 100644 index 000000000..8cf27a625 --- /dev/null +++ b/ui/v2.5/src/components/FrontPage/FilteredRecommendationRow.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import Slider from "@ant-design/react-slick"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { getSlickSliderSettings } from "src/core/recommendations"; +import { RecommendationRow } from "../FrontPage/RecommendationRow"; +import { FormattedMessage } from "react-intl"; +import { PatchComponent } from "src/patch"; +import { UnsupportedCriterion } from "src/models/list-filter/criteria/criterion"; +import { PopoverCard, WarningHoverPopover } from "../Shared/HoverPopover"; + +interface IProps { + className?: string; + isTouch: boolean; + filter: ListFilterModel; + heading: string; + count: number; + loading: boolean; + url: string; +} + +export const FilteredRecommendationRow: React.FC = PatchComponent( + "FilteredRecommendationRow", + (props) => { + const cardCount = props.count; + + const unsupportedCriteria = props.filter.criteria.filter( + (criterion) => criterion instanceof UnsupportedCriterion + ); + + const header = unsupportedCriteria.length ? ( +
+ {props.heading} + + c.criterionOption.type) + .join(", "), + }} + /> + + } + /> +
+ ) : ( + props.heading + ); + + if (!props.loading && !cardCount) { + return null; + } + + return ( + + + + } + > + + {props.children} + + + ); + } +); diff --git a/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx b/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx index 115d8642a..97e43f294 100644 --- a/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx +++ b/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx @@ -3,7 +3,7 @@ import { PatchComponent } from "src/patch"; interface IProps { className?: string; - header: string; + header: React.ReactNode; link: JSX.Element; } diff --git a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx index 9ff7e00f2..cec44abf1 100644 --- a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx +++ b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx @@ -1,100 +1,129 @@ -import React, { useEffect, useState } from "react"; -import { Form, Col, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; -import isEqual from "lodash-es/isEqual"; +import React, { useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkGalleryUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { StudioSelect } from "../Shared/Select"; import { ModalComponent } from "../Shared/Modal"; -import { useToast } from "src/hooks/Toast"; -import * as FormUtils from "src/utils/form"; import { MultiSet } from "../Shared/MultiSet"; +import { useToast } from "src/hooks/Toast"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { - getAggregateInputIDs, getAggregateInputValue, getAggregatePerformerIds, - getAggregateRating, - getAggregateStudioId, + getAggregateStateObject, getAggregateTagIds, + getAggregateStudioId, + getAggregateSceneIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimGalleryDataFragment[]; onClose: (applied: boolean) => void; } +const galleryFields = [ + "code", + "rating100", + "details", + "organized", + "photographer", + "date", +]; + export const EditGalleriesDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [performerMode, setPerformerMode] = - React.useState(GQL.BulkUpdateIdMode.Add); - const [performerIds, setPerformerIds] = useState(); - const [existingPerformerIds, setExistingPerformerIds] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); - const [organized, setOrganized] = useState(); + + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((gallery) => { + return gallery.id; + }), + }); + + const [performerIds, setPerformerIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [sceneIds, setSceneIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + + const unsetDisabled = props.selected.length < 2; + + const [dateError, setDateError] = useState(); const [updateGalleries] = useBulkGalleryUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); - const checkboxRef = React.createRef(); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + updateState.studio_id = getAggregateStudioId(props.selected); + const updateTagIds = getAggregateTagIds(props.selected); + const updatePerformerIds = getAggregatePerformerIds(props.selected); + const updateSceneIds = getAggregateSceneIds(props.selected); + let first = true; + + state.forEach((gallery: GQL.SlimGalleryDataFragment) => { + getAggregateStateObject(updateState, gallery, galleryFields, first); + first = false; + }); + + return { + state: updateState, + tagIds: updateTagIds, + performerIds: updatePerformerIds, + sceneIds: updateSceneIds, + }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + useEffect(() => { + setDateError(getDateError(updateInput.date ?? "", intl)); + }, [updateInput.date, intl]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } function getGalleryInput(): GQL.BulkGalleryUpdateInput { - // need to determine what we are actually setting on each gallery - const aggregateRating = getAggregateRating(props.selected); - const aggregateStudioId = getAggregateStudioId(props.selected); - const aggregatePerformerIds = getAggregatePerformerIds(props.selected); - const aggregateTagIds = getAggregateTagIds(props.selected); - const galleryInput: GQL.BulkGalleryUpdateInput = { - ids: props.selected.map((gallery) => { - return gallery.id; - }), + ...updateInput, + tag_ids: tagIds, + performer_ids: performerIds, + scene_ids: sceneIds, }; - galleryInput.rating100 = getAggregateInputValue(rating100, aggregateRating); - galleryInput.studio_id = getAggregateInputValue( - studioId, - aggregateStudioId + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + galleryInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 ); - galleryInput.performer_ids = getAggregateInputIDs( - performerMode, - performerIds, - aggregatePerformerIds - ); - galleryInput.tag_ids = getAggregateInputIDs( - tagMode, - tagIds, - aggregateTagIds - ); - - if (organized !== undefined) { - galleryInput.organized = organized; - } - return galleryInput; } async function onSave() { setIsUpdating(true); try { - await updateGalleries({ - variables: { - input: getGalleryInput(), - }, - }); + await updateGalleries({ variables: { input: getGalleryInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, @@ -110,129 +139,13 @@ export const EditGalleriesDialog: React.FC = ( setIsUpdating(false); } - useEffect(() => { - const state = props.selected; - let updateRating: number | undefined; - let updateStudioID: string | undefined; - let updatePerformerIds: string[] = []; - let updateTagIds: string[] = []; - let updateOrganized: boolean | undefined; - let first = true; - - state.forEach((gallery: GQL.SlimGalleryDataFragment) => { - const galleryRating = gallery.rating100; - const GalleriestudioID = gallery?.studio?.id; - const galleryPerformerIDs = (gallery.performers ?? []) - .map((p) => p.id) - .sort(); - const galleryTagIDs = (gallery.tags ?? []).map((p) => p.id).sort(); - - if (first) { - updateRating = galleryRating ?? undefined; - updateStudioID = GalleriestudioID; - updatePerformerIds = galleryPerformerIDs; - updateTagIds = galleryTagIDs; - updateOrganized = gallery.organized; - first = false; - } else { - if (galleryRating !== updateRating) { - updateRating = undefined; - } - if (GalleriestudioID !== updateStudioID) { - updateStudioID = undefined; - } - if (!isEqual(galleryPerformerIDs, updatePerformerIds)) { - updatePerformerIds = []; - } - if (!isEqual(galleryTagIDs, updateTagIds)) { - updateTagIds = []; - } - if (gallery.organized !== updateOrganized) { - updateOrganized = undefined; - } - } - }); - - setRating(updateRating); - setStudioId(updateStudioID); - setExistingPerformerIds(updatePerformerIds); - setExistingTagIds(updateTagIds); - - setOrganized(updateOrganized); - }, [props.selected]); - - useEffect(() => { - if (checkboxRef.current) { - checkboxRef.current.indeterminate = organized === undefined; - } - }, [organized, checkboxRef]); - - function renderMultiSelect( - type: "performers" | "tags", - ids: string[] | undefined - ) { - let mode = GQL.BulkUpdateIdMode.Add; - let existingIds: string[] | undefined = []; - switch (type) { - case "performers": - mode = performerMode; - existingIds = existingPerformerIds; - break; - case "tags": - mode = tagMode; - existingIds = existingTagIds; - break; - } - - return ( - { - switch (type) { - case "performers": - setPerformerIds(itemIDs); - break; - case "tags": - setTagIds(itemIDs); - break; - } - }} - onSetMode={(newMode) => { - switch (type) { - case "performers": - setPerformerMode(newMode); - break; - case "tags": - setTagMode(newMode); - break; - } - }} - existingIds={existingIds ?? []} - ids={ids ?? []} - mode={mode} - menuPortalTarget={document.body} - /> - ); - } - - function cycleOrganized() { - if (organized) { - setOrganized(undefined); - } else if (organized === undefined) { - setOrganized(false); - } else { - setOrganized(true); - } - } - function render() { return ( = ( onClick: onSave, text: intl.formatMessage({ id: "actions.apply" }), }} + disabled={isUpdating || !!dateError} cancel={{ onClick: () => props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -251,55 +165,119 @@ export const EditGalleriesDialog: React.FC = ( isRunning={isUpdating} >
- - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} -
- setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + - - - - - {renderMultiSelect("performers", performerIds)} - + + setUpdateField({ code: newValue })} + unsetDisabled={unsetDisabled} + /> + + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + - - - - - {renderMultiSelect("tags", tagIds)} - + + + setUpdateField({ photographer: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + + { + setPerformerIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setPerformerIds((c) => ({ ...c, mode: newMode })); + }} + ids={performerIds.ids ?? []} + existingIds={aggregateState.performerIds} + mode={performerIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setSceneIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setSceneIds((c) => ({ ...c, mode: newMode })); + }} + ids={sceneIds.ids ?? []} + existingIds={aggregateState.sceneIds} + mode={sceneIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + - cycleOrganized()} + setChecked={(checked) => setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} /> diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index e4e227f3e..01e0b6045 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -1,5 +1,5 @@ import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { GridCard } from "../Shared/GridCard/GridCard"; import { HoverPopover } from "../Shared/HoverPopover"; @@ -21,11 +21,13 @@ import { PatchComponent } from "src/patch"; interface IGalleryPreviewProps { gallery: GQL.SlimGalleryDataFragment; onScrubberClick?: (index: number) => void; + disabled?: boolean; } export const GalleryPreview: React.FC = ({ gallery, onScrubberClick, + disabled, }) => { const [imgSrc, setImgSrc] = useState( gallery.paths.cover ?? undefined @@ -48,6 +50,7 @@ export const GalleryPreview: React.FC = ({ imageCount={gallery.image_count} onClick={onScrubberClick} onPathChanged={setImgSrc} + disabled={disabled} /> )} @@ -195,7 +198,16 @@ const GalleryCardDetails = PatchComponent( const GalleryCardOverlays = PatchComponent( "GalleryCard.Overlays", (props: IGalleryCardProps) => { - return ; + const ret = useMemo(() => { + return ( + + ); + }, [props.gallery.studio, props.selecting]); + + return ret; } ); @@ -211,6 +223,7 @@ const GalleryCardImage = PatchComponent( onScrubberClick={(i) => { history.push(`/galleries/${props.gallery.id}/images/${i}`); }} + disabled={props.selecting} /> diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 18cbeff96..1fce02b32 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -1,11 +1,6 @@ import { Button, Tab, Nav, Dropdown } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; -import { - useHistory, - Link, - RouteComponentProps, - Redirect, -} from "react-router-dom"; +import { useHistory, RouteComponentProps, Redirect } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; @@ -50,6 +45,7 @@ import { useConfigurationContext } from "src/hooks/Config"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; +import { StudioLogo } from "src/components/Shared/StudioLogo"; interface IProps { gallery: GQL.GalleryDataFragment; @@ -66,6 +62,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { const Toast = useToast(); const intl = useIntl(); const { configuration } = useConfigurationContext(); + const { showStudioText } = configuration?.ui ?? {}; const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters); const [collapsed, setCollapsed] = useState(false); @@ -415,17 +412,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => {
- {gallery.studio && ( -

- - {`${gallery.studio.name} - -

- )} +

diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx index 275c4263b..e0c115f34 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx @@ -1,8 +1,8 @@ -import React from "react"; +import React, { useCallback } from "react"; import * as GQL from "src/core/generated-graphql"; import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { ImageList } from "src/components/Images/ImageList"; +import { FilteredImageList } from "src/components/Images/ImageList"; import { showWhenSelected } from "src/components/List/ItemList"; import { mutateAddGalleryImages } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; @@ -24,40 +24,43 @@ export const GalleryAddPanel: React.FC = PatchComponent( const Toast = useToast(); const intl = useIntl(); - function filterHook(filter: ListFilterModel) { - const galleryValue = { - id: gallery.id, - label: galleryTitle(gallery), - }; - // if galleries is already present, then we modify it, otherwise add - let galleryCriterion = filter.criteria.find((c) => { - return c.criterionOption.type === "galleries"; - }) as GalleriesCriterion | undefined; + const filterHook = useCallback( + (filter: ListFilterModel) => { + const galleryValue = { + id: gallery.id, + label: galleryTitle(gallery), + }; + // if galleries is already present, then we modify it, otherwise add + let galleryCriterion = filter.criteria.find((c) => { + return c.criterionOption.type === "galleries"; + }) as GalleriesCriterion | undefined; - if ( - galleryCriterion && - galleryCriterion.modifier === GQL.CriterionModifier.Excludes - ) { - // add the gallery if not present if ( - !galleryCriterion.value.find((p) => { - return p.id === gallery.id; - }) + galleryCriterion && + galleryCriterion.modifier === GQL.CriterionModifier.Excludes ) { - galleryCriterion.value.push(galleryValue); + // add the gallery if not present + if ( + !galleryCriterion.value.find((p) => { + return p.id === gallery.id; + }) + ) { + galleryCriterion.value.push(galleryValue); + } + + galleryCriterion.modifier = GQL.CriterionModifier.Excludes; + } else { + // overwrite + galleryCriterion = new GalleriesCriterion(); + galleryCriterion.modifier = GQL.CriterionModifier.Excludes; + galleryCriterion.value = [galleryValue]; + filter.criteria.push(galleryCriterion); } - galleryCriterion.modifier = GQL.CriterionModifier.Excludes; - } else { - // overwrite - galleryCriterion = new GalleriesCriterion(); - galleryCriterion.modifier = GQL.CriterionModifier.Excludes; - galleryCriterion.value = [galleryValue]; - filter.criteria.push(galleryCriterion); - } - - return filter; - } + return filter; + }, + [gallery] + ); async function addImages( result: GQL.FindImagesQueryResult, @@ -100,7 +103,7 @@ export const GalleryAddPanel: React.FC = PatchComponent( ]; return ( - = ({ {renderDetails()} {renderTags()} {renderPerformers()} +

diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 04b802784..0fc466bd9 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -31,6 +31,11 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import cloneDeep from "lodash-es/cloneDeep"; interface IProps { gallery: Partial; @@ -76,6 +81,7 @@ export const GalleryEditPanel: React.FC = ({ tag_ids: yup.array(yup.string().required()).defined(), scene_ids: yup.array(yup.string().required()).defined(), details: yup.string().ensure(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -89,15 +95,26 @@ export const GalleryEditPanel: React.FC = ({ tag_ids: (gallery?.tags ?? []).map((t) => t.id), scene_ids: (gallery?.scenes ?? []).map((s) => s.id), details: gallery?.details ?? "", + custom_fields: cloneDeep(gallery?.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -189,7 +206,10 @@ export const GalleryEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -455,7 +475,9 @@ export const GalleryEditPanel: React.FC = ({ id="gallery-save-split-button" className="edit-button" variant="primary" - disabled={!isEqual(formik.errors, {})} + disabled={ + !isEqual(formik.errors, {}) || customFieldsError !== undefined + } title={intl.formatMessage({ id: "actions.save" })} onClick={() => formik.submitForm()} > @@ -468,7 +490,9 @@ export const GalleryEditPanel: React.FC = ({ className="edit-button" variant="primary" disabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onClick={() => formik.submitForm()} > @@ -523,6 +547,13 @@ export const GalleryEditPanel: React.FC = ({ {cover} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx index 63fedd400..b7dab09a0 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx @@ -3,11 +3,12 @@ import { Accordion, Button, Card } from "react-bootstrap"; import { FormattedMessage, FormattedTime } from "react-intl"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; +import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; import * as GQL from "src/core/generated-graphql"; import { mutateGallerySetPrimaryFile } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import TextUtils from "src/utils/text"; -import { TextField, URLField, URLsField } from "src/utils/field"; +import { TextField, URLsField } from "src/utils/field"; interface IFileInfoPanelProps { folder?: Pick; @@ -37,13 +38,16 @@ const FileInfoPanel: React.FC = ( )} - - + + + + + + + {props.file && ( = const intl = useIntl(); const Toast = useToast(); - function filterHook(filter: ListFilterModel) { - const galleryValue = { - id: gallery.id!, - label: galleryTitle(gallery), - }; - // if galleries is already present, then we modify it, otherwise add - let galleryCriterion = filter.criteria.find((c) => { - return c.criterionOption.type === "galleries"; - }) as GalleriesCriterion | undefined; + const filterHook = useCallback( + (filter: ListFilterModel) => { + const galleryValue = { + id: gallery.id!, + label: galleryTitle(gallery), + }; + // if galleries is already present, then we modify it, otherwise add + let galleryCriterion = filter.criteria.find((c) => { + return c.criterionOption.type === "galleries"; + }) as GalleriesCriterion | undefined; - if ( - galleryCriterion && - (galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll || - galleryCriterion.modifier === GQL.CriterionModifier.Includes) - ) { - // add the gallery if not present if ( - !galleryCriterion.value.find((p) => { - return p.id === gallery.id; - }) + galleryCriterion && + (galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll || + galleryCriterion.modifier === GQL.CriterionModifier.Includes) ) { - galleryCriterion.value.push(galleryValue); + // add the gallery if not present + if ( + !galleryCriterion.value.find((p) => { + return p.id === gallery.id; + }) + ) { + galleryCriterion.value.push(galleryValue); + } + + galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll; + } else { + // overwrite + galleryCriterion = new GalleriesCriterion(); + galleryCriterion.value = [galleryValue]; + filter.criteria.push(galleryCriterion); } - galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll; - } else { - // overwrite - galleryCriterion = new GalleriesCriterion(); - galleryCriterion.value = [galleryValue]; - filter.criteria.push(galleryCriterion); - } - - return filter; - } + return filter; + }, + [gallery] + ); async function setCover( result: GQL.FindImagesQueryResult, @@ -142,7 +145,7 @@ export const GalleryImagesPanel: React.FC = ]; return ( - + } + criterionOption={ParentFolderCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="parent_folder" + /> } data-type={OrganizedCriterionOption.type} option={OrganizedCriterionOption} filter={filter} setFilter={setFilter} + sectionID="organized" + /> + } + option={PerformerAgeCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="performer_age" /> @@ -282,7 +301,7 @@ export const FilteredGalleryList = PatchComponent( setFilter, }); - useAddKeybinds(filter, totalCount); + useAddKeybinds(effectiveFilter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, @@ -313,7 +332,7 @@ export const FilteredGalleryList = PatchComponent( result, }); - const viewRandom = useViewRandom(filter, totalCount); + const viewRandom = useViewRandom(effectiveFilter, totalCount); function onExport(all: boolean) { showModal( diff --git a/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx b/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx index ef47782bf..5c0a07356 100644 --- a/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx @@ -10,6 +10,7 @@ export const GalleryPreviewScrubber: React.FC<{ imageCount: number; onClick?: (imageIndex: number) => void; onPathChanged: React.Dispatch>; + disabled?: boolean; }> = ({ className, previewPath, @@ -17,6 +18,7 @@ export const GalleryPreviewScrubber: React.FC<{ imageCount, onClick, onPathChanged, + disabled, }) => { const [activeIndex, setActiveIndex] = useState(); const debounceSetActiveIndex = useThrottle(setActiveIndex, 50); @@ -48,6 +50,7 @@ export const GalleryPreviewScrubber: React.FC<{ activeIndex={activeIndex} setActiveIndex={(i) => debounceSetActiveIndex(i)} onClick={onScrubberClick} + disabled={disabled} />
); diff --git a/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx b/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx index b56b48c36..3df07b643 100644 --- a/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx @@ -1,13 +1,9 @@ import React from "react"; -import { Link } from "react-router-dom"; import { useFindGalleries } from "src/core/StashService"; -import Slider from "@ant-design/react-slick"; import { GalleryCard } from "./GalleryCard"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { getSlickSliderSettings } from "src/core/recommendations"; -import { RecommendationRow } from "../FrontPage/RecommendationRow"; -import { FormattedMessage } from "react-intl"; import { PatchComponent } from "src/patch"; +import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; @@ -19,40 +15,29 @@ export const GalleryRecommendationRow: React.FC = PatchComponent( "GalleryRecommendationRow", (props) => { const result = useFindGalleries(props.filter); - const cardCount = result.data?.findGalleries.count; - - if (!result.loading && !cardCount) { - return null; - } + const count = result.data?.findGalleries.count ?? 0; return ( - - - - } + heading={props.header} + url={`/galleries?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findGalleries.galleries.map((g) => ( - - ))} -
-
+ {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findGalleries.galleries.map((g) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx index c1501bd9d..c79000783 100644 --- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx @@ -132,7 +132,13 @@ const GalleryWallCard: React.FC = ({
e.stopPropagation()} + onClick={(e) => { + if (selecting) { + e.preventDefault(); + handleCardClick(e); + } + e.stopPropagation(); + }} > {title && ( = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [director, setDirector] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((group) => { + return group.id; + }), + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); const [containingGroupsMode, setGroupMode] = React.useState(GQL.BulkUpdateIdMode.Add); const [containingGroups, setGroups] = useState(); - const [existingContainingGroups, setExistingContainingGroups] = - useState(); - const [updateGroups] = useBulkGroupUpdate(getGroupInput()); + const unsetDisabled = props.selected.length < 2; + const [updateGroups] = useBulkGroupUpdate(); + + const [dateError, setDateError] = useState(); + + // Network state const [isUpdating, setIsUpdating] = useState(false); - function getGroupInput(): GQL.BulkGroupUpdateInput { - const aggregateRating = getAggregateRating(props.selected); - const aggregateStudioId = getAggregateStudioId(props.selected); - const aggregateTagIds = getAggregateTagIds(props.selected); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + updateState.studio_id = getAggregateStudioId(props.selected); + const updateTagIds = getAggregateTagIds(props.selected); const aggregateGroups = getAggregateContainingGroups(props.selected); + let first = true; + state.forEach((group: GQL.ListGroupDataFragment) => { + getAggregateStateObject(updateState, group, groupFields, first); + first = false; + }); + + return { + state: updateState, + tagIds: updateTagIds, + containingGroups: aggregateGroups, + }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + useEffect(() => { + setDateError(getDateError(updateInput.date ?? "", intl)); + }, [updateInput.date, intl]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } + + function getGroupInput(): GQL.BulkGroupUpdateInput { const groupInput: GQL.BulkGroupUpdateInput = { - ids: props.selected.map((group) => group.id), - director, + ...updateInput, + tag_ids: tagIds, }; - groupInput.rating100 = getAggregateInputValue(rating100, aggregateRating); - groupInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); - groupInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + groupInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 + ); groupInput.containing_groups = getAggregateContainingGroupInput( containingGroupsMode, containingGroups, - aggregateGroups + aggregateState.containingGroups ); return groupInput; @@ -119,13 +155,11 @@ export const EditGroupsDialog: React.FC = ( async function onSave() { setIsUpdating(true); try { - await updateGroups(); + await updateGroups({ variables: { input: getGroupInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, - { - entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase(), - } + { entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase() } ) ); props.onClose(true); @@ -135,67 +169,24 @@ export const EditGroupsDialog: React.FC = ( setIsUpdating(false); } - useEffect(() => { - const state = props.selected; - let updateRating: number | undefined; - let updateStudioId: string | undefined; - let updateTagIds: string[] = []; - let updateContainingGroupIds: IRelatedGroupEntry[] = []; - let updateDirector: string | undefined; - let first = true; - - state.forEach((group: GQL.ListGroupDataFragment) => { - const groupTagIDs = (group.tags ?? []).map((p) => p.id).sort(); - const groupContainingGroupIDs = (group.containing_groups ?? []).sort( - (a, b) => a.group.id.localeCompare(b.group.id) - ); - - if (first) { - first = false; - updateRating = group.rating100 ?? undefined; - updateStudioId = group.studio?.id ?? undefined; - updateTagIds = groupTagIDs; - updateContainingGroupIds = groupContainingGroupIDs; - updateDirector = group.director ?? undefined; - } else { - if (group.rating100 !== updateRating) { - updateRating = undefined; - } - if (group.studio?.id !== updateStudioId) { - updateStudioId = undefined; - } - if (group.director !== updateDirector) { - updateDirector = undefined; - } - if (!isEqual(groupTagIDs, updateTagIds)) { - updateTagIds = []; - } - if (!isEqual(groupContainingGroupIDs, updateContainingGroupIds)) { - updateTagIds = []; - } - } - }); - - setRating(updateRating); - setStudioId(updateStudioId); - setExistingTagIds(updateTagIds); - setExistingContainingGroups(updateContainingGroupIds); - setDirector(updateDirector); - }, [props.selected]); - function render() { return ( props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -204,74 +195,90 @@ export const EditGroupsDialog: React.FC = ( isRunning={isUpdating} >
- - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} -
- setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - - - - - + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + + + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + + + + + setUpdateField({ director: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + setGroups(v)} onSetMode={(newMode) => setGroupMode(newMode)} - existingValue={existingContainingGroups ?? []} + existingValue={aggregateState.containingGroups ?? []} value={containingGroups ?? []} mode={containingGroupsMode} menuPortalTarget={document.body} /> - - - - - - setDirector(event.currentTarget.value)} - placeholder={intl.formatMessage({ id: "director" })} - /> - - - - - + + + setTagIds(itemIDs)} - onSetMode={(newMode) => setTagMode(newMode)} - existingIds={existingTagIds ?? []} - ids={tagIds ?? []} - mode={tagMode} + onUpdate={(itemIDs) => { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} menuPortalTarget={document.body} /> - + + + + + setUpdateField({ synopsis: newValue }) + } + unsetDisabled={unsetDisabled} + as="textarea" + /> + ); diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx index b8e39ffe6..8ae4b16a9 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx @@ -6,6 +6,7 @@ import { DetailItem } from "src/components/Shared/DetailItem"; import { Link } from "react-router-dom"; import { DirectorLink } from "src/components/Shared/Link"; import { GroupLink, TagLink } from "src/components/Shared/TagLink"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface IGroupDescription { group: GQL.SlimGroupDataFragment; @@ -101,6 +102,7 @@ export const GroupDetailsPanel: React.FC = ({ fullWidth={fullWidth} /> )} + ); }; diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx index f0a6f17c1..1a77abeaa 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx @@ -28,6 +28,11 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { Group } from "src/components/Groups/GroupSelect"; import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import cloneDeep from "lodash-es/cloneDeep"; interface IGroupEditPanel { group: Partial; @@ -84,6 +89,7 @@ export const GroupEditPanel: React.FC = ({ synopsis: yup.string().ensure(), front_image: yup.string().nullable().optional(), back_image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -99,15 +105,26 @@ export const GroupEditPanel: React.FC = ({ director: group?.director ?? "", urls: group?.urls ?? [], synopsis: group?.synopsis ?? "", + custom_fields: cloneDeep(group?.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -220,7 +237,10 @@ export const GroupEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -458,6 +478,13 @@ export const GroupEditPanel: React.FC = ({ {renderURLListField("urls", onScrapeGroupURL, urlScrapable)} {renderInputField("synopsis", "textarea")} {renderTagsField()} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> = ({ onToggleEdit={onCancel} onSave={formik.handleSubmit} onSaveAndNew={isNew ? onSaveAndNewClick : undefined} - saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} + saveDisabled={ + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined + } onImageChange={onFrontImageChange} onImageChangeURL={onFrontImageLoad} onClearImage={() => onFrontImageLoad(null)} diff --git a/ui/v2.5/src/components/Groups/GroupList.tsx b/ui/v2.5/src/components/Groups/GroupList.tsx index 6ce00831c..69961f783 100644 --- a/ui/v2.5/src/components/Groups/GroupList.tsx +++ b/ui/v2.5/src/components/Groups/GroupList.tsx @@ -265,7 +265,7 @@ export const FilteredGroupList = PatchComponent( setFilter, }); - useAddKeybinds(filter, totalCount); + useAddKeybinds(effectiveFilter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, @@ -296,7 +296,7 @@ export const FilteredGroupList = PatchComponent( result, }); - const viewRandom = useViewRandom(filter, totalCount); + const viewRandom = useViewRandom(effectiveFilter, totalCount); function onExport(all: boolean) { showModal( diff --git a/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx b/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx index 228cb3467..b9e523b34 100644 --- a/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx +++ b/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx @@ -1,13 +1,9 @@ import React from "react"; -import { Link } from "react-router-dom"; import { useFindGroups } from "src/core/StashService"; -import Slider from "@ant-design/react-slick"; import { GroupCard } from "./GroupCard"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { getSlickSliderSettings } from "src/core/recommendations"; -import { RecommendationRow } from "../FrontPage/RecommendationRow"; -import { FormattedMessage } from "react-intl"; import { PatchComponent } from "src/patch"; +import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; @@ -19,40 +15,26 @@ export const GroupRecommendationRow: React.FC = PatchComponent( "GroupRecommendationRow", (props: IProps) => { const result = useFindGroups(props.filter); - const cardCount = result.data?.findGroups.count; - - if (!result.loading && !cardCount) { - return null; - } + const count = result.data?.findGroups.count ?? 0; return ( - - - - } + heading={props.header} + url={`/groups?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findGroups.groups.map((g) => ( - - ))} -
-
+ {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findGroups.groups.map((g) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/Images/EditImagesDialog.tsx b/ui/v2.5/src/components/Images/EditImagesDialog.tsx index 275ff1556..a90ef922e 100644 --- a/ui/v2.5/src/components/Images/EditImagesDialog.tsx +++ b/ui/v2.5/src/components/Images/EditImagesDialog.tsx @@ -1,96 +1,121 @@ -import React, { useEffect, useState } from "react"; -import { Form, Col, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; -import isEqual from "lodash-es/isEqual"; +import React, { useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkImageUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; -import { StudioSelect } from "src/components/Shared/Select"; -import { ModalComponent } from "src/components/Shared/Modal"; -import { useToast } from "src/hooks/Toast"; -import * as FormUtils from "src/utils/form"; +import { StudioSelect } from "../Shared/Select"; +import { ModalComponent } from "../Shared/Modal"; import { MultiSet } from "../Shared/MultiSet"; +import { useToast } from "src/hooks/Toast"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { - getAggregateGalleryIds, - getAggregateInputIDs, getAggregateInputValue, getAggregatePerformerIds, - getAggregateRating, - getAggregateStudioId, + getAggregateStateObject, getAggregateTagIds, + getAggregateStudioId, + getAggregateGalleryIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimImageDataFragment[]; onClose: (applied: boolean) => void; } +const imageFields = [ + "code", + "rating100", + "details", + "organized", + "photographer", + "date", +]; + export const EditImagesDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [performerMode, setPerformerMode] = - React.useState(GQL.BulkUpdateIdMode.Add); - const [performerIds, setPerformerIds] = useState(); - const [existingPerformerIds, setExistingPerformerIds] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((image) => { + return image.id; + }), + }); - const [galleryMode, setGalleryMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [galleryIds, setGalleryIds] = useState(); - const [existingGalleryIds, setExistingGalleryIds] = useState(); + const [performerIds, setPerformerIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [galleryIds, setGalleryIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); - const [organized, setOrganized] = useState(); + const unsetDisabled = props.selected.length < 2; + + const [dateError, setDateError] = useState(); const [updateImages] = useBulkImageUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); - const checkboxRef = React.createRef(); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + updateState.studio_id = getAggregateStudioId(props.selected); + const updateTagIds = getAggregateTagIds(props.selected); + const updatePerformerIds = getAggregatePerformerIds(props.selected); + const updateGalleryIds = getAggregateGalleryIds(props.selected); + let first = true; + + state.forEach((image: GQL.SlimImageDataFragment) => { + getAggregateStateObject(updateState, image, imageFields, first); + first = false; + }); + + return { + state: updateState, + tagIds: updateTagIds, + performerIds: updatePerformerIds, + galleryIds: updateGalleryIds, + }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + useEffect(() => { + setDateError(getDateError(updateInput.date ?? "", intl)); + }, [updateInput.date, intl]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } function getImageInput(): GQL.BulkImageUpdateInput { - // need to determine what we are actually setting on each image - const aggregateRating = getAggregateRating(props.selected); - const aggregateStudioId = getAggregateStudioId(props.selected); - const aggregatePerformerIds = getAggregatePerformerIds(props.selected); - const aggregateTagIds = getAggregateTagIds(props.selected); - const aggregateGalleryIds = getAggregateGalleryIds(props.selected); - const imageInput: GQL.BulkImageUpdateInput = { - ids: props.selected.map((image) => { - return image.id; - }), + ...updateInput, + tag_ids: tagIds, + performer_ids: performerIds, + gallery_ids: galleryIds, }; - imageInput.rating100 = getAggregateInputValue(rating100, aggregateRating); - imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); - - imageInput.performer_ids = getAggregateInputIDs( - performerMode, - performerIds, - aggregatePerformerIds + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + imageInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 ); - imageInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); - imageInput.gallery_ids = getAggregateInputIDs( - galleryMode, - galleryIds, - aggregateGalleryIds - ); - - if (organized !== undefined) { - imageInput.organized = organized; - } return imageInput; } @@ -98,11 +123,7 @@ export const EditImagesDialog: React.FC = ( async function onSave() { setIsUpdating(true); try { - await updateImages({ - variables: { - input: getImageInput(), - }, - }); + await updateImages({ variables: { input: getImageInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, @@ -116,86 +137,13 @@ export const EditImagesDialog: React.FC = ( setIsUpdating(false); } - useEffect(() => { - const state = props.selected; - let updateRating: number | undefined; - let updateStudioID: string | undefined; - let updatePerformerIds: string[] = []; - let updateTagIds: string[] = []; - let updateGalleryIds: string[] = []; - let updateOrganized: boolean | undefined; - let first = true; - - state.forEach((image: GQL.SlimImageDataFragment) => { - const imageRating = image.rating100; - const imageStudioID = image?.studio?.id; - const imagePerformerIDs = (image.performers ?? []) - .map((p) => p.id) - .sort(); - const imageTagIDs = (image.tags ?? []).map((p) => p.id).sort(); - const imageGalleryIDs = (image.galleries ?? []).map((p) => p.id).sort(); - - if (first) { - updateRating = imageRating ?? undefined; - updateStudioID = imageStudioID; - updatePerformerIds = imagePerformerIDs; - updateTagIds = imageTagIDs; - updateGalleryIds = imageGalleryIDs; - updateOrganized = image.organized; - first = false; - } else { - if (imageRating !== updateRating) { - updateRating = undefined; - } - if (imageStudioID !== updateStudioID) { - updateStudioID = undefined; - } - if (!isEqual(imagePerformerIDs, updatePerformerIds)) { - updatePerformerIds = []; - } - if (!isEqual(imageTagIDs, updateTagIds)) { - updateTagIds = []; - } - if (!isEqual(imageGalleryIDs, updateGalleryIds)) { - updateGalleryIds = []; - } - if (image.organized !== updateOrganized) { - updateOrganized = undefined; - } - } - }); - - setRating(updateRating); - setStudioId(updateStudioID); - setExistingPerformerIds(updatePerformerIds); - setExistingTagIds(updateTagIds); - setExistingGalleryIds(updateGalleryIds); - setOrganized(updateOrganized); - }, [props.selected]); - - useEffect(() => { - if (checkboxRef.current) { - checkboxRef.current.indeterminate = organized === undefined; - } - }, [organized, checkboxRef]); - - function cycleOrganized() { - if (organized) { - setOrganized(undefined); - } else if (organized === undefined) { - setOrganized(false); - } else { - setOrganized(true); - } - } - function render() { return ( = ( onClick: onSave, text: intl.formatMessage({ id: "actions.apply" }), }} + disabled={isUpdating || !!dateError} cancel={{ onClick: () => props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -214,89 +163,120 @@ export const EditImagesDialog: React.FC = ( isRunning={isUpdating} >
- - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} -
- setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - - - - - - - + + setUpdateField({ rating100: value ?? undefined }) + } disabled={isUpdating} - onUpdate={(itemIDs) => setPerformerIds(itemIDs)} - onSetMode={(newMode) => setPerformerMode(newMode)} - existingIds={existingPerformerIds ?? []} - ids={performerIds ?? []} - mode={performerMode} + /> + + + + setUpdateField({ code: newValue })} + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + + + + + setUpdateField({ photographer: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} menuPortalTarget={document.body} /> - + - - - - + setTagIds(itemIDs)} - onSetMode={(newMode) => setTagMode(newMode)} - existingIds={existingTagIds ?? []} - ids={tagIds ?? []} - mode={tagMode} + onUpdate={(itemIDs) => { + setPerformerIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setPerformerIds((c) => ({ ...c, mode: newMode })); + }} + ids={performerIds.ids ?? []} + existingIds={aggregateState.performerIds} + mode={performerIds.mode} menuPortalTarget={document.body} /> - + - - - - + setGalleryIds(itemIDs)} - onSetMode={(newMode) => setGalleryMode(newMode)} - existingIds={existingGalleryIds ?? []} - ids={galleryIds ?? []} - mode={galleryMode} + onUpdate={(itemIDs) => { + setGalleryIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setGalleryIds((c) => ({ ...c, mode: newMode })); + }} + ids={galleryIds.ids ?? []} + existingIds={aggregateState.galleryIds} + mode={galleryIds.mode} menuPortalTarget={document.body} /> - + + + + { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + - cycleOrganized()} + setChecked={(checked) => setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} /> diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index adaee9923..a1189c844 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -148,7 +148,13 @@ const ImageCardDetails = PatchComponent( const ImageCardOverlays = PatchComponent( "ImageCard.Overlays", (props: IImageCardProps) => { - return ; + const ret = useMemo(() => { + return ( + + ); + }, [props.image.studio, props.selecting]); + + return ret; } ); diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index f79d95fca..f885c21bb 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -1,7 +1,7 @@ import { Tab, Nav, Dropdown } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useHistory, Link, RouteComponentProps } from "react-router-dom"; +import { useHistory, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useFindImage, @@ -37,6 +37,7 @@ import { TruncatedText } from "src/components/Shared/TruncatedText"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; import { GenerateDialog } from "src/components/Dialogs/GenerateDialog"; +import { StudioLogo } from "src/components/Shared/StudioLogo"; interface IProps { image: GQL.ImageDataFragment; @@ -51,6 +52,7 @@ const ImagePage: React.FC = ({ image }) => { const Toast = useToast(); const intl = useIntl(); const { configuration } = useConfigurationContext(); + const { showStudioText } = configuration?.ui ?? {}; const [incrementO] = useImageIncrementO(image.id); const [decrementO] = useImageDecrementO(image.id); @@ -326,17 +328,7 @@ const ImagePage: React.FC = ({ image }) => {
- {image.studio && ( -

- - {`${image.studio.name} - -

- )} +

diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx index a2044fcff..cf33b648b 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx @@ -7,6 +7,7 @@ import { sortPerformers } from "src/core/performers"; import { FormattedMessage, useIntl } from "react-intl"; import { PhotographerLink } from "src/components/Shared/Link"; import { PatchComponent } from "../../../patch"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface IImageDetailProps { image: GQL.ImageDataFragment; } @@ -132,6 +133,7 @@ export const ImageDetailPanel: React.FC = PatchComponent( {renderDetails()} {renderTags()} {renderPerformers()} +
diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index 58b809d41..7dcaa93d2 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -35,6 +35,11 @@ import { } from "src/components/Galleries/GallerySelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import cloneDeep from "lodash-es/cloneDeep"; interface IProps { image: GQL.ImageDataFragment; @@ -86,6 +91,7 @@ export const ImageEditPanel: React.FC = ({ studio_id: yup.string().required().nullable(), performer_ids: yup.array(yup.string().required()).defined(), tag_ids: yup.array(yup.string().required()).defined(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -99,15 +105,26 @@ export const ImageEditPanel: React.FC = ({ studio_id: image.studio?.id ?? null, performer_ids: (image.performers ?? []).map((p) => p.id), tag_ids: (image.tags ?? []).map((t) => t.id), + custom_fields: cloneDeep(image.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -444,7 +461,9 @@ export const ImageEditPanel: React.FC = ({ className="edit-button" variant="primary" disabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onClick={() => formik.submitForm()} > @@ -492,6 +511,13 @@ export const ImageEditPanel: React.FC = ({
{renderDetailsField()} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx index f247e062b..097a64340 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx @@ -1,8 +1,9 @@ import React, { useState } from "react"; import { Accordion, Button, Card } from "react-bootstrap"; -import { FormattedMessage, FormattedTime } from "react-intl"; +import { FormattedMessage, FormattedTime, useIntl } from "react-intl"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; +import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; import * as GQL from "src/core/generated-graphql"; import { mutateImageSetPrimaryFile } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; @@ -23,6 +24,7 @@ interface IFileInfoPanelProps { const FileInfoPanel: React.FC = ( props: IFileInfoPanelProps ) => { + const intl = useIntl(); const checksum = props.file.fingerprints.find((f) => f.type === "md5"); const phash = props.file.fingerprints.find((f) => f.type === "phash"); @@ -37,22 +39,22 @@ const FileInfoPanel: React.FC = ( )} - + - + + + + + + diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index eee789bb1..00b23b0aa 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -1,5 +1,11 @@ -import React, { useCallback, useState, useMemo, MouseEvent } from "react"; -import { FormattedNumber, useIntl } from "react-intl"; +import React, { + useCallback, + useState, + useMemo, + MouseEvent, + useEffect, +} from "react"; +import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; @@ -9,11 +15,10 @@ import { useFindImages, useFindImagesMetadata, } from "src/core/StashService"; -import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; +import { useFilteredItemList } from "../List/ItemList"; import { useLightbox } from "src/hooks/Lightbox/hooks"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; - import { ImageWallItem } from "./ImageWallItem"; import { EditImagesDialog } from "./EditImagesDialog"; import { DeleteImagesDialog } from "./DeleteImagesDialog"; @@ -24,11 +29,44 @@ import { objectTitle } from "src/core/files"; import { useConfigurationContext } from "src/hooks/Config"; import { ImageCardGrid } from "./ImageCardGrid"; import { View } from "../List/views"; -import { IItemListOperation } from "../List/FilteredListToolbar"; +import { + FilteredListToolbar, + IItemListOperation, +} from "../List/FilteredListToolbar"; import { FileSize } from "../Shared/FileSize"; -import { PatchComponent } from "src/patch"; +import { PatchComponent, PatchContainerComponent } from "src/patch"; import { GenerateDialog } from "../Dialogs/GenerateDialog"; -import { useModal } from "src/hooks/modal"; +import { + Sidebar, + SidebarPane, + SidebarPaneContent, + SidebarStateContext, + useSidebarState, +} from "../Shared/Sidebar"; +import { useCloseEditDelete, useFilterOperations } from "../List/util"; +import { + FilteredSidebarHeader, + useFilteredSidebarKeybinds, +} from "../List/Filters/FilterSidebar"; +import { + IListFilterOperation, + ListOperations, +} from "../List/ListOperationButtons"; +import { FilterTags } from "../List/FilterTags"; +import { Pagination, PaginationIndex } from "../List/Pagination"; +import { LoadedContent } from "../List/PagedList"; +import useFocus from "src/utils/focus"; +import cx from "classnames"; +import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; +import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; +import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; +import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; +import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; +import { Button } from "react-bootstrap"; +import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; +import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; +import { PerformerAgeCriterionOption } from "src/models/list-filter/images"; +import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; interface IImageWallProps { images: GQL.SlimImageDataFragment[]; @@ -180,131 +218,125 @@ interface IImageListImages { chapters?: GQL.GalleryChapterDataFragment[]; } -const ImageListImages: React.FC = ({ - images, - filter, - selectedIds, - onChangePage, - pageCount, - onSelectChange, - slideshowRunning, - setSlideshowRunning, - chapters = [], -}) => { - const handleLightBoxPage = useCallback( - (props: { direction?: number; page?: number }) => { - const { direction, page: newPage } = props; - - if (direction !== undefined) { - if (direction < 0) { - if (filter.currentPage === 1) { - onChangePage(pageCount); - } else { - onChangePage(filter.currentPage + direction); - } - } else if (direction > 0) { - if (filter.currentPage === pageCount) { - // return to the first page - onChangePage(1); - } else { - onChangePage(filter.currentPage + direction); - } - } - } else if (newPage !== undefined) { - onChangePage(newPage); - } - }, - [onChangePage, filter.currentPage, pageCount] - ); - - const handleClose = useCallback(() => { - setSlideshowRunning(false); - }, [setSlideshowRunning]); - - const lightboxState = useMemo(() => { - return { - images, - showNavigation: false, - pageCallback: pageCount > 1 ? handleLightBoxPage : undefined, - page: filter.currentPage, - pages: pageCount, - pageSize: filter.itemsPerPage, - slideshowEnabled: slideshowRunning, - onClose: handleClose, - }; - }, [ +const ImageList: React.FC = PatchComponent( + "ImageList", + ({ images, + filter, + selectedIds, + onChangePage, pageCount, - filter.currentPage, - filter.itemsPerPage, + onSelectChange, slideshowRunning, - handleClose, - handleLightBoxPage, - ]); + setSlideshowRunning, + chapters = [], + }) => { + const handleLightBoxPage = useCallback( + (props: { direction?: number; page?: number }) => { + const { direction, page: newPage } = props; - const showLightbox = useLightbox( - lightboxState, - filter.sortBy === "path" && - filter.sortDirection === GQL.SortDirectionEnum.Asc - ? chapters - : [] - ); - - const handleImageOpen = useCallback( - (index) => { - setSlideshowRunning(true); - showLightbox({ initialIndex: index, slideshowEnabled: true }); - }, - [showLightbox, setSlideshowRunning] - ); - - function onPreview(index: number, ev: MouseEvent) { - handleImageOpen(index); - ev.preventDefault(); - } - - if (filter.displayMode === DisplayMode.Grid) { - return ( - + if (direction !== undefined) { + if (direction < 0) { + if (filter.currentPage === 1) { + onChangePage(pageCount); + } else { + onChangePage(filter.currentPage + direction); + } + } else if (direction > 0) { + if (filter.currentPage === pageCount) { + // return to the first page + onChangePage(1); + } else { + onChangePage(filter.currentPage + direction); + } + } + } else if (newPage !== undefined) { + onChangePage(newPage); + } + }, + [onChangePage, filter.currentPage, pageCount] ); - } - if (filter.displayMode === DisplayMode.Wall) { - return ( - 0} - /> + + const handleClose = useCallback(() => { + setSlideshowRunning(false); + }, [setSlideshowRunning]); + + const lightboxState = useMemo(() => { + return { + images, + showNavigation: false, + pageCallback: pageCount > 1 ? handleLightBoxPage : undefined, + page: filter.currentPage, + pages: pageCount, + pageSize: filter.itemsPerPage, + slideshowEnabled: slideshowRunning, + onClose: handleClose, + }; + }, [ + images, + pageCount, + filter.currentPage, + filter.itemsPerPage, + slideshowRunning, + handleClose, + handleLightBoxPage, + ]); + + const showLightbox = useLightbox( + lightboxState, + filter.sortBy === "path" && + filter.sortDirection === GQL.SortDirectionEnum.Asc + ? chapters + : [] ); + + const handleImageOpen = useCallback( + (index) => { + setSlideshowRunning(true); + showLightbox({ initialIndex: index, slideshowEnabled: true }); + }, + [showLightbox, setSlideshowRunning] + ); + + function onPreview(index: number, ev: MouseEvent) { + handleImageOpen(index); + ev.preventDefault(); + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.Wall) { + return ( + 0} + /> + ); + } + + // should not happen + return <>; } - - // should not happen - return <>; -}; - -function getItems(result: GQL.FindImagesQueryResult) { - return result?.data?.findImages?.images ?? []; -} - -function getCount(result: GQL.FindImagesQueryResult) { - return result?.data?.findImages?.count ?? 0; -} +); function renderMetadataByline( - result: GQL.FindImagesQueryResult, - metadataInfo?: GQL.FindImagesMetadataQueryResult + metadataInfo: GQL.FindImagesMetadataQueryResult | undefined ) { const megapixels = metadataInfo?.data?.findImages?.megapixels; const size = metadataInfo?.data?.findImages?.filesize; @@ -339,6 +371,136 @@ function renderMetadataByline( ); } +const ImageFilterSidebarSections = PatchContainerComponent( + "FilteredImageList.SidebarSections" +); + +const SidebarContent: React.FC<{ + filter: ListFilterModel; + setFilter: (filter: ListFilterModel) => void; + filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; + sidebarOpen: boolean; + onClose?: () => void; + showEditFilter: (editingCriterion?: string) => void; + count?: number; + focus?: ReturnType; +}> = ({ + filter, + setFilter, + filterHook, + view, + showEditFilter, + sidebarOpen, + onClose, + count, + focus, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + + const hideStudios = view === View.StudioScenes; + + return ( + <> + + + + {!hideStudios && ( + + )} + + + + } + filter={filter} + setFilter={setFilter} + sectionID="folder" + /> + } + data-type={OrganizedCriterionOption.type} + option={OrganizedCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="organized" + /> + } + option={PerformerAgeCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="performer_age" + /> + + +
+ +
+ + ); +}; + +function useViewRandom(filter: ListFilterModel, count: number) { + const history = useHistory(); + + const viewRandom = useCallback(async () => { + // query for a random image + if (count === 0) { + return; + } + + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindImages(filterCopy); + if (singleResult.data.findImages.images.length === 1) { + const { id } = singleResult.data.findImages.images[0]; + // navigate to the image player page + history.push(`/images/${id}`); + } + }, [history, filter, count]); + + return viewRandom; +} + +function useAddKeybinds(filter: ListFilterModel, count: number) { + const viewRandom = useViewRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + viewRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [viewRandom]); +} + interface IImageList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; @@ -347,28 +509,185 @@ interface IImageList { chapters?: GQL.GalleryChapterDataFragment[]; } -export const ImageList: React.FC = PatchComponent( - "ImageList", - ({ filterHook, view, alterQuery, extraOperations = [], chapters = [] }) => { +export const FilteredImageList = PatchComponent( + "FilteredImageList", + (props: IImageList) => { const intl = useIntl(); - const history = useHistory(); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExportAll, setIsExportAll] = useState(false); + const [slideshowRunning, setSlideshowRunning] = useState(false); - const filterMode = GQL.FilterMode.Images; + const searchFocus = useFocus(); - const { modal, showModal, closeModal } = useModal(); + const withSidebar = props.view !== View.GalleryImages; - const otherOperations: IItemListOperation[] = [ - ...extraOperations, + const { + filterHook, + view, + alterQuery, + extraOperations: providedOperations = [], + chapters, + } = props; + + // States + const { + showSidebar, + setShowSidebar, + sectionOpen, + setSectionOpen, + loading: sidebarStateLoading, + } = useSidebarState(view); + + const { + filterState, + queryResult, + metadataInfo, + modalState, + listSelect, + showEditFilter, + } = useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.Images, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindImages, + useMetadataInfo: useFindImagesMetadata, + getCount: (r) => r.data?.findImages.count ?? 0, + getItems: (r) => r.data?.findImages.images ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const metadataByline = useMemo(() => { + if (cachedResult.loading) return null; + + return renderMetadataByline(metadataInfo) ?? null; + }, [cachedResult.loading, metadataInfo]); + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, + }); + + useAddKeybinds(filter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); + + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); + + const viewRandom = useViewRandom(effectiveFilter, totalCount); + + function onExport(all: boolean) { + showModal( + closeModal()} + /> + ); + } + + const onEdit = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + + const onDelete = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } + }); + + return () => { + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); + }; + }, [hasSelection, onEdit, onDelete]); + + const convertedExtraOperations: IListFilterOperation[] = + providedOperations.map((o) => ({ + ...o, + isDisplayed: o.isDisplayed + ? () => o.isDisplayed!(result, filter, selectedIds) + : undefined, + onClick: () => { + o.onClick(result, filter, selectedIds); + }, + })); + + const otherOperations: IListFilterOperation[] = [ + ...convertedExtraOperations, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, { text: intl.formatMessage({ id: "actions.view_random" }), onClick: viewRandom, }, { text: `${intl.formatMessage({ id: "actions.generate" })}…`, - onClick: (result, filter, selectedIds) => { + onClick: () => { showModal( = PatchComponent( onClose={() => closeModal()} /> ); - return Promise.resolve(); }, - isDisplayed: showWhenSelected, + isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export" }), - onClick: onExport, - isDisplayed: showWhenSelected, + onClick: () => onExport(false), + isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export_all" }), - onClick: onExportAll, + onClick: () => onExport(true), }, ]; - function addKeybinds( - result: GQL.FindImagesQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - viewRandom(result, filter); - }); + // render + if (sidebarStateLoading) return null; - return () => { - Mousetrap.unbind("p r"); - }; - } + const operations = ( + + ); - async function viewRandom( - result: GQL.FindImagesQueryResult, - filter: ListFilterModel - ) { - // query for a random image - if (result.data?.findImages) { - const { count } = result.data.findImages; + const pageCount = Math.ceil(totalCount / filter.itemsPerPage); - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindImages(filterCopy); - if (singleResult.data.findImages.images.length === 1) { - const { id } = singleResult.data.findImages.images[0]; - // navigate to the image player page - history.push(`/images/${id}`); - } - } - } + const content = ( + <> + - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } +
+ + +
- function renderContent( - result: GQL.FindImagesQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: ( - id: string, - selected: boolean, - shiftKey: boolean - ) => void, - onChangePage: (page: number) => void, - pageCount: number - ) { - function maybeRenderImageExportDialog() { - if (isExportDialogOpen) { - return ( - setIsExportDialogOpen(false)} - /> - ); - } - } - - function renderImages() { - if (!result.data?.findImages) return; - - return ( - + = PatchComponent( setSlideshowRunning={setSlideshowRunning} chapters={chapters} /> - ); - } + - return ( - <> - {maybeRenderImageExportDialog()} - {renderImages()} - - ); - } - - function renderEditDialog( - selectedImages: GQL.SlimImageDataFragment[], - onClose: (applied: boolean) => void - ) { - return ; - } - - function renderDeleteDialog( - selectedImages: GQL.SlimImageDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ; - } + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} + + ); return ( - + <> {modal} - - + {!withSidebar ? ( +
{content}
+ ) : ( +
+ + + setShowSidebar(false)} + > + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + {content} + + + +
+ )} + ); } ); diff --git a/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx index 6499be894..0541e5934 100644 --- a/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx +++ b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx @@ -1,13 +1,9 @@ import React from "react"; -import { Link } from "react-router-dom"; import { useFindImages } from "src/core/StashService"; -import Slider from "@ant-design/react-slick"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { getSlickSliderSettings } from "src/core/recommendations"; -import { RecommendationRow } from "../FrontPage/RecommendationRow"; -import { FormattedMessage } from "react-intl"; import { ImageCard } from "./ImageCard"; import { PatchComponent } from "src/patch"; +import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; @@ -19,40 +15,26 @@ export const ImageRecommendationRow: React.FC = PatchComponent( "ImageRecommendationRow", (props: IProps) => { const result = useFindImages(props.filter); - const cardCount = result.data?.findImages.count; - - if (!result.loading && !cardCount) { - return null; - } + const count = result.data?.findImages.count ?? 0; return ( - - - - } + heading={props.header} + url={`/images?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findImages.images.map((i) => ( - - ))} -
-
+ {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findImages.images.map((i) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/Images/Images.tsx b/ui/v2.5/src/components/Images/Images.tsx index 91edfdf79..932bbc2c1 100644 --- a/ui/v2.5/src/components/Images/Images.tsx +++ b/ui/v2.5/src/components/Images/Images.tsx @@ -3,11 +3,11 @@ import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Image from "./ImageDetails/Image"; -import { ImageList } from "./ImageList"; +import { FilteredImageList } from "./ImageList"; import { View } from "../List/views"; const Images: React.FC = () => { - return ; + return ; }; const ImageRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Images/styles.scss b/ui/v2.5/src/components/Images/styles.scss index 0050a9434..0a8ca760e 100644 --- a/ui/v2.5/src/components/Images/styles.scss +++ b/ui/v2.5/src/components/Images/styles.scss @@ -9,7 +9,7 @@ order: 1; } - .image-studio-image { + .studio-logo { flex: 0 0 25%; order: 2; } @@ -179,6 +179,20 @@ $imageTabWidth: 450px; .form-group[data-field="urls"] .string-list-input input.form-control { font-size: 0.85em; } + + @include media-breakpoint-up(xl) { + .custom-fields-input { + .custom-fields-field { + flex: 0 0 25%; + max-width: 25%; + } + + .custom-fields-value { + flex: 0 0 75%; + max-width: 75%; + } + } + } } .image-file-card.card { diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index 8795296fa..8a72d6e43 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -52,6 +52,11 @@ import { PathCriterion } from "src/models/list-filter/criteria/path"; import { ModifierSelectorButtons } from "./ModifierSelect"; import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields"; import { CustomFieldsFilter } from "./Filters/CustomFieldsFilter"; +import { FolderFilter } from "./Filters/FolderFilter"; +import { + FolderCriterion, + ParentFolderCriterion, +} from "src/models/list-filter/criteria/folder"; interface IGenericCriterionEditor { criterion: ModifierCriterion; @@ -68,7 +73,9 @@ const GenericCriterionEditor: React.FC = ({ if ( criterion instanceof PerformersCriterion || criterion instanceof StudiosCriterion || - criterion instanceof TagsCriterion + criterion instanceof TagsCriterion || + criterion instanceof FolderCriterion || + criterion instanceof ParentFolderCriterion ) { return false; } @@ -163,6 +170,18 @@ const GenericCriterionEditor: React.FC = ({ ); } + if ( + criterion instanceof FolderCriterion || + criterion instanceof ParentFolderCriterion + ) { + return ( + setCriterion(c)} + /> + ); + } + if (criterion instanceof ILabeledIdCriterion) { return ( ; onRemove: React.MouseEventHandler; -}> = ({ className, label, onClick, onRemove }) => { + unsupported?: boolean; +}> = ({ className, label, onClick, onRemove, unsupported }) => { + function handleClick(e: React.MouseEvent) { + if (unsupported) { + return; + } + onClick(e); + } + return ( - + + {unsupported && ( + + )} {label} + )} + + + {folder.expanded && + folder.children?.map((child) => ( + + ))} + + ); +}; + +function toggleExpandedFn(object: IFolder): (f: IFolder) => IFolder { + return (f: IFolder) => { + if (f.id === object.id) { + return { ...f, expanded: !f.expanded }; + } + + if (f.children) { + return { + ...f, + children: f.children.map(toggleExpandedFn(object)), + }; + } + + return f; + }; +} + +function replaceFolder(folder: IFolder): (f: IFolder) => IFolder { + return (f: IFolder) => { + if (f.id === folder.id) { + return folder; + } + + if (f.children) { + return { + ...f, + children: f.children.map(replaceFolder(folder)), + }; + } + + return f; + }; +} + +function mergeFolderMaps(base: IFolder[], update: IFolder[]): IFolder[] { + const ret = [...base]; + + update.forEach((updateFolder) => { + const existingIndex = ret.findIndex((f) => f.id === updateFolder.id); + if (existingIndex === -1) { + // not found, add to the end + ret.push(updateFolder); + } else { + // found, replace + ret[existingIndex] = updateFolder; + } + }); + + return ret; +} + +function useFolderMap(props: { + query: string; + skip?: boolean; + initialSelected?: string[]; + mode?: FilterMode; +}) { + const { query, skip = false, initialSelected, mode } = props; + + const [cachedInitialSelected] = useState(initialSelected ?? []); + + // exclude zip folders for scenes and galleries + const excludeZipFolders = + mode === FilterMode.Scenes || mode === FilterMode.Galleries; + + const zipFileFilter: MultiCriterionInput | undefined = useMemo( + () => + excludeZipFolders + ? { + modifier: CriterionModifier.IsNull, + } + : undefined, + [excludeZipFolders] + ); + + const folderFilterForQuery = useMemo( + () => (zipFileFilter ? { zip_file: zipFileFilter } : undefined), + [zipFileFilter] + ); + + const { data: rootFoldersResult } = useFindRootFoldersForSelectQuery({ + skip, + variables: { + zip_file_filter: zipFileFilter, + }, + }); + + const { data: queryFoldersResult } = useFindFoldersForQueryQuery({ + skip: !query, + variables: { + filter: { q: query, per_page: 200 }, + folder_filter: folderFilterForQuery, + }, + }); + + const { data: initialSelectedResult } = useFindFolderHierarchyForIDsQuery({ + skip: !initialSelected || cachedInitialSelected.length === 0, + variables: { + ids: cachedInitialSelected ?? [], + }, + }); + + const rootFolders: IFolder[] = useMemo(() => { + const ret = rootFoldersResult?.findFolders.folders ?? []; + return ret.map((f) => ({ ...f, expanded: false, children: undefined })); + }, [rootFoldersResult]); + + const initialSelectedFolders: IFolder[] = useMemo(() => { + const ret: IFolder[] = []; + (initialSelectedResult?.findFolders.folders ?? []).forEach((folder) => { + if (!folder.parent_folders.length) { + // add root folder if not present + if (!ret.find((f) => f.id === folder.id)) { + ret.push({ ...folder, expanded: true, children: [] }); + } + return; + } + + let currentParent: IFolder | undefined; + + for (let i = folder.parent_folders.length - 1; i >= 0; i--) { + const thisFolder = folder.parent_folders[i]; + let existing: IFolder | undefined; + + if (i === folder.parent_folders.length - 1) { + // last parent, add the folder as root if not present + existing = ret.find((f) => f.id === thisFolder.id); + if (!existing) { + existing = { + ...folder.parent_folders[i], + expanded: true, + children: folder.parent_folders[i].sub_folders + // filter out zip folders if needed + .filter((f) => f.zip_file === null || !excludeZipFolders) + .map((f) => ({ + ...f, + expanded: false, + children: undefined, + })), + }; + ret.push(existing); + } + currentParent = existing; + continue; + } + + const existingIndex = + currentParent!.children?.findIndex((f) => f.id === thisFolder.id) ?? + -1; + if (existingIndex === -1) { + // should be guaranteed + throw new Error( + `Parent folder ${thisFolder.id} not found in children of ${ + currentParent!.id + }` + ); + } + + existing = currentParent!.children![existingIndex]; + + // replace children + existing = { + ...existing, + expanded: true, + // filter out zip folders if needed + children: thisFolder.sub_folders + .filter((f) => f.zip_file === null || !excludeZipFolders) + .map((f) => ({ + ...f, + expanded: false, + children: undefined, + })), + }; + + currentParent!.children![existingIndex] = existing; + currentParent = existing; + } + }); + return ret; + }, [initialSelectedResult, excludeZipFolders]); + + const mergedRootFolders = useMemo(() => { + if (query) { + return rootFolders; + } + + return mergeFolderMaps(rootFolders, initialSelectedFolders); + }, [rootFolders, initialSelectedFolders, query]); + + const queryFolders: IFolder[] = useMemo(() => { + // construct the folder list from the query result + const ret: IFolder[] = []; + + (queryFoldersResult?.findFolders.folders ?? []).forEach((folder) => { + if (!folder.parent_folders.length) { + // no parents, just add it if not present + if (!ret.find((f) => f.id === folder.id)) { + ret.push({ ...folder, expanded: true, children: [] }); + } + return; + } + + // expand the parent folders + let currentParent: IFolder | undefined; + for (let i = folder.parent_folders.length - 1; i >= 0; i--) { + const thisFolder = folder.parent_folders[i]; + let existing: IFolder | undefined; + + if (i === folder.parent_folders.length - 1) { + // last parent, add the folder as root + existing = ret.find((f) => f.id === thisFolder.id); + if (!existing) { + existing = { + ...folder.parent_folders[i], + expanded: true, + children: [], + }; + ret.push(existing); + } + currentParent = existing; + continue; + } + + // find folder in current parent's children + // currentParent is guaranteed to be defined here + existing = currentParent!.children?.find((f) => f.id === thisFolder.id); + if (!existing) { + // add to current parent's children + existing = { + ...thisFolder, + expanded: true, + children: [], + }; + currentParent!.children!.push(existing); + } + currentParent = existing; + } + + if (!currentParent) { + return; + } + + if (!currentParent.children) { + currentParent.children = []; + } + + // currentParent is now the immediate parent folder + currentParent!.children!.push({ + ...folder, + expanded: false, + children: undefined, + }); + }); + return ret; + }, [queryFoldersResult]); + + const [folderMap, setFolderMap] = React.useState([]); + + useEffect(() => { + if (!query) { + setFolderMap(mergedRootFolders); + } else { + setFolderMap(queryFolders); + } + }, [query, mergedRootFolders, queryFolders]); + + async function onToggleExpanded(folder: IFolder) { + setFolderMap(folderMap.map(toggleExpandedFn(folder))); + + // query children folders if not already loaded + if (folder.children === undefined) { + const subFolderResult = await queryFindSubFolders( + folder.id, + excludeZipFolders + ); + setFolderMap((current) => + current.map( + replaceFolder({ + ...folder, + expanded: true, + children: subFolderResult.data.findFolders.folders.map((f) => ({ + ...f, + expanded: false, + })), + }) + ) + ); + } + } + + return { folderMap, onToggleExpanded }; +} + +function getMatchingFolders(folders: IFolder[], query: string): IFolder[] { + let matches: IFolder[] = []; + + const queryLower = query.toLowerCase(); + + folders.forEach((folder) => { + if ( + folder.basename.toLowerCase().includes(queryLower) || + folder.path.toLowerCase() === queryLower + ) { + matches.push(folder); + } + + if (folder.children) { + matches = matches.concat(getMatchingFolders(folder.children, query)); + } + }); + + return matches; +} + +export const FolderSelector: React.FC<{ + onSelect: (folder: IFolder, exclude?: boolean) => void; + canExclude?: boolean; + preListContent?: React.ReactNode; + folderMap: IFolder[]; + onToggleExpanded: (folder: IFolder) => void; +}> = ({ + onSelect, + preListContent, + canExclude = false, + folderMap, + onToggleExpanded, +}) => { + return ( +
    + {preListContent} + {folderMap.map((folder) => ( + onSelect(f, exclude)} + toggleExpanded={onToggleExpanded} + canExclude={canExclude} + /> + ))} +
+ ); +}; + +interface IInputFilterProps { + criterion: FolderCriterion; + setCriterion: (c: FolderCriterion) => void; + mode?: FilterMode; +} + +export const FolderFilter: React.FC = ({ + criterion, + setCriterion, + mode, +}) => { + const intl = useIntl(); + const [query, setQuery] = useState(""); + const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250); + + const { folderMap, onToggleExpanded } = useFolderMap({ query, mode }); + + const messages = defineMessages({ + sub_folder_depth: { + id: "sub_folder_depth", + defaultMessage: "Levels (empty for all)", + }, + }); + + function criterionOptionTypeToIncludeID(): string { + return "include-sub-folders"; + } + + function criterionOptionTypeToIncludeUIString(): MessageDescriptor { + const optionType = "include_sub_folders"; + + return { + id: optionType, + }; + } + + function onDepthChanged(depth: number) { + // this could be ParentFolderCriterion, but the types are the same + const newValue = criterion.clone() as FolderCriterion; + newValue.value.depth = depth; + setCriterion(newValue); + } + + function onSelect(folder: IFolder, exclude: boolean = false) { + // toggle selection + const newValue = criterion.clone() as FolderCriterion; + + if (!exclude) { + if (newValue.value.items.find((i) => i.id === folder.id)) { + return; + } + + newValue.value.items.push({ id: folder.id, label: folder.path }); + } else { + if (newValue.value.excluded.find((i) => i.id === folder.id)) { + return; + } + + newValue.value.excluded.push({ id: folder.id, label: folder.path }); + } + + setCriterion(newValue); + } + + const onUnselect = useCallback( + (i: Option, excluded?: boolean) => { + const newValue = criterion.clone() as FolderCriterion; + + if (!excluded) { + newValue.value.items = newValue.value.items.filter( + (item) => item.id !== i.id + ); + } else { + newValue.value.excluded = newValue.value.excluded.filter( + (item) => item.id !== i.id + ); + } + setCriterion(newValue); + }, + [criterion, setCriterion] + ); + + function onEnter() { + if (!query) return; + + // if there is a single folder that matches the query, select it + const matchingFolders = getMatchingFolders(folderMap, query); + if (matchingFolders.length === 1) { + onSelect(matchingFolders[0]); + } + } + + const selectedList = useMemo(() => { + const selected: Option[] = + criterion.value?.items.map((item) => ({ + id: item.id, + label: item.label, + })) ?? []; + + return ; + }, [criterion, onUnselect]); + + const excludedList = useMemo(() => { + const selected: Option[] = + criterion.value?.excluded.map((item) => ({ + id: item.id, + label: item.label, + })) ?? []; + + return ( + onUnselect(i, true)} + /> + ); + }, [criterion, onUnselect]); + + return ( +
+ + + + {selectedList} + {excludedList} + onQueryChange(v)} + placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} + onEnter={onEnter} + /> + + +
+ ); +}; + +export const SidebarFolderFilter: React.FC< + ISidebarSectionProps & { + filter: ListFilterModel; + setFilter: (f: ListFilterModel) => void; + criterionOption?: ModifierCriterionOption; + } +> = (props) => { + const intl = useIntl(); + const [skip, setSkip] = useState(true); + const [query, setQuery] = useState(""); + const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250); + + function onOpen() { + setSkip(false); + props.onOpen?.(); + } + + const option = props.criterionOption ?? FolderCriterionOption; + const { filter, setFilter } = props; + + const criterion = useMemo(() => { + const ret = filter.criteria.find( + (c) => c.criterionOption.type === option.type + ); + if (ret) return ret as FolderCriterion; + + const newCriterion = filter.makeCriterion(option.type) as FolderCriterion; + return newCriterion; + }, [option.type, filter]); + + const subDirsSelected = criterion.value?.depth === -1; + + // if there are multiple values or excluded values, then we show none of the + // current values + const multipleSelected = + criterion.value.items.length > 1 || criterion.value.excluded.length > 0; + + const { folderMap, onToggleExpanded } = useFolderMap({ + query, + skip, + initialSelected: criterion.value.items.map((i) => i.id), + mode: filter.mode, + }); + + function onSelect(folder: IFolder) { + // maintain sub-folder select if present + const depth = subDirsSelected ? -1 : 0; + + const c = criterion.clone() as FolderCriterion; + c.value = { + items: [{ id: folder.id, label: folder.path }], + depth, + excluded: [], + }; + + const newCriteria = props.filter.criteria.filter( + (cc) => cc.criterionOption.type !== option.type + ); + + if (c.isValid()) newCriteria.push(c); + + setFilter(props.filter.setCriteria(newCriteria)); + } + + function onSelectSubfolders() { + const c = criterion.clone() as FolderCriterion; + c.value = { + items: c.value?.items ?? [], + depth: -1, + excluded: c.value?.excluded ?? [], + }; + + setFilter(props.filter.replaceCriteria(option.type, [c])); + } + + const onUnselect = useCallback( + (i: Option) => { + if (i.className === "modifier-object") { + // subfolders option + const c = criterion.clone() as FolderCriterion; + c.value = { + items: c.value?.items ?? [], + depth: 0, + excluded: c.value?.excluded ?? [], + }; + + setFilter(props.filter.replaceCriteria(option.type, [c])); + return; + } + + setFilter(props.filter.removeCriterion(option.type)); + }, + [props.filter, setFilter, option.type, criterion] + ); + + function onEnter() { + if (!query) return; + + // if there is a single folder that matches the query, select it + const matchingFolders = getMatchingFolders(folderMap, query); + if (matchingFolders.length === 1) { + onSelect(matchingFolders[0]); + } + } + + const selectedList = useMemo(() => { + if (multipleSelected) { + return null; + } + + const selected: Option[] = + criterion.value?.items.map((item) => ({ + id: item.id, + label: item.label, + })) ?? []; + + if (subDirsSelected) { + selected.push({ + id: "subfolders", + label: "(" + intl.formatMessage({ id: "sub_folders" }) + ")", + className: "modifier-object", + }); + } + + return ; + }, [intl, multipleSelected, subDirsSelected, criterion, onUnselect]); + + const modifierItem = criterion.value.items.length > 0 && + !multipleSelected && + !subDirsSelected && ( +
  • + + + + () + + +
  • + ); + + return ( + + onQueryChange(v)} + placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} + onEnter={onEnter} + /> + + onSelect(f)} + /> + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index a9163578f..355a85d67 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -20,10 +20,12 @@ import { FilterMode, GalleryFilterType, GroupFilterType, + ImageFilterType, InputMaybe, IntCriterionInput, PerformerFilterType, SceneFilterType, + SceneMarkerFilterType, StudioFilterType, } from "src/core/generated-graphql"; import { useIntl } from "react-intl"; @@ -389,10 +391,17 @@ export function useCandidates(props: { const defaultModifier = getDefaultModifier(singleValue); const candidates = useMemo(() => { + return (results ?? []).map((r) => ({ + id: r.id, + label: r.label, + })); + }, [results]); + + const modifierCandidates = useMemo(() => { const hierarchicalCandidate = hierarchical && (criterion.value as IHierarchicalLabelValue).depth !== -1; - const modifierCandidates: Option[] = getModifierCandidates({ + return getModifierCandidates({ modifier, defaultModifier, hasSelected: selected.length > 0, @@ -414,19 +423,11 @@ export function useCandidates(props: { canExclude: false, }; }); - - return modifierCandidates.concat( - (results ?? []).map((r) => ({ - id: r.id, - label: r.label, - })) - ); }, [ defaultModifier, intl, modifier, singleValue, - results, selected, excluded, criterion.value, @@ -434,7 +435,7 @@ export function useCandidates(props: { includeSubMessageID, ]); - return candidates; + return { candidates, modifierCandidates }; } export function useLabeledIdFilterState(props: { @@ -479,7 +480,7 @@ export function useLabeledIdFilterState(props: { includeSubMessageID, }); - const candidates = useCandidates({ + const { candidates, modifierCandidates } = useCandidates({ criterion, queryResults, selected, @@ -495,6 +496,7 @@ export function useLabeledIdFilterState(props: { return { candidates, + modifierCandidates, onSelect, onUnselect, selected, @@ -523,10 +525,14 @@ interface IFilterType { performer_count?: InputMaybe; galleries_filter?: InputMaybe; gallery_count?: InputMaybe; + images_filter?: InputMaybe; + image_count?: InputMaybe; groups_filter?: InputMaybe; group_count?: InputMaybe; studios_filter?: InputMaybe; studio_count?: InputMaybe; + marker_count?: InputMaybe; + markers_filter?: InputMaybe; } export function setObjectFilter( @@ -549,6 +555,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.scenes_filter = relatedFilterOutput as SceneFilterType; break; @@ -559,6 +566,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.performers_filter = relatedFilterOutput as PerformerFilterType; break; @@ -569,9 +577,21 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.galleries_filter = relatedFilterOutput as GalleryFilterType; break; + case FilterMode.Images: + // if empty, only get objects with galleries + if (empty) { + out.image_count = { + modifier: CriterionModifier.GreaterThan, + value: 0, + }; + break; + } + out.images_filter = relatedFilterOutput as ImageFilterType; + break; case FilterMode.Groups: // if empty, only get objects with groups if (empty) { @@ -579,6 +599,7 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.groups_filter = relatedFilterOutput as GroupFilterType; break; @@ -589,9 +610,21 @@ export function setObjectFilter( modifier: CriterionModifier.GreaterThan, value: 0, }; + break; } out.studios_filter = relatedFilterOutput as StudioFilterType; break; + case FilterMode.SceneMarkers: + // if empty, only get objects with scene markers + if (empty) { + out.marker_count = { + modifier: CriterionModifier.GreaterThan, + value: 0, + }; + break; + } + out.markers_filter = relatedFilterOutput as SceneMarkerFilterType; + break; default: throw new Error("Invalid filter mode"); } diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx index 9ea4333da..e599f3a87 100644 --- a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -19,7 +19,12 @@ import { ModifierCriterion, IHierarchicalLabeledIdCriterion, } from "src/models/list-filter/criteria/criterion"; -import { defineMessages, MessageDescriptor, useIntl } from "react-intl"; +import { + defineMessages, + FormattedMessage, + MessageDescriptor, + useIntl, +} from "react-intl"; import { CriterionModifier } from "src/core/generated-graphql"; import { keyboardClickHandler } from "src/utils/keyboard"; import { useDebounce } from "src/hooks/debounce"; @@ -118,7 +123,9 @@ const UnselectedItem: React.FC<{ onKeyDown={(e) => e.stopPropagation()} className="minimal exclude-button" > - exclude + + + {excludeIcon} )} @@ -240,12 +247,19 @@ const SelectableFilter: React.FC = ({ onSetModifier(defaultModifier); } + function onEnter() { + if (objects.length === 1) { + onSelect(objects[0], false); + } + } + return (
    onQueryChange(v)} + onEnter={onEnter} placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} />
      @@ -450,6 +464,42 @@ export const ObjectsFilter = < ); }; +export const DepthSelector: React.FC<{ + depth: number | undefined; + onDepthChanged: (depth: number) => void; + id: string; + label?: React.ReactNode; + placeholder?: string; + disabled?: boolean; +}> = ({ depth, onDepthChanged, id, label, disabled, placeholder }) => { + return ( + + + onDepthChanged(depth !== 0 ? 0 : -1)} + disabled={disabled} + /> + + {depth !== 0 && ( + + + onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) + } + defaultValue={depth !== -1 ? depth : ""} + min="1" + /> + + )} + + ); +}; + interface IHierarchicalObjectsFilter extends IObjectsFilter {} @@ -497,38 +547,15 @@ export const HierarchicalObjectsFilter = < } return ( -
      - - onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)} - disabled={criterion.modifier === CriterionModifier.Equals} - /> - - - {criterion.value.depth !== 0 && ( - - - onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) - } - defaultValue={ - criterion.value && criterion.value.depth !== -1 - ? criterion.value.depth - : "" - } - min="1" - /> - - )} +
      + - +
      ); }; diff --git a/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx b/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx index fe9b7987c..14e11e968 100644 --- a/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx @@ -182,7 +182,8 @@ const QueryField: React.FC<{ focus: ReturnType; value: string; setValue: (query: string) => void; -}> = ({ focus, value, setValue }) => { + onEnter?: () => void; +}> = ({ focus, value, setValue, onEnter }) => { const intl = useIntl(); const [displayQuery, setDisplayQuery] = useState(value); @@ -206,6 +207,7 @@ const QueryField: React.FC<{ value={displayQuery} setValue={(v) => onQueryChange(v)} placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} + onEnter={onEnter} /> ); }; @@ -214,6 +216,7 @@ interface IQueryableProps { inputFocus?: ReturnType; query?: string; setQuery?: (query: string) => void; + onEnter?: () => void; } export const CandidateList: React.FC< @@ -227,6 +230,7 @@ export const CandidateList: React.FC< inputFocus, query, setQuery, + onEnter, items, onSelect, canExclude, @@ -242,6 +246,7 @@ export const CandidateList: React.FC< focus={inputFocus} value={query} setValue={(v) => setQuery(v)} + onEnter={onEnter} /> )}
        @@ -265,6 +270,7 @@ export const SidebarListFilter: React.FC<{ selected: Option[]; excluded?: Option[]; candidates: Option[]; + modifierCandidates?: Option[]; singleValue?: boolean; onSelect: (item: Option, exclude: boolean) => void; onUnselect: (item: Option, exclude: boolean) => void; @@ -283,6 +289,7 @@ export const SidebarListFilter: React.FC<{ selected, excluded, candidates, + modifierCandidates, onSelect, onUnselect, canExclude, @@ -324,6 +331,20 @@ export const SidebarListFilter: React.FC<{ } } + function onEnter() { + if (candidates && candidates.length === 1) { + selectHook(candidates[0], false); + } + } + + const items = useMemo(() => { + if (!modifierCandidates) { + return candidates; + } + + return [...modifierCandidates, ...candidates]; + }, [candidates, modifierCandidates]); + return ( {preCandidates ?
        {preCandidates}
        : null} {postCandidates ?
        {postCandidates}
        : null}
        diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 67d09e721..893f1d005 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -1,33 +1,11 @@ -import React, { - PropsWithChildren, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; -import * as GQL from "src/core/generated-graphql"; import { QueryResult } from "@apollo/client"; -import { Criterion } from "src/models/list-filter/criteria/criterion"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { - EditFilterDialog, - useShowEditFilter, -} from "src/components/List/EditFilterDialog"; -import { FilterTags } from "./FilterTags"; -import { View } from "./views"; +import { useShowEditFilter } from "src/components/List/EditFilterDialog"; import { IHasID } from "src/utils/data"; -import { - ListContext, - QueryResultContext, - useListContext, - useQueryResultContext, -} from "./ListProvider"; -import { FilterContext, SetFilterURL, useFilter } from "./FilterProvider"; import { useModal } from "src/hooks/modal"; import { IFilterStateHook, IQueryResultHook, - useDefaultFilter, useEnsureValidPage, useFilterOperations, useFilterState, @@ -36,26 +14,23 @@ import { useQueryResult, useScrollToTopOnPageChange, } from "./util"; -import { - FilteredListToolbar, - IFilteredListToolbar, - IItemListOperation, -} from "./FilteredListToolbar"; -import { PagedList } from "./PagedList"; import { useConfigurationContext } from "src/hooks/Config"; -import { useZoomKeybinds } from "./ZoomSlider"; -import { DisplayMode } from "src/models/list-filter/types"; -interface IFilteredItemList { +interface IFilteredItemList< + T extends QueryResult, + E extends IHasID = IHasID, + M = unknown +> { filterStateProps: IFilterStateHook; - queryResultProps: IQueryResultHook; + queryResultProps: IQueryResultHook; } // Provides the common state and behaviour for filtered item list components export function useFilteredItemList< T extends QueryResult, - E extends IHasID = IHasID ->(props: IFilteredItemList) { + E extends IHasID = IHasID, + M = unknown +>(props: IFilteredItemList) { const { configuration: config } = useConfigurationContext(); // States @@ -70,7 +45,7 @@ export function useFilteredItemList< filter, ...props.queryResultProps, }); - const { result, items, totalCount, pages } = queryResult; + const { result, items, totalCount, pages, metadataInfo } = queryResult; const listSelect = useListSelect(items); const { onSelectAll, onSelectNone, onInvertSelection } = listSelect; @@ -107,352 +82,13 @@ export function useFilteredItemList< return { filterState, queryResult, + metadataInfo, listSelect, modalState, showEditFilter, }; } -interface IItemListProps { - view?: View; - otherOperations?: IItemListOperation[]; - renderContent: ( - result: T, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void, - onChangePage: (page: number) => void, - pageCount: number - ) => React.ReactNode; - renderMetadataByline?: (data: T, metadataInfo?: M) => React.ReactNode; - renderEditDialog?: ( - selected: E[], - onClose: (applied: boolean) => void - ) => React.ReactNode; - renderDeleteDialog?: ( - selected: E[], - onClose: (confirmed: boolean) => void - ) => React.ReactNode; - addKeybinds?: ( - result: T, - filter: ListFilterModel, - selectedIds: Set - ) => () => void; - renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode; -} - -export const ItemList = ( - props: IItemListProps -) => { - const { - view, - otherOperations, - renderContent, - renderEditDialog, - renderDeleteDialog, - renderMetadataByline, - addKeybinds, - renderToolbar: providedToolbar, - } = props; - - const { filter, setFilter: updateFilter } = useFilter(); - const { effectiveFilter, result, metadataInfo, cachedResult, totalCount } = - useQueryResultContext(); - const listSelect = useListContext(); - const { - selectedIds, - getSelected, - onSelectChange, - onSelectAll, - onSelectNone, - onInvertSelection, - } = listSelect; - - // scroll to the top of the page when the page changes - useScrollToTopOnPageChange(filter.currentPage, result.loading); - - const { modal, showModal, closeModal } = useModal(); - - const metadataByline = useMemo(() => { - if (cachedResult.loading) return ""; - - return renderMetadataByline?.(cachedResult, metadataInfo) ?? ""; - }, [renderMetadataByline, cachedResult, metadataInfo]); - - const pages = Math.ceil(totalCount / filter.itemsPerPage); - - const onChangePage = useCallback( - (p: number) => { - updateFilter(filter.changePage(p)); - }, - [filter, updateFilter] - ); - - useEnsureValidPage(filter, totalCount, updateFilter); - - const showEditFilter = useCallback( - (editingCriterion?: string) => { - function onApplyEditFilter(f: ListFilterModel) { - closeModal(); - updateFilter(f); - } - - showModal( - closeModal()} - editingCriterion={editingCriterion} - /> - ); - }, - [filter, updateFilter, showModal, closeModal] - ); - - useListKeyboardShortcuts({ - currentPage: filter.currentPage, - onChangePage, - onSelectAll, - onSelectNone, - onInvertSelection, - pages, - showEditFilter, - }); - - const zoomable = - filter.displayMode === DisplayMode.Grid || - filter.displayMode === DisplayMode.Wall; - - useZoomKeybinds({ - zoomIndex: zoomable ? filter.zoomIndex : undefined, - onChangeZoom: (zoom) => updateFilter(filter.setZoom(zoom)), - }); - - useEffect(() => { - if (addKeybinds) { - const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds); - return () => { - unbindExtras(); - }; - } - }, [addKeybinds, result, effectiveFilter, selectedIds]); - - const operations = useMemo(() => { - async function onOperationClicked(o: IItemListOperation) { - await o.onClick(result, effectiveFilter, selectedIds); - if (o.postRefetch) { - result.refetch(); - } - } - - return otherOperations?.map((o) => ({ - text: o.text, - onClick: () => { - onOperationClicked(o); - }, - isDisplayed: () => { - if (o.isDisplayed) { - return o.isDisplayed(result, effectiveFilter, selectedIds); - } - - return true; - }, - icon: o.icon, - buttonVariant: o.buttonVariant, - })); - }, [result, effectiveFilter, selectedIds, otherOperations]); - - function onEdit() { - if (!renderEditDialog) { - return; - } - - showModal( - renderEditDialog(getSelected(), (applied) => onEditDialogClosed(applied)) - ); - } - - function onEditDialogClosed(applied: boolean) { - if (applied) { - onSelectNone(); - } - closeModal(); - - // refetch - result.refetch(); - } - - function onDelete() { - if (!renderDeleteDialog) { - return; - } - - showModal( - renderDeleteDialog(getSelected(), (deleted) => - onDeleteDialogClosed(deleted) - ) - ); - } - - function onDeleteDialogClosed(deleted: boolean) { - if (deleted) { - onSelectNone(); - } - closeModal(); - - // refetch - result.refetch(); - } - - function onRemoveCriterion(removedCriterion: Criterion, valueIndex?: number) { - if (valueIndex === undefined) { - updateFilter( - filter.removeCriterion(removedCriterion.criterionOption.type) - ); - } else { - updateFilter( - filter.removeCustomFieldCriterion( - removedCriterion.criterionOption.type, - valueIndex - ) - ); - } - } - - function onClearAllCriteria() { - updateFilter(filter.clearCriteria()); - } - - const filterListToolbarProps: IFilteredListToolbar = { - filter, - setFilter: updateFilter, - listSelect, - showEditFilter, - view: view, - operations: operations, - zoomable: zoomable, - onEdit: renderEditDialog ? onEdit : undefined, - onDelete: renderDeleteDialog ? onDelete : undefined, - }; - - return ( -
        - {providedToolbar ? ( - providedToolbar(filterListToolbarProps) - ) : ( - - )} - showEditFilter(c.criterionOption.type)} - onRemoveCriterion={onRemoveCriterion} - onRemoveAll={() => onClearAllCriteria()} - /> - {modal} - - - {renderContent( - result, - // #4780 - use effectiveFilter to ensure filterHook is applied - effectiveFilter, - selectedIds, - onSelectChange, - onChangePage, - pages - )} - -
        - ); -}; - -interface IItemListContextProps< - T extends QueryResult, - E extends IHasID, - M = unknown -> { - filterMode: GQL.FilterMode; - defaultSort?: string; - defaultFilter?: ListFilterModel; - useResult: (filter: ListFilterModel) => T; - useMetadataInfo?: (filter: ListFilterModel) => M; - getCount: (data: T) => number; - getItems: (data: T) => E[]; - filterHook?: (filter: ListFilterModel) => ListFilterModel; - view?: View; - alterQuery?: boolean; - selectable?: boolean; -} - -// Provides the contexts for the ItemList component. Includes functionality to scroll -// to top on page change. -export const ItemListContext = < - T extends QueryResult, - E extends IHasID, - M = unknown ->( - props: PropsWithChildren> -) => { - const { - filterMode, - defaultSort, - defaultFilter: providedDefaultFilter, - useResult, - useMetadataInfo, - getCount, - getItems, - view, - filterHook, - alterQuery = true, - selectable, - children, - } = props; - - const { configuration: config } = useConfigurationContext(); - - const emptyFilter = useMemo( - () => - providedDefaultFilter?.clone() ?? - new ListFilterModel(filterMode, config, { - defaultSortBy: defaultSort, - }), - [config, filterMode, defaultSort, providedDefaultFilter] - ); - - const [filter, setFilterState] = useState( - () => - new ListFilterModel(filterMode, config, { defaultSortBy: defaultSort }) - ); - - const { defaultFilter } = useDefaultFilter(emptyFilter, view); - - return ( - - - - {({ items }) => ( - - {children} - - )} - - - - ); -}; - export const showWhenSelected = ( result: T, filter: ListFilterModel, diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index e7a4caf02..7ae8f23cf 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -234,14 +234,14 @@ input[type="range"].zoom-slider { .saved-filter-item { cursor: pointer; - height: 2em; margin-bottom: 0.25rem; + min-height: 2em; a { align-items: center; display: flex; - height: 2em; justify-content: space-between; + min-height: 2em; outline: none; &:hover, @@ -470,6 +470,10 @@ input[type="range"].zoom-slider { line-height: 16px; padding: 0; } + + .tag-item.unsupported { + background-color: $warning; + } } .filter-button { @@ -503,14 +507,15 @@ input[type="range"].zoom-slider { } } -.selectable-filter ul { +.selectable-filter ul, +ul.selectable-list { list-style-type: none; margin-top: 0.5rem; max-height: 300px; overflow-y: auto; // to prevent unnecessary vertical scrollbar - padding-bottom: 0.15rem; padding-inline-start: 0; + padding-bottom: 0.15rem; .modifier-object { font-style: italic; @@ -529,14 +534,14 @@ input[type="range"].zoom-slider { .excluded-object, .unselected-object { cursor: pointer; - height: 2em; margin-bottom: 0.25rem; + min-height: 2em; a { align-items: center; display: flex; - height: 2em; justify-content: space-between; + min-height: 2em; outline: none; &:hover, @@ -609,14 +614,15 @@ input[type="range"].zoom-slider { margin-bottom: 0.5rem; } -.sidebar-list-filter ul { +.sidebar-list-filter ul, +.folder-filter ul { list-style-type: none; margin-bottom: 0.25rem; max-height: 300px; overflow-y: auto; // to prevent unnecessary vertical scrollbar - padding-bottom: 0.15rem; padding-inline-start: 0; + padding-bottom: 0.15rem; .modifier-object { font-style: italic; @@ -635,14 +641,14 @@ input[type="range"].zoom-slider { .excluded-object, .unselected-object { cursor: pointer; - height: 2em; margin-bottom: 0.25rem; + min-height: 2em; a { align-items: center; display: flex; - height: 2em; justify-content: space-between; + min-height: 2em; outline: none; &:hover, @@ -683,7 +689,7 @@ input[type="range"].zoom-slider { } &:hover { - background-color: inherit; + background-color: transparent; } &:hover .exclude-button-text, @@ -744,6 +750,29 @@ input[type="range"].zoom-slider { } } +.sidebar-folder-filter ul, +.folder-filter ul, +ul.selectable-list { + margin-top: 0.25rem; + + .btn.expand-collapse { + font-size: 0.8rem; + padding-left: 0; + padding-right: 0.25rem; + text-align: left; + } + + .empty .btn.expand-collapse { + visibility: hidden; + } + + .selected-object a .selected-object-label { + font-size: 0.8em; + overflow-wrap: break-word; + white-space: normal; + } +} + .tilted { transform: rotate(45deg); } diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index d870c631f..da52ea765 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Mousetrap from "mousetrap"; import { ListFilterModel } from "src/models/list-filter/filter"; import { useHistory, useLocation } from "react-router-dom"; @@ -489,43 +489,47 @@ export function useCachedQueryResult( result: T ) { const [cachedResult, setCachedResult] = useState(result); - const [lastFilter, setLastFilter] = useState(filter); + const lastFilterRef = useRef(filter); // if we are only changing the page or sort, don't update the result count useEffect(() => { if (!result.loading) { setCachedResult(result); } else { - if (totalCountImpacted(lastFilter, filter)) { + if (totalCountImpacted(lastFilterRef.current, filter)) { setCachedResult(result); } } - setLastFilter(filter); - }, [filter, result, lastFilter]); + lastFilterRef.current = filter; + }, [filter, result]); return cachedResult; } export interface IQueryResultHook< T extends QueryResult, - E extends IHasID = IHasID + E extends IHasID = IHasID, + M = unknown > { filterHook?: (filter: ListFilterModel) => ListFilterModel; useResult: (filter: ListFilterModel) => T; + useMetadataInfo?: (filter: ListFilterModel) => M; getCount: (data: T) => number; getItems: (data: T) => E[]; } export function useQueryResult< T extends QueryResult, - E extends IHasID = IHasID + E extends IHasID = IHasID, + M = unknown >( - props: IQueryResultHook & { + props: IQueryResultHook & { filter: ListFilterModel; } ) { - const { filter, filterHook, useResult, getItems, getCount } = props; + const { filter, filterHook, useResult, useMetadataInfo, getItems, getCount } = + props; const effectiveFilter = useMemo(() => { if (filterHook) { @@ -534,7 +538,14 @@ export function useQueryResult< return filter; }, [filter, filterHook]); + // metadata filter is the effective filter with the sort, page size and page number removed + const metadataFilter = useMemo( + () => effectiveFilter.metadataInfo(), + [effectiveFilter] + ); + const result = useResult(effectiveFilter); + const metadataInfo = useMetadataInfo?.(metadataFilter); // use cached query result for pagination and metadata rendering const cachedResult = useCachedQueryResult(effectiveFilter, result); @@ -549,6 +560,7 @@ export function useQueryResult< return { effectiveFilter, + metadataInfo, result, cachedResult, items, diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index d60118d4b..06ae2834a 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; -import { Col, Form, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkPerformerUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "../Shared/Modal"; @@ -23,12 +23,13 @@ import { stringToCircumcised, } from "src/utils/circumcised"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; -import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; -import * as FormUtils from "src/utils/form"; import { CountrySelect } from "../Shared/CountrySelect"; import { useConfigurationContext } from "src/hooks/Config"; import cx from "classnames"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimPerformerDataFragment[]; @@ -75,17 +76,42 @@ export const EditPerformersDialog: React.FC = ( const [aggregateState, setAggregateState] = useState({}); // height and weight needs conversion to/from number - const [height, setHeight] = useState(); - const [weight, setWeight] = useState(); - const [penis_length, setPenisLength] = useState(); + const [height, setHeight] = useState(); + const [weight, setWeight] = useState(); + const [penis_length, setPenisLength] = useState(); const [updateInput, setUpdateInput] = useState( {} ); const genderOptions = [""].concat(genderStrings); const circumcisedOptions = [""].concat(circumcisedStrings); + const unsetDisabled = props.selected.length < 2; + const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput()); + const [birthdateError, setBirthdateError] = useState(); + const [deathDateError, setDeathDateError] = useState(); + const [careerStartError, setCareerStartError] = useState< + string | undefined + >(); + const [careerEndError, setCareerEndError] = useState(); + + useEffect(() => { + setBirthdateError(getDateError(updateInput.birthdate ?? "", intl)); + }, [updateInput.birthdate, intl]); + + useEffect(() => { + setDeathDateError(getDateError(updateInput.death_date ?? "", intl)); + }, [updateInput.death_date, intl]); + + useEffect(() => { + setCareerStartError(getDateError(updateInput.career_start ?? "", intl)); + }, [updateInput.career_start, intl]); + + useEffect(() => { + setCareerEndError(getDateError(updateInput.career_end ?? "", intl)); + }, [updateInput.career_end, intl]); + // Network state const [isUpdating, setIsUpdating] = useState(false); @@ -121,14 +147,14 @@ export const EditPerformersDialog: React.FC = ( ); if (height !== undefined) { - performerInput.height_cm = parseFloat(height); + performerInput.height_cm = height === null ? null : parseFloat(height); } if (weight !== undefined) { - performerInput.weight = parseFloat(weight); + performerInput.weight = weight === null ? null : parseFloat(weight); } - if (penis_length !== undefined) { - performerInput.penis_length = parseFloat(penis_length); + performerInput.penis_length = + penis_length === null ? null : parseFloat(penis_length); } return performerInput; @@ -205,25 +231,6 @@ export const EditPerformersDialog: React.FC = ( setUpdateInput(updateState); }, [props.selected]); - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - /> - - ); - } - function render() { // sfw class needs to be set because it is outside body @@ -235,13 +242,18 @@ export const EditPerformersDialog: React.FC = ( show icon={faPencilAlt} header={intl.formatMessage( - { id: "actions.edit_entity" }, - { entityType: intl.formatMessage({ id: "performers" }) } + { id: "dialogs.edit_entity_count_title" }, + { + count: props?.selected?.length ?? 1, + singularEntity: intl.formatMessage({ id: "performer" }), + pluralEntity: intl.formatMessage({ id: "performers" }), + } )} accept={{ onClick: onSave, text: intl.formatMessage({ id: "actions.apply" }), }} + disabled={isUpdating || !!birthdateError || !!deathDateError} cancel={{ onClick: () => props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -249,11 +261,8 @@ export const EditPerformersDialog: React.FC = ( }} isRunning={isUpdating} > - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} -
    + + @@ -261,9 +270,8 @@ export const EditPerformersDialog: React.FC = ( } disabled={isUpdating} /> - - - + + setUpdateField({ favorite: checked })} @@ -272,10 +280,7 @@ export const EditPerformersDialog: React.FC = ( /> - - - - + = ( ))} - + - {renderTextField("disambiguation", updateInput.disambiguation, (v) => - setUpdateField({ disambiguation: v }) - )} - {renderTextField("birthdate", updateInput.birthdate, (v) => - setUpdateField({ birthdate: v }) - )} - {renderTextField("death_date", updateInput.death_date, (v) => - setUpdateField({ death_date: v }) - )} + + + setUpdateField({ disambiguation: newValue }) + } + unsetDisabled={unsetDisabled} + /> + - - - - + + + setUpdateField({ birthdate: newValue }) + } + unsetDisabled={unsetDisabled} + error={birthdateError} + /> + + + + setUpdateField({ death_date: newValue }) + } + unsetDisabled={unsetDisabled} + error={deathDateError} + /> + + setUpdateField({ country: v })} showFlag /> - + - {renderTextField("ethnicity", updateInput.ethnicity, (v) => - setUpdateField({ ethnicity: v }) - )} - {renderTextField("hair_color", updateInput.hair_color, (v) => - setUpdateField({ hair_color: v }) - )} - {renderTextField("eye_color", updateInput.eye_color, (v) => - setUpdateField({ eye_color: v }) - )} - {renderTextField("height", height, (v) => setHeight(v))} - {renderTextField("weight", weight, (v) => setWeight(v))} - {renderTextField("measurements", updateInput.measurements, (v) => - setUpdateField({ measurements: v }) - )} - {renderTextField("penis_length", penis_length, (v) => - setPenisLength(v) - )} + + + setUpdateField({ ethnicity: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ hair_color: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ eye_color: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + setHeight(newValue)} + unsetDisabled={unsetDisabled} + /> + + + setWeight(newValue)} + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ measurements: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + setPenisLength(newValue)} + unsetDisabled={unsetDisabled} + /> + - - - - + = ( ))} - + - {renderTextField("fake_tits", updateInput.fake_tits, (v) => - setUpdateField({ fake_tits: v }) - )} - {renderTextField("tattoos", updateInput.tattoos, (v) => - setUpdateField({ tattoos: v }) - )} - {renderTextField("piercings", updateInput.piercings, (v) => - setUpdateField({ piercings: v }) - )} - {renderTextField( - "career_start", - updateInput.career_start?.toString(), - (v) => setUpdateField({ career_start: v ? parseInt(v) : undefined }) - )} - {renderTextField( - "career_end", - updateInput.career_end?.toString(), - (v) => setUpdateField({ career_end: v ? parseInt(v) : undefined }) - )} + + + setUpdateField({ fake_tits: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + setUpdateField({ tattoos: newValue })} + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ piercings: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + setUpdateField({ career_start: newValue }) + } + unsetDisabled={unsetDisabled} + error={careerStartError} + /> + + + + setUpdateField({ career_end: newValue }) + } + unsetDisabled={unsetDisabled} + error={careerEndError} + /> + - - - - + setTagIds({ ...tagIds, ids: itemIDs })} - onSetMode={(newMode) => setTagIds({ ...tagIds, mode: newMode })} - existingIds={existingTagIds ?? []} + onUpdate={(itemIDs) => { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} ids={tagIds.ids ?? []} + existingIds={existingTagIds} mode={tagIds.mode} menuPortalTarget={document.body} /> - + void; } -function customFieldInput(isNew: boolean, input: {}) { - if (isNew) { - return input; - } else { - return { - full: input, - }; - } -} - export const PerformerEditPanel: React.FC = ({ performer, isVisible, @@ -123,11 +116,11 @@ export const PerformerEditPanel: React.FC = ({ measurements: yup.string().ensure(), fake_tits: yup.string().ensure(), penis_length: yupInputNumber().positive().nullable().defined(), - circumcised: yupInputEnum(GQL.CircumisedEnum).nullable().defined(), + circumcised: yupInputEnum(GQL.CircumcisedEnum).nullable().defined(), tattoos: yup.string().ensure(), piercings: yup.string().ensure(), - career_start: yupInputNumber().positive().nullable().defined(), - career_end: yupInputNumber().positive().nullable().defined(), + career_start: yupDateString(intl), + career_end: yupDateString(intl), urls: yupUniqueStringList(intl), details: yup.string().ensure(), tag_ids: yup.array(yup.string().required()).defined(), @@ -156,8 +149,8 @@ export const PerformerEditPanel: React.FC = ({ circumcised: performer.circumcised ?? null, tattoos: performer.tattoos ?? "", piercings: performer.piercings ?? "", - career_start: performer.career_start ?? null, - career_end: performer.career_end ?? null, + career_start: performer.career_start ?? "", + career_end: performer.career_end ?? "", urls: performer.urls ?? [], details: performer.details ?? "", tag_ids: (performer.tags ?? []).map((t) => t.id), @@ -173,7 +166,7 @@ export const PerformerEditPanel: React.FC = ({ function submit(values: InputValues) { const input = { ...schema.cast(values), - custom_fields: customFieldInput(isNew, values.custom_fields), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input); } @@ -368,7 +361,7 @@ export const PerformerEditPanel: React.FC = ({ const { values } = formik; const input = { ...schema.cast(values), - custom_fields: customFieldInput(isNew, values.custom_fields), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; onSave(input, true); } @@ -752,8 +745,8 @@ export const PerformerEditPanel: React.FC = ({ {renderInputField("tattoos", "textarea")} {renderInputField("piercings", "textarea")} - {renderInputField("career_start", "number")} - {renderInputField("career_end", "number")} + {renderDateField("career_start")} + {renderDateField("career_end")} {renderURLListField("urls", onScrapePerformerURL, urlScrapable)} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx index 7b088e5be..bd1484a17 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { ImageList } from "src/components/Images/ImageList"; +import { FilteredImageList } from "src/components/Images/ImageList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; import { PatchComponent } from "src/patch"; @@ -14,7 +14,7 @@ export const PerformerImagesPanel: React.FC = PatchComponent("PerformerImagesPanel", ({ active, performer }) => { const filterHook = usePerformerFilterHook(performer); return ( - = ( return; } - let retEnum: GQL.CircumisedEnum | undefined; + let retEnum: GQL.CircumcisedEnum | undefined; // try to translate from enum values first const upperCircumcised = scrapedCircumcised.toUpperCase(); @@ -273,14 +272,14 @@ export const PerformerScrapeDialog: React.FC = ( const [fakeTits, setFakeTits] = useState>( new ScrapeResult(props.performer.fake_tits, props.scraped.fake_tits) ); - const [careerStart, setCareerStart] = useState>( - new ScrapeResult( + const [careerStart, setCareerStart] = useState>( + new ScrapeResult( props.performer.career_start, props.scraped.career_start ) ); - const [careerEnd, setCareerEnd] = useState>( - new ScrapeResult( + const [careerEnd, setCareerEnd] = useState>( + new ScrapeResult( props.performer.career_end, props.scraped.career_end ) @@ -502,13 +501,13 @@ export const PerformerScrapeDialog: React.FC = ( result={fakeTits} onChange={(value) => setFakeTits(value)} /> - setCareerStart(value)} /> - { }; export function formatYearRange( - start?: number | null, - end?: number | null + start?: string | null, + end?: string | null ): string | undefined { if (!start && !end) return undefined; + return `${start ?? ""} - ${end ?? ""}`; } -export const FormatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => { +export const FormatCircumcised = (circumcised?: GQL.CircumcisedEnum | null) => { const intl = useIntl(); if (!circumcised) { return ""; @@ -207,7 +208,7 @@ const PerformerList: React.FC<{ }> = PatchComponent( "PerformerList", ({ performers, filter, selectedIds, onSelectChange, extraCriteria }) => { - if (performers.length === 0) { + if (performers.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } @@ -423,7 +424,7 @@ export const FilteredPerformerList = PatchComponent( setFilter, }); - useAddKeybinds(filter, totalCount); + useAddKeybinds(effectiveFilter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, @@ -454,7 +455,7 @@ export const FilteredPerformerList = PatchComponent( result, }); - const viewRandom = useViewRandom(filter, totalCount); + const viewRandom = useViewRandom(effectiveFilter, totalCount); function onExport(all: boolean) { showModal( diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 3b500cee6..c155d1298 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -334,19 +334,19 @@ export const PerformerListTable: React.FC = ( }, { value: "scene_count", - label: intl.formatMessage({ id: "scene_count" }), + label: intl.formatMessage({ id: "scenes" }), defaultShow: true, render: SceneCountCell, }, { value: "gallery_count", - label: intl.formatMessage({ id: "gallery_count" }), + label: intl.formatMessage({ id: "galleries" }), defaultShow: true, render: GalleryCountCell, }, { value: "image_count", - label: intl.formatMessage({ id: "image_count" }), + label: intl.formatMessage({ id: "images" }), defaultShow: true, render: ImageCountCell, }, diff --git a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx index efa51f1db..e87256845 100644 --- a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx @@ -20,13 +20,14 @@ import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog"; import { ScrapedCustomFieldRows, + ScrapeDialogRow, ScrapedImageRow, ScrapedInputGroupRow, ScrapedStringListRow, ScrapedTextAreaRow, } from "../Shared/ScrapeDialog/ScrapeDialogRow"; import { ModalComponent } from "../Shared/Modal"; -import { sortStoredIdObjects } from "src/utils/data"; +import { sortStoredIdObjects, uniqIDStoredIDs } from "src/utils/data"; import { CustomFieldScrapeResults, ObjectListScrapeResult, @@ -40,6 +41,7 @@ import { } from "./PerformerDetails/PerformerScrapeDialog"; import { PerformerSelect } from "./PerformerSelect"; import { uniq } from "lodash-es"; +import { StashIDsField } from "../Shared/StashID"; type MergeOptions = { values: GQL.PerformerUpdateInput; @@ -132,6 +134,8 @@ const PerformerMergeDetails: React.FC = ({ ) ); + const [stashIDs, setStashIDs] = useState(new ScrapeResult([])); + const [image, setImage] = useState>( new ScrapeResult(dest.image_path) ); @@ -166,6 +170,10 @@ const PerformerMergeDetails: React.FC = ({ setLoading(false); } + // append dest to all so that if dest has stash_ids with the same + // endpoint, then it will be excluded first + const all = sources.concat(dest); + setName( new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name) ); @@ -297,9 +305,8 @@ const PerformerMergeDetails: React.FC = ({ ); setURLs( new ScrapeResult( - dest.urls, - sources.find((s) => s.urls)?.urls, - !dest.urls?.length + dest.urls ?? [], + uniq(all.map((s) => s.urls ?? []).flat()) ) ); setGender( @@ -327,6 +334,25 @@ const PerformerMergeDetails: React.FC = ({ !dest.details ) ); + setTags( + new ObjectListScrapeResult( + sortStoredIdObjects(dest.tags.map(idToStoredID)), + uniqIDStoredIDs(all.map((s) => s.tags.map(idToStoredID)).flat()) + ) + ); + setStashIDs( + new ScrapeResult( + dest.stash_ids, + all + .map((s) => s.stash_ids) + .flat() + .filter((s, index, a) => { + // remove entries with duplicate endpoints + return index === a.findIndex((ss) => ss.endpoint === s.endpoint); + }) + ) + ); + setImage( new ScrapeResult( dest.image_path, @@ -583,6 +609,27 @@ const PerformerMergeDetails: React.FC = ({ result={details} onChange={(value) => setDetails(value)} /> + + } + newField={ + + } + onChange={(value) => setStashIDs(value)} + alwaysShow={ + !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length + } + /> = ({ : undefined, measurements: measurements.getNewValue(), fake_tits: fakeTits.getNewValue(), - career_start: careerStart.getNewValue() - ? parseInt(careerStart.getNewValue()!) - : undefined, - career_end: careerEnd.getNewValue() - ? parseInt(careerEnd.getNewValue()!) - : undefined, + career_start: careerStart.getNewValue(), + career_end: careerEnd.getNewValue(), tattoos: tattoos.getNewValue(), piercings: piercings.getNewValue(), urls: urls.getNewValue(), @@ -643,6 +686,7 @@ const PerformerMergeDetails: React.FC = ({ circumcised: stringToCircumcised(circumcised.getNewValue()), tag_ids: tags.getNewValue()?.map((t) => t.stored_id!), details: details.getNewValue(), + stash_ids: stashIDs.getNewValue(), image: coverImage, custom_fields: { partial: Object.fromEntries( @@ -664,7 +708,7 @@ const PerformerMergeDetails: React.FC = ({ : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" - : intl.formatMessage({ id: "dialogs.merge.source" }); + : intl.formatMessage({ id: "dialogs.merge.combined" }); return ( = ({ id: "actions.merge", }); + const srcIDs = useMemo( + () => sourcePerformers.map((s) => s.id), + [sourcePerformers] + ); + const destID = useMemo( + () => (destPerformer[0] ? [destPerformer[0].id] : []), + [destPerformer] + ); + useEffect(() => { if (performers.length > 0) { // set the first performer as the destination, others as source @@ -826,6 +879,7 @@ export const PerformerMergeModal: React.FC = ({ onSelect={(items) => setSourcePerformers(items)} values={sourcePerformers} menuPortalTarget={document.body} + excludeIds={destID} /> @@ -859,6 +913,7 @@ export const PerformerMergeModal: React.FC = ({ onSelect={(items) => setDestPerformer(items)} values={destPerformer} menuPortalTarget={document.body} + excludeIds={srcIDs} /> diff --git a/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx b/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx index 13bba1e99..e07c44947 100644 --- a/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx +++ b/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx @@ -1,13 +1,9 @@ import React from "react"; -import { Link } from "react-router-dom"; import { useFindPerformers } from "src/core/StashService"; -import Slider from "@ant-design/react-slick"; import { PerformerCard } from "./PerformerCard"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { getSlickSliderSettings } from "src/core/recommendations"; -import { RecommendationRow } from "../FrontPage/RecommendationRow"; -import { FormattedMessage } from "react-intl"; import { PatchComponent } from "src/patch"; +import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; @@ -19,40 +15,29 @@ export const PerformerRecommendationRow: React.FC = PatchComponent( "PerformerRecommendationRow", (props) => { const result = useFindPerformers(props.filter); - const cardCount = result.data?.findPerformers.count; - - if (!result.loading && !cardCount) { - return null; - } + const count = result.data?.findPerformers.count ?? 0; return ( - - - - } + heading={props.header} + url={`/performers?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
    - )) - : result.data?.findPerformers.performers.map((p) => ( - - ))} -
    -
    + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
    + )) + : result.data?.findPerformers.performers.map((p) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx index f10519897..fbb6fe785 100644 --- a/ui/v2.5/src/components/Performers/PerformerSelect.tsx +++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { OptionProps, components as reactSelectComponents, @@ -23,6 +23,7 @@ import { IFilterProps, IFilterValueProps, Option as SelectOption, + toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Link } from "react-router-dom"; @@ -32,6 +33,8 @@ import { TruncatedText } from "../Shared/TruncatedText"; import TextUtils from "src/utils/text"; import { PerformerPopover } from "./PerformerPopover"; import { Placement } from "react-bootstrap/esm/Overlay"; +import { isUUID } from "src/utils/stashIds"; +import { filterByStashID } from "src/models/list-filter/utils"; export type SelectObject = { id: string; @@ -78,6 +81,7 @@ const _PerformerSelect: React.FC< ageFromDate?: string | null; hoverPlacementLabel?: Placement; hoverPlacementOptions?: Placement; + excludeIds?: string[]; } > = (props) => { const [createPerformer] = usePerformerCreate(); @@ -89,21 +93,43 @@ const _PerformerSelect: React.FC< const defaultCreatable = !configuration?.interface.disableDropdownCreate.performer; + const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + + function filterExcluded(performer: Performer) { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(performer.id.toString()); + } + async function loadPerformers(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Performers); - filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; + + // If the input looks like a GUID, search for stash_id first and return match immediately + if (isUUID(input)) { + filterByStashID(filter, input); + + const query = await queryFindPerformersForSelect(filter); + const matches = + query.data.findPerformers.performers.filter(filterExcluded); + if (matches.length > 0) { + // Matches found, return them immediately. + return matches.map(toOption); + } + // If no stash_id matches found, continue with standard name/alias search. + filter.criteria = []; // Clear stash_id criterion to search by name/alias below. + } + + filter.searchTerm = input; + const query = await queryFindPerformersForSelect(filter); return performerSelectSort( input, - query.data.findPerformers.performers.slice() - ).map((performer) => ({ - value: performer.id, - object: performer, - })); + query.data.findPerformers.performers.filter(filterExcluded) + ).map(toOption); } const PerformerOption: React.FC> = ( diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 54a010e50..49dc27550 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -82,11 +82,6 @@ font-weight: 700; padding-left: 0; } - - .custom-fields .detail-item-title, - .custom-fields .detail-item-value { - font-family: "Courier New", Courier, monospace; - } /* stylelint-enable selector-class-pattern */ } diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx index 93e45a7e7..8fb3fed67 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx @@ -28,6 +28,10 @@ interface ISceneSpriteItem { time: string; } +const scrubberViewportHeight = 120; +const scrubberTagsHeight = 30; +const scrubberSpriteHeight = scrubberViewportHeight - scrubberTagsHeight; + export const ScenePlayerScrubber: React.FC = ({ file, scene, @@ -86,16 +90,36 @@ export const ScenePlayerScrubber: React.FC = ({ const [spriteItems, setSpriteItems] = useState(); useEffect(() => { - if (!spriteInfo) return; + if (!spriteInfo || spriteInfo.length === 0) return; let totalWidth = 0; + + // calculate total width/height of scrubber image so we can scale it + const maxX = Math.max(...spriteInfo.map((sprite) => sprite.x + sprite.w)); + const maxY = Math.max(...spriteInfo.map((sprite) => sprite.y + sprite.h)); + const spriteWidth = spriteInfo[0].w; + const spriteHeight = spriteInfo[0].h; + const scale = scrubberSpriteHeight / spriteHeight; + + const w = spriteWidth * scale; + const h = scrubberSpriteHeight; + + const sizeX = maxX * scale; + const sizeY = maxY * scale; + + // scale sprite dimensions to fit scrubber height, and calculate background position for each sprite const newSprites = spriteInfo?.map((sprite, index) => { - totalWidth += sprite.w; - const left = sprite.w * index; + totalWidth += w; + const left = w * index; + + const spriteX = sprite.x * scale; + const spriteY = sprite.y * scale; + const style = { - width: `${sprite.w}px`, - height: `${sprite.h}px`, - backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, + width: `${w}px`, + height: `${h}px`, + backgroundPosition: `${-spriteX}px ${-spriteY}px`, backgroundImage: `url(${sprite.url})`, + backgroundSize: `${sizeX}px ${sizeY}px`, left: `${left}px`, }; const start = TextUtils.secondsToTimestamp(sprite.start); @@ -325,9 +349,10 @@ export const ScenePlayerScrubber: React.FC = ({
    diff --git a/ui/v2.5/src/components/ScenePlayer/vrmode.ts b/ui/v2.5/src/components/ScenePlayer/vrmode.ts index cad442c4b..cc9b794c1 100644 --- a/ui/v2.5/src/components/ScenePlayer/vrmode.ts +++ b/ui/v2.5/src/components/ScenePlayer/vrmode.ts @@ -1,9 +1,13 @@ /* eslint-disable @typescript-eslint/naming-convention */ import videojs, { VideoJsPlayer } from "video.js"; -import "videojs-vr"; +// eslint-disable-next-line import/no-extraneous-dependencies +import "@blaineam/videojs-vr"; // separate type import, otherwise typescript elides the above import // and the plugin does not get initialized -import type { ProjectionType, Plugin as VideoJsVRPlugin } from "videojs-vr"; +import type { + ProjectionType, + Plugin as VideoJsVRPlugin, +} from "@blaineam/videojs-vr"; export interface VRMenuOptions { /** diff --git a/ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx b/ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx index bb1d8067b..1856543f9 100644 --- a/ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx +++ b/ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from "react"; import { Form } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; +import { useIntl } from "react-intl"; import { useBulkSceneMarkerUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "../Shared/Modal"; @@ -10,7 +10,7 @@ import { getAggregateState, getAggregateStateObject, } from "src/utils/bulkUpdate"; -import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { TagSelect } from "../Shared/Select"; @@ -38,6 +38,8 @@ export const EditSceneMarkersDialog: React.FC = ( mode: GQL.BulkUpdateIdMode.Add, }); + const unsetDisabled = props.selected.length < 2; + const [updateSceneMarkers] = useBulkSceneMarkerUpdate(); // Network state @@ -115,27 +117,6 @@ export const EditSceneMarkersDialog: React.FC = ( setIsUpdating(false); } - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void, - area: boolean = false - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - as={area ? "textarea" : undefined} - /> - - ); - } - function render() { return ( = ( show icon={faPencilAlt} header={intl.formatMessage( - { id: "actions.edit_entity" }, - { entityType: intl.formatMessage({ id: "markers" }) } + { id: "dialogs.edit_entity_count_title" }, + { + count: props?.selected?.length ?? 1, + singularEntity: intl.formatMessage({ id: "marker" }), + pluralEntity: intl.formatMessage({ id: "markers" }), + } )} accept={{ onClick: onSave, @@ -158,39 +143,39 @@ export const EditSceneMarkersDialog: React.FC = ( isRunning={isUpdating} > - {renderTextField("title", updateInput.title, (newValue) => - setUpdateField({ title: newValue }) - )} + + setUpdateField({ title: newValue })} + unsetDisabled={unsetDisabled} + /> + - - - - + setUpdateField({ primary_tag_id: t[0]?.id })} ids={ updateInput.primary_tag_id ? [updateInput.primary_tag_id] : [] } /> - + - - - - + setTagIds((v) => ({ ...v, ids: itemIDs }))} - onSetMode={(newMode) => - setTagIds((v) => ({ ...v, mode: newMode })) - } - existingIds={aggregateState.tagIds ?? []} + onUpdate={(itemIDs) => { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds ?? []} mode={tagIds.mode} menuPortalTarget={document.body} /> - + ); diff --git a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx index 7b69cf655..17466bfc9 100644 --- a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx @@ -1,93 +1,121 @@ -import React, { useEffect, useState } from "react"; -import { Form, Col, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; -import isEqual from "lodash-es/isEqual"; +import React, { useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkSceneUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { StudioSelect } from "../Shared/Select"; import { ModalComponent } from "../Shared/Modal"; import { MultiSet } from "../Shared/MultiSet"; import { useToast } from "src/hooks/Toast"; -import * as FormUtils from "src/utils/form"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { - getAggregateInputIDs, getAggregateInputValue, getAggregateGroupIds, getAggregatePerformerIds, - getAggregateRating, - getAggregateStudioId, + getAggregateStateObject, getAggregateTagIds, + getAggregateStudioId, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; +import { BulkUpdateDateInput } from "../Shared/DateInput"; +import { getDateError } from "src/utils/yup"; interface IListOperationProps { selected: GQL.SlimSceneDataFragment[]; onClose: (applied: boolean) => void; } +const sceneFields = [ + "code", + "rating100", + "details", + "organized", + "director", + "date", +]; + export const EditScenesDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); const Toast = useToast(); - const [rating100, setRating] = useState(); - const [studioId, setStudioId] = useState(); - const [performerMode, setPerformerMode] = - React.useState(GQL.BulkUpdateIdMode.Add); - const [performerIds, setPerformerIds] = useState(); - const [existingPerformerIds, setExistingPerformerIds] = useState(); - const [tagMode, setTagMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [tagIds, setTagIds] = useState(); - const [existingTagIds, setExistingTagIds] = useState(); - const [groupMode, setGroupMode] = React.useState( - GQL.BulkUpdateIdMode.Add - ); - const [groupIds, setGroupIds] = useState(); - const [existingGroupIds, setExistingGroupIds] = useState(); - const [organized, setOrganized] = useState(); - const [updateScenes] = useBulkSceneUpdate(getSceneInput()); + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((scene) => { + return scene.id; + }), + }); + + const [dateError, setDateError] = useState(); + + const [performerIds, setPerformerIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [groupIds, setGroupIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + + const unsetDisabled = props.selected.length < 2; + + const [updateScenes] = useBulkSceneUpdate(); // Network state const [isUpdating, setIsUpdating] = useState(false); - const checkboxRef = React.createRef(); + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + updateState.studio_id = getAggregateStudioId(props.selected); + const updateTagIds = getAggregateTagIds(props.selected); + const updatePerformerIds = getAggregatePerformerIds(props.selected); + const updateGroupIds = getAggregateGroupIds(props.selected); + let first = true; + + state.forEach((scene: GQL.SlimSceneDataFragment) => { + getAggregateStateObject(updateState, scene, sceneFields, first); + first = false; + }); + + return { + state: updateState, + tagIds: updateTagIds, + performerIds: updatePerformerIds, + groupIds: updateGroupIds, + }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + useEffect(() => { + setDateError(getDateError(updateInput.date ?? "", intl)); + }, [updateInput.date, intl]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } function getSceneInput(): GQL.BulkSceneUpdateInput { - // need to determine what we are actually setting on each scene - const aggregateRating = getAggregateRating(props.selected); - const aggregateStudioId = getAggregateStudioId(props.selected); - const aggregatePerformerIds = getAggregatePerformerIds(props.selected); - const aggregateTagIds = getAggregateTagIds(props.selected); - const aggregateGroupIds = getAggregateGroupIds(props.selected); - const sceneInput: GQL.BulkSceneUpdateInput = { - ids: props.selected.map((scene) => { - return scene.id; - }), + ...updateInput, + tag_ids: tagIds, + performer_ids: performerIds, + group_ids: groupIds, }; - sceneInput.rating100 = getAggregateInputValue(rating100, aggregateRating); - sceneInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); - - sceneInput.performer_ids = getAggregateInputIDs( - performerMode, - performerIds, - aggregatePerformerIds + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + sceneInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 ); - sceneInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); - sceneInput.group_ids = getAggregateInputIDs( - groupMode, - groupIds, - aggregateGroupIds - ); - - if (organized !== undefined) { - sceneInput.organized = organized; - } return sceneInput; } @@ -95,7 +123,7 @@ export const EditScenesDialog: React.FC = ( async function onSave() { setIsUpdating(true); try { - await updateScenes(); + await updateScenes({ variables: { input: getSceneInput() } }); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, @@ -109,145 +137,13 @@ export const EditScenesDialog: React.FC = ( setIsUpdating(false); } - useEffect(() => { - const state = props.selected; - let updateRating: number | undefined; - let updateStudioID: string | undefined; - let updatePerformerIds: string[] = []; - let updateTagIds: string[] = []; - let updateGroupIds: string[] = []; - let updateOrganized: boolean | undefined; - let first = true; - - state.forEach((scene: GQL.SlimSceneDataFragment) => { - const sceneRating = scene.rating100; - const sceneStudioID = scene?.studio?.id; - const scenePerformerIDs = (scene.performers ?? []) - .map((p) => p.id) - .sort(); - const sceneTagIDs = (scene.tags ?? []).map((p) => p.id).sort(); - const sceneGroupIDs = (scene.groups ?? []).map((m) => m.group.id).sort(); - - if (first) { - updateRating = sceneRating ?? undefined; - updateStudioID = sceneStudioID; - updatePerformerIds = scenePerformerIDs; - updateTagIds = sceneTagIDs; - updateGroupIds = sceneGroupIDs; - first = false; - updateOrganized = scene.organized; - } else { - if (sceneRating !== updateRating) { - updateRating = undefined; - } - if (sceneStudioID !== updateStudioID) { - updateStudioID = undefined; - } - if (!isEqual(scenePerformerIDs, updatePerformerIds)) { - updatePerformerIds = []; - } - if (!isEqual(sceneTagIDs, updateTagIds)) { - updateTagIds = []; - } - if (!isEqual(sceneGroupIDs, updateGroupIds)) { - updateGroupIds = []; - } - if (scene.organized !== updateOrganized) { - updateOrganized = undefined; - } - } - }); - - setRating(updateRating); - setStudioId(updateStudioID); - setExistingPerformerIds(updatePerformerIds); - setExistingTagIds(updateTagIds); - setExistingGroupIds(updateGroupIds); - setOrganized(updateOrganized); - }, [props.selected]); - - useEffect(() => { - if (checkboxRef.current) { - checkboxRef.current.indeterminate = organized === undefined; - } - }, [organized, checkboxRef]); - - function renderMultiSelect( - type: "performers" | "tags" | "groups", - ids: string[] | undefined - ) { - let mode = GQL.BulkUpdateIdMode.Add; - let existingIds: string[] | undefined = []; - switch (type) { - case "performers": - mode = performerMode; - existingIds = existingPerformerIds; - break; - case "tags": - mode = tagMode; - existingIds = existingTagIds; - break; - case "groups": - mode = groupMode; - existingIds = existingGroupIds; - break; - } - - return ( - { - switch (type) { - case "performers": - setPerformerIds(itemIDs); - break; - case "tags": - setTagIds(itemIDs); - break; - case "groups": - setGroupIds(itemIDs); - break; - } - }} - onSetMode={(newMode) => { - switch (type) { - case "performers": - setPerformerMode(newMode); - break; - case "tags": - setTagMode(newMode); - break; - case "groups": - setGroupMode(newMode); - break; - } - }} - ids={ids ?? []} - existingIds={existingIds ?? []} - mode={mode} - menuPortalTarget={document.body} - /> - ); - } - - function cycleOrganized() { - if (organized) { - setOrganized(undefined); - } else if (organized === undefined) { - setOrganized(false); - } else { - setOrganized(true); - } - } - function render() { return ( = ( onClick: onSave, text: intl.formatMessage({ id: "actions.apply" }), }} + disabled={isUpdating || !!dateError} cancel={{ onClick: () => props.onClose(false), text: intl.formatMessage({ id: "actions.cancel" }), @@ -266,62 +163,121 @@ export const EditScenesDialog: React.FC = ( isRunning={isUpdating} >
    - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} -
    - setRating(value ?? undefined)} - disabled={isUpdating} - /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "studio" }), - })} - - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - isDisabled={isUpdating} - menuPortalTarget={document.body} - /> - - + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + - - - - - {renderMultiSelect("performers", performerIds)} - + + setUpdateField({ code: newValue })} + unsetDisabled={unsetDisabled} + /> + - - - - - {renderMultiSelect("tags", tagIds)} - + + setUpdateField({ date: newValue })} + unsetDisabled={unsetDisabled} + error={dateError} + /> + - - - - - {renderMultiSelect("groups", groupIds)} - + + + setUpdateField({ director: newValue }) + } + unsetDisabled={unsetDisabled} + /> + + + + + setUpdateField({ + studio_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.studio_id ? [updateInput.studio_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + + { + setPerformerIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setPerformerIds((c) => ({ ...c, mode: newMode })); + }} + ids={performerIds.ids ?? []} + existingIds={aggregateState.performerIds} + mode={performerIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setGroupIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setGroupIds((c) => ({ ...c, mode: newMode })); + }} + ids={groupIds.ids ?? []} + existingIds={aggregateState.groupIds} + mode={groupIds.mode} + menuPortalTarget={document.body} + /> + + + + { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} + ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + - cycleOrganized()} + setChecked={(checked) => setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} /> diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index 8ecb6e557..e60c638d7 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -13,6 +13,7 @@ import { HoverScrubber } from "../Shared/HoverScrubber"; interface IScenePreviewProps { vttPath: string | undefined; onClick?: (timestamp: number) => void; + disabled?: boolean; } function scaleToFit(dimensions: { w: number; h: number }, bounds: DOMRect) { @@ -32,6 +33,7 @@ const defaultSprites = 81; // 9x9 grid by default export const PreviewScrubber: React.FC = ({ vttPath, onClick, + disabled, }) => { const imageParentRef = useRef(null); const [style, setStyle] = useState({}); @@ -44,6 +46,18 @@ export const PreviewScrubber: React.FC = ({ const [hasLoaded, setHasLoaded] = useState(false); const spriteInfo = useSpriteInfo(hasLoaded ? vttPath : undefined); + const spriteSheetSize = useMemo(() => { + if (!spriteInfo) { + return { x: 0, y: 0 }; + } + + // calculate total width/height of scrubber image so we can scale it + const maxX = Math.max(...spriteInfo.map((sprite) => sprite.x + sprite.w)); + const maxY = Math.max(...spriteInfo.map((sprite) => sprite.y + sprite.h)); + + return { x: maxX, y: maxY }; + }, [spriteInfo]); + const sprite = useMemo(() => { if (!spriteInfo || activeIndex === undefined) { return undefined; @@ -67,17 +81,17 @@ export const PreviewScrubber: React.FC = ({ const clientRect = imageParent.getBoundingClientRect(); const scale = scaleToFit(sprite, clientRect); - const spriteSheet = new Image(); - spriteSheet.src = sprite.url; setStyle({ - backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, + backgroundPosition: `${-sprite.x * scale}px ${-sprite.y * scale}px`, backgroundImage: `url(${sprite.url})`, - width: `${sprite.w}px`, - height: `${sprite.h}px`, - transform: `scale(${scale})`, + backgroundSize: `${spriteSheetSize.x * scale}px ${ + spriteSheetSize.y * scale + }px`, + width: `${sprite.w * scale}px`, + height: `${sprite.h * scale}px`, }); - }, [sprite]); + }, [sprite, spriteSheetSize]); const currentTime = useMemo(() => { if (!sprite) return undefined; @@ -113,6 +127,7 @@ export const PreviewScrubber: React.FC = ({ activeIndex={activeIndex} setActiveIndex={(i) => debounceSetActiveIndex(i)} onClick={onScrubberClick} + disabled={disabled} /> ); diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 2cb4a9af3..55124e9b0 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -30,14 +30,17 @@ import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; import { GroupTag } from "../Groups/GroupTag"; import { FileSize } from "../Shared/FileSize"; import { OCounterButton } from "../Shared/CountButton"; +import { defaultPreviewVolume } from "src/core/config"; interface IScenePreviewProps { isPortrait: boolean; image?: string; video?: string; soundActive: boolean; + volume?: number; vttPath?: string; onScrubberClick?: (timestamp: number) => void; + disabled?: boolean; } export const ScenePreview: React.FC = ({ @@ -47,6 +50,8 @@ export const ScenePreview: React.FC = ({ soundActive, vttPath, onScrubberClick, + disabled, + volume, }) => { const videoEl = useRef(null); @@ -65,8 +70,8 @@ export const ScenePreview: React.FC = ({ useEffect(() => { if (videoEl?.current?.volume) - videoEl.current.volume = soundActive ? 0.05 : 0; - }, [soundActive]); + videoEl.current.volume = soundActive ? (volume ?? 0) / 100 : 0; + }, [volume, soundActive]); return (
    @@ -86,7 +91,11 @@ export const ScenePreview: React.FC = ({ ref={videoEl} src={video} /> - +
    ); }; @@ -336,7 +345,46 @@ const SceneCardDetails = PatchComponent( const SceneCardOverlays = PatchComponent( "SceneCard.Overlays", (props: ISceneCardProps) => { - return ; + const ret = useMemo(() => { + return ( + + ); + }, [props.scene.studio, props.selecting]); + + return ret; + } +); + +interface ISceneSpecsOverlay { + scene: GQL.SlimSceneDataFragment; +} + +export const SceneSpecsOverlay: React.FC = PatchComponent( + "SceneCard.SceneSpecs", + ({ scene }) => { + const file = scene.files?.[0]; + if (!file) return null; + return ( +
    + + + + {file.width && file.height ? ( + + {TextUtils.resolution(file.width, file.height)} + + ) : ( + "" + )} + {file.duration > 0 ? ( + + {TextUtils.secondsToTimestamp(file.duration)} + + ) : ( + "" + )} +
    + ); } ); @@ -352,35 +400,6 @@ const SceneCardImage = PatchComponent( [props.scene] ); - function maybeRenderSceneSpecsOverlay() { - return ( -
    - {file?.size !== undefined ? ( - - - - ) : ( - "" - )} - {file?.width && file?.height ? ( - - {" "} - {TextUtils.resolution(file?.width, file?.height)} - - ) : ( - "" - )} - {(file?.duration ?? 0) >= 1 ? ( - - {TextUtils.secondsToTimestamp(file?.duration ?? 0)} - - ) : ( - "" - )} -
    - ); - } - function maybeRenderInteractiveSpeedOverlay() { return (
    @@ -390,6 +409,7 @@ const SceneCardImage = PatchComponent( } function onScrubberClick(timestamp: number) { + if (props.selecting) return; const link = props.queue ? props.queue.makeLink(props.scene.id, { sceneIndex: props.index, @@ -414,11 +434,13 @@ const SceneCardImage = PatchComponent( video={props.scene.paths.preview ?? undefined} isPortrait={isPortrait()} soundActive={configuration?.interface?.soundOnPreview ?? false} + volume={configuration?.ui.previewVolume ?? defaultPreviewVolume} vttPath={props.scene.paths.vtt ?? undefined} onScrubberClick={onScrubberClick} + disabled={props.selecting} /> - {maybeRenderSceneSpecsOverlay()} + {maybeRenderInteractiveSpeedOverlay()} ); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 435b9dce2..7d1b245fc 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -7,7 +7,7 @@ import React, { useLayoutEffect, } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useHistory, Link, RouteComponentProps } from "react-router-dom"; +import { useHistory, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; import { @@ -56,6 +56,7 @@ import { PatchComponent, PatchContainerComponent } from "src/patch"; import { SceneMergeModal } from "../SceneMergeDialog"; import { goBackOrReplace } from "src/utils/history"; import { FormattedDate } from "src/components/Shared/Date"; +import { StudioLogo } from "src/components/Shared/StudioLogo"; const SubmitStashBoxDraft = lazyComponent( () => import("src/components/Dialogs/SubmitDraft") @@ -190,6 +191,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { const [updateScene] = useSceneUpdate(); const [generateScreenshot] = useSceneGenerateScreenshot(); const { configuration } = useConfigurationContext(); + const { showStudioText } = configuration?.ui ?? {}; const [showDraftModal, setShowDraftModal] = useState(false); const boxes = configuration?.general?.stashBoxes ?? []; @@ -254,6 +256,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { Mousetrap.bind("p p", () => onQueuePrevious()); Mousetrap.bind("p r", () => onQueueRandom()); Mousetrap.bind(",", () => setCollapsed(!collapsed)); + Mousetrap.bind("d d", () => setIsDeleteAlertOpen(true)); Mousetrap.bind("c c", () => { onGenerateScreenshot(getPlayerPosition()); }); @@ -269,6 +272,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { Mousetrap.unbind("i"); Mousetrap.unbind("h"); Mousetrap.unbind("o"); + Mousetrap.unbind("d d"); Mousetrap.unbind("p n"); Mousetrap.unbind("p p"); Mousetrap.unbind("p r"); @@ -674,17 +678,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { >
    - {scene.studio && ( -

    - - {`${scene.studio.name} - -

    - )} +

    diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx index ad7663e9d..b109016b1 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx @@ -6,6 +6,7 @@ import { TagLink } from "src/components/Shared/TagLink"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { sortPerformers } from "src/core/performers"; import { DirectorLink } from "src/components/Shared/Link"; +import { CustomFields } from "src/components/Shared/CustomFields"; interface ISceneDetailProps { scene: GQL.SceneDataFragment; @@ -103,6 +104,7 @@ export const SceneDetailPanel: React.FC = (props) => { {renderDetails()} {renderTags()} {renderPerformers()} +
    diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 54bf5b573..2e8bb39fa 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -50,6 +50,11 @@ import { Group } from "src/components/Groups/GroupSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import cloneDeep from "lodash-es/cloneDeep"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -140,6 +145,7 @@ export const SceneEditPanel: React.FC = ({ stash_ids: yup.mixed().defined(), details: yup.string().ensure(), cover_image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = useMemo( @@ -159,17 +165,28 @@ export const SceneEditPanel: React.FC = ({ stash_ids: getStashIDs(scene.stash_ids), details: scene.details ?? "", cover_image: initialCoverImage, + custom_fields: cloneDeep(scene.custom_fields ?? {}), }), [scene, initialCoverImage] ); type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( @@ -288,7 +305,10 @@ export const SceneEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -759,7 +779,9 @@ export const SceneEditPanel: React.FC = ({ id="scene-save-split-button" className="edit-button" variant="primary" - disabled={!isEqual(formik.errors, {})} + disabled={ + !isEqual(formik.errors, {}) || customFieldsError !== undefined + } title={intl.formatMessage({ id: "actions.save" })} onClick={() => formik.submitForm()} > @@ -772,7 +794,9 @@ export const SceneEditPanel: React.FC = ({ className="edit-button" variant="primary" disabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onClick={() => formik.submitForm()} > @@ -863,6 +887,13 @@ export const SceneEditPanel: React.FC = ({ onReset={scene.id ? onResetCover : undefined} /> + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index 63490a2ee..cd11a2c8a 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -9,6 +9,7 @@ import { import { useHistory } from "react-router-dom"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; +import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; import { ReassignFilesDialog } from "src/components/Shared/ReassignFilesDialog"; import * as GQL from "src/core/generated-graphql"; import { mutateSceneSetPrimaryFile } from "src/core/StashService"; @@ -59,23 +60,28 @@ const FileInfoPanel: React.FC = ( )} - - + + - + + + + + + diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index a0458c5ac..156258045 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -58,6 +58,7 @@ import useFocus from "src/utils/focus"; import { useZoomKeybinds } from "../List/ZoomSlider"; import { FilteredListToolbar } from "../List/FilteredListToolbar"; import { FilterTags } from "../List/FilterTags"; +import { SidebarFolderFilter } from "../List/Filters/FolderFilter"; function renderMetadataByline(result: GQL.FindScenesQueryResult) { const duration = result?.data?.findScenes?.duration; @@ -305,6 +306,12 @@ const SidebarContent: React.FC<{ /> + } + filter={filter} + setFilter={setFilter} + sectionID="folder" + /> } data-type={HasMarkersCriterionOption.type} @@ -412,7 +419,7 @@ export const FilteredSceneList = PatchComponent( setFilter, }); - useAddKeybinds(filter, totalCount); + useAddKeybinds(effectiveFilter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index b5975ca5a..6287b25ae 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -1,7 +1,7 @@ import cloneDeep from "lodash-es/cloneDeep"; -import React from "react"; +import React, { useCallback, useEffect } from "react"; import { useHistory } from "react-router-dom"; -import { useIntl } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { @@ -9,7 +9,7 @@ import { useFindSceneMarkers, } from "src/core/StashService"; import NavUtils from "src/utils/navigation"; -import { ItemList, ItemListContext } from "../List/ItemList"; +import { useFilteredItemList } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { MarkerWallPanel } from "./SceneMarkerWallPanel"; @@ -17,17 +17,179 @@ import { View } from "../List/views"; import { SceneMarkerCardGrid } from "./SceneMarkerCardGrid"; import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog"; import { EditSceneMarkersDialog } from "./EditSceneMarkersDialog"; -import { PatchComponent } from "src/patch"; -import { IItemListOperation } from "../List/FilteredListToolbar"; +import { PatchComponent, PatchContainerComponent } from "src/patch"; +import { + FilteredListToolbar, + IItemListOperation, +} from "../List/FilteredListToolbar"; +import { + Sidebar, + SidebarPane, + SidebarPaneContent, + SidebarStateContext, + useSidebarState, +} from "../Shared/Sidebar"; +import { useCloseEditDelete, useFilterOperations } from "../List/util"; +import { + FilteredSidebarHeader, + useFilteredSidebarKeybinds, +} from "../List/Filters/FilterSidebar"; +import { useZoomKeybinds } from "../List/ZoomSlider"; +import { + IListFilterOperation, + ListOperations, +} from "../List/ListOperationButtons"; +import cx from "classnames"; +import { FilterTags } from "../List/FilterTags"; +import { Pagination, PaginationIndex } from "../List/Pagination"; +import { LoadedContent } from "../List/PagedList"; +import useFocus from "src/utils/focus"; +import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; +import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; +import { Button } from "react-bootstrap"; -function getItems(result: GQL.FindSceneMarkersQueryResult) { - return result?.data?.findSceneMarkers?.scene_markers ?? []; +const SceneMarkerList: React.FC<{ + markers: GQL.SceneMarkerDataFragment[]; + filter: ListFilterModel; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +}> = PatchComponent( + "SceneMarkerList", + ({ markers, filter, selectedIds, onSelectChange }) => { + if (markers.length === 0) { + return null; + } + + if (filter.displayMode === DisplayMode.Wall) { + return ( + + ); + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + + return null; + } +); + +function usePlayRandom(filter: ListFilterModel, count: number) { + const history = useHistory(); + + const playRandom = useCallback(async () => { + // query for a random scene + if (count === 0) { + return; + } + + const pages = Math.ceil(count / filter.itemsPerPage); + const page = Math.floor(Math.random() * pages) + 1; + + const indexMax = Math.min(filter.itemsPerPage, count); + const index = Math.floor(Math.random() * indexMax); + const filterCopy = cloneDeep(filter); + filterCopy.currentPage = page; + filterCopy.sortBy = "random"; + const queryResults = await queryFindSceneMarkers(filterCopy); + const marker = queryResults.data.findSceneMarkers.scene_markers[index]; + if (marker) { + // navigate to the scene player page + const url = NavUtils.makeSceneMarkerUrl(marker); + history.push(url); + } + }, [filter, count, history]); + + return playRandom; } -function getCount(result: GQL.FindSceneMarkersQueryResult) { - return result?.data?.findSceneMarkers?.count ?? 0; +function useAddKeybinds(filter: ListFilterModel, count: number) { + const playRandom = usePlayRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + playRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [playRandom]); } +const ScenesFilterSidebarSections = PatchContainerComponent( + "FilteredSceneMarkerList.SidebarSections" +); + +const SidebarContent: React.FC<{ + filter: ListFilterModel; + setFilter: (filter: ListFilterModel) => void; + filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; + sidebarOpen: boolean; + onClose?: () => void; + showEditFilter: (editingCriterion?: string) => void; + count?: number; + focus?: ReturnType; +}> = ({ + filter, + setFilter, + filterHook, + view, + showEditFilter, + sidebarOpen, + onClose, + count, + focus, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + + return ( + <> + + + + + + + +
    + +
    + + ); +}; + interface ISceneMarkerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; @@ -36,132 +198,274 @@ interface ISceneMarkerList { extraOperations?: IItemListOperation[]; } -export const SceneMarkerList: React.FC = PatchComponent( - "SceneMarkerList", - ({ filterHook, view, alterQuery, extraOperations = [] }) => { +export const FilteredSceneMarkerList = PatchComponent( + "FilteredSceneMarkerList", + (props: ISceneMarkerList) => { const intl = useIntl(); - const history = useHistory(); - const filterMode = GQL.FilterMode.SceneMarkers; + const searchFocus = useFocus(); - const otherOperations = [ - ...extraOperations, - { - text: intl.formatMessage({ id: "actions.play_random" }), - onClick: playRandom, - }, - ]; + const { + filterHook, + defaultSort, + view, + alterQuery, + extraOperations = [], + } = props; - function addKeybinds( - result: GQL.FindSceneMarkersQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - playRandom(result, filter); + // States + const { + showSidebar, + setShowSidebar, + loading: sidebarStateLoading, + sectionOpen, + setSectionOpen, + } = useSidebarState(view); + + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.SceneMarkers, + defaultSort, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindSceneMarkers, + getCount: (r) => r.data?.findSceneMarkers.count ?? 0, + getItems: (r) => r.data?.findSceneMarkers.scene_markers ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, + }); + + useAddKeybinds(filter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); + + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); + + const onEdit = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + + const onDelete = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } }); return () => { - Mousetrap.unbind("p r"); + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); }; - } + }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]); - async function playRandom( - result: GQL.FindSceneMarkersQueryResult, - filter: ListFilterModel - ) { - // query for a random scene - if (result.data?.findSceneMarkers) { - const { count } = result.data.findSceneMarkers; + useZoomKeybinds({ + zoomIndex: filter.zoomIndex, + onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), + }); - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindSceneMarkers(filterCopy); - if (singleResult.data.findSceneMarkers.scene_markers.length === 1) { - // navigate to the scene player page - const url = NavUtils.makeSceneMarkerUrl( - singleResult.data.findSceneMarkers.scene_markers[0] - ); - history.push(url); - } - } - } + const playRandom = usePlayRandom(effectiveFilter, totalCount); - function renderContent( - result: GQL.FindSceneMarkersQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - if (!result.data?.findSceneMarkers) return; + const convertedExtraOperations: IListFilterOperation[] = + extraOperations.map((o) => ({ + ...o, + isDisplayed: o.isDisplayed + ? () => o.isDisplayed!(result, filter, selectedIds) + : undefined, + onClick: () => { + o.onClick(result, filter, selectedIds); + }, + })); - if (filter.displayMode === DisplayMode.Wall) { - return ( - - ); - } + const otherOperations = [ + ...convertedExtraOperations, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.play_random" }), + onClick: playRandom, + isDisplayed: () => totalCount > 1, + }, + // { + // text: `${intl.formatMessage({ id: "actions.generate" })}…`, + // onClick: () => + // showModal( + // closeModal()} + // /> + // ), + // isDisplayed: () => hasSelection, + // }, + ]; - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - } + // render + if (sidebarStateLoading) return null; - function renderEditDialog( - selectedMarkers: GQL.SceneMarkerDataFragment[], - onClose: (applied: boolean) => void - ) { - return ( - - ); - } - - function renderDeleteDialog( - selectedSceneMarkers: GQL.SceneMarkerDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( - - ); - } + const operations = ( + + ); return ( - - - + {modal} + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> + +
    + setFilter(filter.changePage(page))} + /> + +
    + + + + + + {totalCount > filter.itemsPerPage && ( +
    +
    + +
    +
    + )} +
    +
    +
    +
    ); } ); -export default SceneMarkerList; +export default FilteredSceneMarkerList; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx index 5c9769206..891801f2d 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx @@ -1,13 +1,9 @@ import React from "react"; -import { Link } from "react-router-dom"; import { useFindSceneMarkers } from "src/core/StashService"; -import Slider from "@ant-design/react-slick"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { getSlickSliderSettings } from "src/core/recommendations"; -import { RecommendationRow } from "../FrontPage/RecommendationRow"; -import { FormattedMessage } from "react-intl"; import { SceneMarkerCard } from "./SceneMarkerCard"; import { PatchComponent } from "src/patch"; +import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; @@ -19,47 +15,34 @@ export const SceneMarkerRecommendationRow: React.FC = PatchComponent( "SceneMarkerRecommendationRow", (props) => { const result = useFindSceneMarkers(props.filter); - const cardCount = result.data?.findSceneMarkers.count; - - if (!result.loading && !cardCount) { - return null; - } + const count = result.data?.findSceneMarkers.count ?? 0; return ( - - - - } + heading={props.header} + url={`/scenes/markers?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
    - )) - : result.data?.findSceneMarkers.scene_markers.map( - (marker, index) => ( - - ) - )} -
    -
    + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
    + )) + : result.data?.findSceneMarkers.scene_markers.map((marker, index) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx index 863078c4e..5883bbed7 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx @@ -73,7 +73,7 @@ export const MarkerWallItem: React.FC< divStyle.top = props.top; } - var handleClick = function handleClick(event: React.MouseEvent) { + const handleClick = function (event: React.MouseEvent) { if (props.selecting && props.onSelectedChanged) { props.onSelectedChanged(!props.selected, event.shiftKey); event.preventDefault(); @@ -131,7 +131,8 @@ export const MarkerWallItem: React.FC< alt={props.photo.alt} onMouseEnter={() => setActive(true)} onMouseLeave={() => setActive(false)} - onClick={handleClick} + // having a click handler here results in multiple calls to handleClick + // due to having the same click handler on the parent div onError={() => { props.photo.onError?.(props.photo); }} diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 9455af186..d62daac7a 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -3,16 +3,20 @@ import React, { useEffect, useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; -import { StringListSelect, GallerySelect } from "../Shared/Select"; +import { GallerySelect } from "../Shared/Select"; import * as FormUtils from "src/utils/form"; import ImageUtils from "src/utils/image"; import TextUtils from "src/utils/text"; -import { mutateSceneMerge, queryFindScenesByID } from "src/core/StashService"; +import { + mutateSceneMerge, + queryFindFullScenesByID, +} from "src/core/StashService"; import { FormattedMessage, useIntl } from "react-intl"; import { useToast } from "src/hooks/Toast"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { ScrapeDialogRow, + ScrapedCustomFieldRows, ScrapedImageRow, ScrapedInputGroupRow, ScrapedStringListRow, @@ -22,8 +26,9 @@ import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog"; import { clone, uniq } from "lodash-es"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ModalComponent } from "../Shared/Modal"; -import { IHasStoredID, sortStoredIdObjects } from "src/utils/data"; +import { sortStoredIdObjects, uniqIDStoredIDs } from "src/utils/data"; import { + CustomFieldScrapeResults, ObjectListScrapeResult, ScrapeResult, ZeroableScrapeResult, @@ -36,14 +41,7 @@ import { ScrapedTagsRow, } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; - -interface IStashIDsField { - values: GQL.StashId[]; -} - -const StashIDsField: React.FC = ({ values }) => { - return v.stash_id)} />; -}; +import { StashIDsField } from "../Shared/StashID"; type MergeOptions = { values: GQL.SceneUpdateInput; @@ -52,8 +50,8 @@ type MergeOptions = { }; interface ISceneMergeDetailsProps { - sources: GQL.SlimSceneDataFragment[]; - dest: GQL.SlimSceneDataFragment; + sources: GQL.SceneDataFragment[]; + dest: GQL.SceneDataFragment; onClose: (options?: MergeOptions) => void; } @@ -127,12 +125,6 @@ const SceneMergeDetails: React.FC = ({ return ret; } - function uniqIDStoredIDs(objs: T[]) { - return objs.filter((o, i) => { - return objs.findIndex((oo) => oo.stored_id === o.stored_id) === i; - }); - } - const [performers, setPerformers] = useState< ObjectListScrapeResult >( @@ -173,6 +165,10 @@ const SceneMergeDetails: React.FC = ({ new ScrapeResult(dest.paths.screenshot) ); + const [customFields, setCustomFields] = useState( + new Map() + ); + // calculate the values for everything // uses the first set value for single value fields, and combines all useEffect(() => { @@ -309,28 +305,64 @@ const SceneMergeDetails: React.FC = ({ ) ); + const customFieldNames = new Set( + Object.keys(dest.custom_fields ?? {}) + ); + + for (const s of sources) { + for (const n of Object.keys(s.custom_fields ?? {})) { + customFieldNames.add(n); + } + } + + setCustomFields( + new Map( + Array.from(customFieldNames) + .sort() + .map((field) => { + return [ + field, + new ScrapeResult( + dest.custom_fields?.[field], + sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[ + field + ], + dest.custom_fields?.[field] === undefined + ), + ]; + }) + ) + ); + loadImages(); }, [sources, dest]); + const hasCustomFieldValues = useMemo(() => { + return hasScrapedValues(Array.from(customFields.values())); + }, [customFields]); + // ensure this is updated if fields are changed const hasValues = useMemo(() => { - return hasScrapedValues([ - title, - code, - url, - date, - rating, - oCounter, - galleries, - studio, - performers, - groups, - tags, - details, - organized, - stashIDs, - image, - ]); + return ( + hasCustomFieldValues || + hasScrapedValues([ + title, + code, + url, + date, + rating, + oCounter, + galleries, + studio, + performers, + groups, + tags, + details, + organized, + stashIDs, + image, + ]) + ); }, [ title, code, @@ -347,6 +379,7 @@ const SceneMergeDetails: React.FC = ({ organized, stashIDs, image, + hasCustomFieldValues, ]); function renderScrapeRows() { @@ -554,10 +587,21 @@ const SceneMergeDetails: React.FC = ({ title={intl.formatMessage({ id: "stash_id" })} result={stashIDs} originalField={ - + + } + newField={ + } - newField={} onChange={(value) => setStashIDs(value)} + alwaysShow={ + !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length + } /> = ({ result={image} onChange={(value) => setImage(value)} /> + {hasCustomFieldValues && ( + setCustomFields(newCustomFields)} + /> + )} ); } @@ -606,6 +656,13 @@ const SceneMergeDetails: React.FC = ({ organized: organized.getNewValue(), stash_ids: stashIDs.getNewValue(), cover_image: coverImage, + custom_fields: { + partial: Object.fromEntries( + Array.from(customFields.entries()).flatMap(([field, v]) => + v.useNewValue ? [[field, v.getNewValue()]] : [] + ) + ), + }, }, includeViewHistory: playCount.getNewValue() !== undefined, includeOHistory: oCounter.getNewValue() !== undefined, @@ -621,7 +678,7 @@ const SceneMergeDetails: React.FC = ({ : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" - : intl.formatMessage({ id: "dialogs.merge.source" }); + : intl.formatMessage({ id: "dialogs.merge.combined" }); return ( = ({ const [sourceScenes, setSourceScenes] = useState([]); const [destScene, setDestScene] = useState([]); - const [loadedSources, setLoadedSources] = useState< - GQL.SlimSceneDataFragment[] - >([]); - const [loadedDest, setLoadedDest] = useState(); + const [loadedSources, setLoadedSources] = useState( + [] + ); + const [loadedDest, setLoadedDest] = useState(); const [running, setRunning] = useState(false); const [secondStep, setSecondStep] = useState(false); @@ -670,6 +727,12 @@ export const SceneMergeModal: React.FC = ({ id: "actions.merge", }); + const srcIDs = useMemo(() => sourceScenes.map((s) => s.id), [sourceScenes]); + const destID = useMemo( + () => (destScene[0] ? [destScene[0].id] : []), + [destScene] + ); + useEffect(() => { if (scenes.length > 0) { // set the first scene as the destination, others as source @@ -684,7 +747,7 @@ export const SceneMergeModal: React.FC = ({ async function loadScenes() { const sceneIDs = sourceScenes.map((s) => parseInt(s.id)); sceneIDs.push(parseInt(destScene[0].id)); - const query = await queryFindScenesByID(sceneIDs); + const query = await queryFindFullScenesByID(sceneIDs); const { scenes: loadedScenes } = query.data.findScenes; setLoadedDest(loadedScenes.find((s) => s.id === destScene[0].id)); @@ -777,6 +840,7 @@ export const SceneMergeModal: React.FC = ({ onSelect={(items) => setSourceScenes(items)} values={sourceScenes} menuPortalTarget={document.body} + excludeIds={destID} /> @@ -810,6 +874,7 @@ export const SceneMergeModal: React.FC = ({ onSelect={(items) => setDestScene(items)} values={destScene} menuPortalTarget={document.body} + excludeIds={srcIDs} /> diff --git a/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx b/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx index f90b63ec6..f5aafe846 100644 --- a/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx +++ b/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx @@ -1,14 +1,10 @@ import React, { useMemo } from "react"; -import { Link } from "react-router-dom"; import { useFindScenes } from "src/core/StashService"; -import Slider from "@ant-design/react-slick"; import { SceneCard } from "./SceneCard"; import { SceneQueue } from "src/models/sceneQueue"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { getSlickSliderSettings } from "src/core/recommendations"; -import { RecommendationRow } from "../FrontPage/RecommendationRow"; -import { FormattedMessage } from "react-intl"; import { PatchComponent } from "src/patch"; +import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; @@ -20,50 +16,36 @@ export const SceneRecommendationRow: React.FC = PatchComponent( "SceneRecommendationRow", (props) => { const result = useFindScenes(props.filter); - const cardCount = result.data?.findScenes.count; + const count = result.data?.findScenes.count ?? 0; const queue = useMemo(() => { return SceneQueue.fromListFilterModel(props.filter); }, [props.filter]); - if (!result.loading && !cardCount) { - return null; - } - return ( - - - - } + heading={props.header} + url={`/scenes?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
    - )) - : result.data?.findScenes.scenes.map((scene, index) => ( - - ))} -
    -
    + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
    + )) + : result.data?.findScenes.scenes.map((scene, index) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/Scenes/SceneSelect.tsx b/ui/v2.5/src/components/Scenes/SceneSelect.tsx index 8ab32b753..fed72dd53 100644 --- a/ui/v2.5/src/components/Scenes/SceneSelect.tsx +++ b/ui/v2.5/src/components/Scenes/SceneSelect.tsx @@ -22,6 +22,7 @@ import { IFilterProps, IFilterValueProps, Option as SelectOption, + toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Placement } from "react-bootstrap/esm/Overlay"; @@ -33,6 +34,8 @@ import { CriterionValue, } from "src/models/list-filter/criteria/criterion"; import { TruncatedText } from "../Shared/TruncatedText"; +import { isUUID } from "src/utils/stashIds"; +import { filterByStashID } from "src/models/list-filter/utils"; export type Scene = Pick & { studio?: Pick | null; @@ -73,29 +76,44 @@ const _SceneSelect: React.FC< const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + function filterExcluded(scene: Scene) { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(scene.id.toString()); + } + async function loadScenes(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Scenes); - filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "title"; filter.sortDirection = GQL.SortDirectionEnum.Asc; - if (props.extraCriteria) { - filter.criteria = [...props.extraCriteria]; + filter.criteria = [...(props.extraCriteria ?? [])]; + + if (isUUID(input)) { + const oldCriteria = filter.criteria; + + filterByStashID(filter, input); + + const query = await queryFindScenesForSelect(filter); + const matches = query.data.findScenes.scenes.filter(filterExcluded); + + if (matches.length > 0) { + // Matches found, return them immediately. + return matches.map(toOption); + } + + // If no stash_id matches found, continue with standard name/alias search. + filter.criteria = oldCriteria; // Clear stash_id criterion to search by name/alias below. } - const query = await queryFindScenesForSelect(filter); - let ret = query.data.findScenes.scenes.filter((scene) => { - // HACK - we should probably exclude these in the backend query, but - // this will do in the short-term - return !exclude.includes(scene.id.toString()); - }); + filter.searchTerm = input; - return sceneSelectSort(input, ret).map((scene) => ({ - value: scene.id, - object: scene, - })); + const query = await queryFindScenesForSelect(filter); + const ret = query.data.findScenes.scenes.filter(filterExcluded); + + return sceneSelectSort(input, ret).map(toOption); } const SceneOption: React.FC> = (optionProps) => { diff --git a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx index bf4a97b49..f9a42dd48 100644 --- a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; @@ -15,6 +21,7 @@ import TextUtils from "src/utils/text"; import { useIntl } from "react-intl"; import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; +import { defaultPreviewVolume } from "src/core/config"; interface IScenePhoto { scene: GQL.SlimSceneDataFragment; @@ -42,6 +49,7 @@ export const SceneWallItem: React.FC< const { configuration } = useConfigurationContext(); const playSound = configuration?.interface.soundOnPreview ?? false; + const volume = configuration?.ui.previewVolume ?? defaultPreviewVolume; const showTitle = configuration?.interface.wallShowTitle ?? false; const height = Math.min(props.maxHeight, props.photo.height); @@ -62,7 +70,7 @@ export const SceneWallItem: React.FC< divStyle.top = props.top; } - var handleClick = function handleClick(event: React.MouseEvent) { + const handleClick = function (event: React.MouseEvent) { if (props.selecting && props.onSelectedChanged) { props.onSelectedChanged(!props.selected, event.shiftKey); event.preventDefault(); @@ -75,7 +83,32 @@ export const SceneWallItem: React.FC< }; const video = props.photo.src.includes("preview"); - const ImagePreview = video ? "video" : "img"; + const previewProps = { + loading: "lazy", + loop: video, + muted: !video || !playSound || !active, + autoPlay: video, + playsInline: video, + key: props.photo.key, + src: props.photo.src, + width, + height, + alt: props.photo.alt, + onMouseEnter: () => setActive(true), + onMouseLeave: () => setActive(false), + // having a click handler here results in multiple calls to handleClick + // due to having the same click handler on the parent div + onError: () => { + props.photo.onError?.(props.photo); + }, + }; + + const videoEl = useRef(null); + + useEffect(() => { + if (video && videoEl?.current?.volume) + videoEl.current.volume = playSound ? volume / 100 : 0; + }, [video, playSound, volume]); const { scene } = props.photo; const title = objectTitle(scene); @@ -111,27 +144,23 @@ export const SceneWallItem: React.FC< }} /> )} - setActive(true)} - onMouseLeave={() => setActive(false)} - onClick={handleClick} - onError={() => { - props.photo.onError?.(props.photo); - }} - /> + {video ? ( +
    {children} + + ); + } + + return ( + + + + + {children} + + ); +}; diff --git a/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx b/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx deleted file mode 100644 index cf78798e1..000000000 --- a/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { faBan } from "@fortawesome/free-solid-svg-icons"; -import React from "react"; -import { Button, Form, FormControlProps, InputGroup } from "react-bootstrap"; -import { useIntl } from "react-intl"; -import { Icon } from "./Icon"; - -interface IBulkUpdateTextInputProps extends FormControlProps { - valueChanged: (value: string | undefined) => void; - unsetDisabled?: boolean; - as?: React.ElementType; -} - -export const BulkUpdateTextInput: React.FC = ({ - valueChanged, - unsetDisabled, - ...props -}) => { - const intl = useIntl(); - - const unsetClassName = props.value === undefined ? "unset" : ""; - - return ( - - ` - : undefined - } - onChange={(event) => valueChanged(event.currentTarget.value)} - /> - {!unsetDisabled ? ( - - ) : undefined} - - ); -}; diff --git a/ui/v2.5/src/components/Shared/ClearableInput.tsx b/ui/v2.5/src/components/Shared/ClearableInput.tsx index 76c6db54a..56f17a7f9 100644 --- a/ui/v2.5/src/components/Shared/ClearableInput.tsx +++ b/ui/v2.5/src/components/Shared/ClearableInput.tsx @@ -10,6 +10,7 @@ interface IClearableInput { className?: string; value: string; setValue: (value: string) => void; + onEnter?: () => void; focus?: ReturnType; placeholder?: string; } @@ -18,6 +19,7 @@ export const ClearableInput: React.FC = ({ className, value, setValue, + onEnter, focus, placeholder, }) => { @@ -43,6 +45,9 @@ export const ClearableInput: React.FC = ({ if (e.key === "Escape") { queryRef.current?.blur(); } + if (e.key === "Enter" && onEnter) { + onEnter(); + } } return ( diff --git a/ui/v2.5/src/components/Shared/CollapseButton.tsx b/ui/v2.5/src/components/Shared/CollapseButton.tsx index 0d05f6e64..fe8330a9c 100644 --- a/ui/v2.5/src/components/Shared/CollapseButton.tsx +++ b/ui/v2.5/src/components/Shared/CollapseButton.tsx @@ -2,6 +2,7 @@ import { faChevronDown, faChevronRight, faChevronUp, + IconDefinition, } from "@fortawesome/free-solid-svg-icons"; import React, { useEffect, useState } from "react"; import { Button, Collapse, CollapseProps } from "react-bootstrap"; @@ -55,14 +56,21 @@ export const CollapseButton: React.FC> = ( export const ExpandCollapseButton: React.FC<{ collapsed: boolean; setCollapsed: (collapsed: boolean) => void; -}> = ({ collapsed, setCollapsed }) => { - const buttonIcon = collapsed ? faChevronDown : faChevronUp; + collapsedIcon?: IconDefinition; + notCollapsedIcon?: IconDefinition; +}> = ({ collapsedIcon, notCollapsedIcon, collapsed, setCollapsed }) => { + const buttonIcon = collapsed + ? collapsedIcon ?? faChevronDown + : notCollapsedIcon ?? faChevronUp; return ( diff --git a/ui/v2.5/src/components/Shared/CustomFields.tsx b/ui/v2.5/src/components/Shared/CustomFields.tsx index c8d389a17..c6e0ecb76 100644 --- a/ui/v2.5/src/components/Shared/CustomFields.tsx +++ b/ui/v2.5/src/components/Shared/CustomFields.tsx @@ -3,7 +3,7 @@ import { CollapseButton } from "./CollapseButton"; import { DetailItem } from "./DetailItem"; import { Button, Col, Form, FormGroup, InputGroup, Row } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; -import { cloneDeep } from "@apollo/client/utilities"; +import cloneDeep from "lodash-es/cloneDeep"; import { Icon } from "./Icon"; import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; @@ -18,6 +18,7 @@ export type CustomFieldMap = { interface ICustomFields { values: CustomFieldMap; + fullWidth?: boolean; } function convertValue(value: unknown): string { @@ -41,7 +42,7 @@ const CustomField: React.FC<{ field: string; value: unknown }> = ({ const valueStr = convertValue(value); // replace spaces with hyphen characters for css id - const id = field.toLowerCase().replace(/ /g, "-"); + const id = `custom-field-${field.toLowerCase().replace(/ /g, "-")}`; return ( = ({ export const CustomFields: React.FC = PatchComponent( "CustomFields", - ({ values }) => { + ({ values, fullWidth }) => { const intl = useIntl(); if (Object.keys(values).length === 0) { return null; @@ -65,7 +66,7 @@ export const CustomFields: React.FC = PatchComponent( return ( // according to linter rule CSS classes shouldn't use underscores -
    +
    @@ -125,7 +126,7 @@ const CustomFieldInput: React.FC<{ -
    + {isNew ? ( <> {currentField} )} - + void; } +export function formatCustomFieldInput(isNew: boolean, input: {}) { + if (isNew) { + return input; + } else { + return { + full: input, + }; + } +} + export const CustomFieldsInput: React.FC = PatchComponent( "CustomFieldsInput", ({ values, error, onChange, setError }) => { @@ -282,10 +293,10 @@ export const CustomFieldsInput: React.FC = PatchComponent( - + - + diff --git a/ui/v2.5/src/components/Shared/DateInput.tsx b/ui/v2.5/src/components/Shared/DateInput.tsx index 15a0f1123..4bb39ac39 100644 --- a/ui/v2.5/src/components/Shared/DateInput.tsx +++ b/ui/v2.5/src/components/Shared/DateInput.tsx @@ -8,14 +8,20 @@ import { Icon } from "./Icon"; import "react-datepicker/dist/react-datepicker.css"; import { useIntl } from "react-intl"; import { PatchComponent } from "src/patch"; +import { faBan, faTimes } from "@fortawesome/free-solid-svg-icons"; interface IProps { + groupClassName?: string; + className?: string; disabled?: boolean; value: string; isTime?: boolean; onValueChange(value: string): void; placeholder?: string; + placeholderOverride?: string; error?: string; + appendBefore?: React.ReactNode; + appendAfter?: React.ReactNode; } const ShowPickerButton = forwardRef< @@ -32,6 +38,11 @@ const ShowPickerButton = forwardRef< const _DateInput: React.FC = (props: IProps) => { const intl = useIntl(); + const { + groupClassName = "date-input-group", + className = "date-input text-input", + } = props; + const date = useMemo(() => { const toDate = props.isTime ? TextUtils.stringToFuzzyDateTime @@ -70,34 +81,108 @@ const _DateInput: React.FC = (props: IProps) => { } } - const placeholderText = intl.formatMessage({ + const formatHint = intl.formatMessage({ id: props.isTime ? "datetime_format" : "date_format", }); + const placeholderText = props.placeholder + ? `${props.placeholder} (${formatHint})` + : formatHint; + return ( -
    - - props.onValueChange(e.currentTarget.value)} - placeholder={ - !props.disabled - ? props.placeholder - ? `${props.placeholder} (${placeholderText})` - : placeholderText - : undefined - } - isInvalid={!!props.error} - /> - {maybeRenderButton()} - - {props.error} - - -
    + + props.onValueChange(e.currentTarget.value)} + placeholder={ + !props.disabled + ? props.placeholderOverride ?? placeholderText + : undefined + } + isInvalid={!!props.error} + /> + + {props.appendBefore} + {maybeRenderButton()} + {props.appendAfter} + + + {props.error} + + ); }; export const DateInput = PatchComponent("DateInput", _DateInput); + +interface IBulkUpdateDateInputProps + extends Omit { + value: string | null | undefined; + valueChanged: (value: string | null | undefined) => void; + unsetDisabled?: boolean; + as?: React.ElementType; + error?: string; +} + +export const BulkUpdateDateInput: React.FC = ({ + valueChanged, + unsetDisabled, + ...props +}) => { + const intl = useIntl(); + + const unset = props.value === undefined; + + const unsetButton = !unsetDisabled ? ( + + ) : undefined; + + const clearButton = + props.value !== null ? ( + + ) : undefined; + + const placeholderValue = + props.value === null + ? `<${intl.formatMessage({ id: "empty_value" })}>` + : props.value === undefined + ? `<${intl.formatMessage({ id: "existing_value" })}>` + : undefined; + + function outValue(v: string | undefined) { + if (v === "") { + return null; + } + + return v; + } + + return ( + valueChanged(outValue(v))} + groupClassName="bulk-update-date-input" + className="date-input text-input" + appendBefore={clearButton} + appendAfter={unsetButton} + /> + ); +}; diff --git a/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx b/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx index cea415db7..159bb9f09 100644 --- a/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx +++ b/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx @@ -2,6 +2,7 @@ import { Button, Dropdown, Modal, SplitButton } from "react-bootstrap"; import React, { useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { ImageInput } from "./ImageInput"; +import { AutoTagConfirmDialog } from "./AutoTagConfirmDialog"; import cx from "classnames"; interface IProps { @@ -30,6 +31,7 @@ interface IProps { export const DetailsEditNavbar: React.FC = (props: IProps) => { const intl = useIntl(); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); + const [isAutoTagAlertOpen, setIsAutoTagAlertOpen] = useState(false); function renderEditButton() { if (props.isNew) return; @@ -114,14 +116,20 @@ export const DetailsEditNavbar: React.FC = (props: IProps) => { + { + setIsAutoTagAlertOpen(false); if (props.onAutoTag) { props.onAutoTag(); } }} - > - - + onCancel={() => setIsAutoTagAlertOpen(false)} + /> ); } diff --git a/ui/v2.5/src/components/Shared/FilterSelect.tsx b/ui/v2.5/src/components/Shared/FilterSelect.tsx index 732b1cffb..fbe786522 100644 --- a/ui/v2.5/src/components/Shared/FilterSelect.tsx +++ b/ui/v2.5/src/components/Shared/FilterSelect.tsx @@ -58,7 +58,6 @@ const SelectComponent = ( ) => { const { selectedOptions, - isLoading, isDisabled = false, creatable = false, components, @@ -101,10 +100,7 @@ const SelectComponent = ( }; return creatable ? ( - + ) : ( ); @@ -260,3 +256,10 @@ export interface IFilterIDProps { ids?: string[]; onSelect?: (item: T[]) => void; } + +export function toOption(item: T): Option { + return { + value: item.id, + object: item, + }; +} diff --git a/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx b/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx index 9bfd25071..6fe07a454 100644 --- a/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx @@ -10,7 +10,8 @@ interface IStudio { export const StudioOverlay: React.FC<{ studio: IStudio | null | undefined; -}> = ({ studio }) => { + disabled?: boolean; +}> = ({ studio, disabled }) => { const { configuration } = useConfigurationContext(); const configValue = configuration?.interface.showStudioAsText; @@ -29,12 +30,18 @@ export const StudioOverlay: React.FC<{ return false; }, [configValue, studio?.image_path]); + function onClick(e: React.MouseEvent) { + if (disabled) { + e.preventDefault(); + } + } + if (!studio) return <>; return ( // this class name is incorrect
    - + {showStudioAsText ? ( studio.name ) : ( diff --git a/ui/v2.5/src/components/Shared/HoverPopover.tsx b/ui/v2.5/src/components/Shared/HoverPopover.tsx index 013da7472..7ec8137ca 100644 --- a/ui/v2.5/src/components/Shared/HoverPopover.tsx +++ b/ui/v2.5/src/components/Shared/HoverPopover.tsx @@ -1,6 +1,8 @@ import React, { useState, useCallback, useEffect, useRef } from "react"; import { Overlay, Popover, OverlayProps } from "react-bootstrap"; import { PatchComponent } from "src/patch"; +import { Icon } from "./Icon"; +import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons"; interface IHoverPopover { enterDelay?: number; @@ -85,3 +87,20 @@ export const HoverPopover: React.FC = PatchComponent( ); } ); + +// convenience component to set the padding on popover content +export const PopoverCard: React.FC<{ className?: string }> = ({ + className, + children, +}) => { + return
    {children}
    ; +}; + +export const WarningHoverPopover: React.FC = PatchComponent( + "WarningHoverPopover", + ({ children, ...props }) => ( + + + + ) +); diff --git a/ui/v2.5/src/components/Shared/HoverScrubber.tsx b/ui/v2.5/src/components/Shared/HoverScrubber.tsx index 7c07e8adc..17c0ed79e 100644 --- a/ui/v2.5/src/components/Shared/HoverScrubber.tsx +++ b/ui/v2.5/src/components/Shared/HoverScrubber.tsx @@ -9,6 +9,7 @@ interface IHoverScrubber { activeIndex: number | undefined; setActiveIndex: (index: number | undefined) => void; onClick?: (index: number) => void; + disabled?: boolean; } export const HoverScrubber: React.FC = ({ @@ -16,6 +17,7 @@ export const HoverScrubber: React.FC = ({ activeIndex, setActiveIndex, onClick, + disabled, }) => { function getActiveIndex( e: @@ -69,6 +71,11 @@ export const HoverScrubber: React.FC = ({ | React.TouchEvent ) { if (!onClick) return; + if (disabled) { + // allow propagation up so that selection still works + e.preventDefault(); + return; + } const relatedTarget = e.currentTarget; diff --git a/ui/v2.5/src/components/Shared/ImageInput.tsx b/ui/v2.5/src/components/Shared/ImageInput.tsx index 7675da41f..57b8f06f8 100644 --- a/ui/v2.5/src/components/Shared/ImageInput.tsx +++ b/ui/v2.5/src/components/Shared/ImageInput.tsx @@ -10,8 +10,10 @@ import { import { useIntl } from "react-intl"; import { ModalComponent } from "./Modal"; import { Icon } from "./Icon"; -import { faFile, faLink } from "@fortawesome/free-solid-svg-icons"; +import { faClipboard, faFile, faLink } from "@fortawesome/free-solid-svg-icons"; import { PatchComponent } from "src/patch"; +import ImageUtils from "src/utils/image"; +import { useToast } from "src/hooks/Toast"; interface IImageInput { isEditing: boolean; @@ -39,6 +41,7 @@ export const ImageInput: React.FC = PatchComponent( const [isShowDialog, setIsShowDialog] = useState(false); const [url, setURL] = useState(""); const intl = useIntl(); + const Toast = useToast(); if (!isEditing) return
    ; @@ -58,6 +61,28 @@ export const ImageInput: React.FC = PatchComponent( ); } + async function onPasteClipboard() { + try { + const data = await ImageUtils.readClipboardImage(); + if (data && onImageURL) { + onImageURL(data); + Toast.success( + intl.formatMessage({ id: "toast.clipboard_image_pasted" }) + ); + } else { + Toast.error(intl.formatMessage({ id: "toast.clipboard_no_image" })); + } + } catch (e) { + if (e instanceof DOMException && e.name === "NotAllowedError") { + Toast.error( + intl.formatMessage({ id: "toast.clipboard_access_denied" }) + ); + } else { + Toast.error(e); + } + } + } + function showDialog() { setURL(""); setIsShowDialog(true); @@ -127,6 +152,16 @@ export const ImageInput: React.FC = PatchComponent( {intl.formatMessage({ id: "actions.from_url" })}
    + {window.isSecureContext && ( +
    + +
    + )} diff --git a/ui/v2.5/src/components/Shared/MultiSet.tsx b/ui/v2.5/src/components/Shared/MultiSet.tsx index 6be85b8b3..8f16bd716 100644 --- a/ui/v2.5/src/components/Shared/MultiSet.tsx +++ b/ui/v2.5/src/components/Shared/MultiSet.tsx @@ -12,9 +12,10 @@ import { PerformerIDSelect } from "../Performers/PerformerSelect"; import { StudioIDSelect } from "../Studios/StudioSelect"; import { TagIDSelect } from "../Tags/TagSelect"; import { GroupIDSelect } from "../Groups/GroupSelect"; +import { SceneIDSelect } from "../Scenes/SceneSelect"; interface IMultiSetProps { - type: "performers" | "studios" | "tags" | "groups" | "galleries"; + type: "performers" | "studios" | "tags" | "groups" | "galleries" | "scenes"; existingIds?: string[]; ids?: string[]; mode: GQL.BulkUpdateIdMode; @@ -89,6 +90,17 @@ const Select: React.FC = (props) => { menuPortalTarget={props.menuPortalTarget} /> ); + case "scenes": + return ( + + ); default: return ( = ({ fileId, folderId }) => { + const intl = useIntl(); + + if (!isLocalhost()) return null; + + function onClick() { + if (folderId) { + mutateRevealFolderInFileManager(folderId); + } else if (fileId) { + mutateRevealFileInFileManager(fileId); + } + } + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx index a0fe6489e..665dfa187 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx @@ -40,6 +40,7 @@ interface IScrapedRowProps extends IScrapedFieldProps { newField: React.ReactNode; onChange: (value: ScrapeResult) => void; newValues?: React.ReactNode; + alwaysShow?: boolean; } export const ScrapeDialogRow = (props: IScrapedRowProps) => { @@ -51,7 +52,7 @@ export const ScrapeDialogRow = (props: IScrapedRowProps) => { props.onChange(ret); } - if (!props.result.scraped && !props.newValues) { + if (!props.result.scraped && !props.newValues && !props.alwaysShow) { return <>; } diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index f383f245a..6bd535df7 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -395,7 +395,13 @@ export const ScrapedTagsRow: React.FC< onSelect={(items) => { if (onChangeFn) { // map the id back to stored_id - onChangeFn(items.map((p) => ({ ...p, stored_id: p.id }))); + onChangeFn( + items.map((p) => ({ + ...p, + stored_id: p.id, + alias_list: p.aliases, + })) + ); } }} ids={selectValue} diff --git a/ui/v2.5/src/components/Shared/ScraperMenu.tsx b/ui/v2.5/src/components/Shared/ScraperMenu.tsx index 4cc38b6f8..9bdb84d45 100644 --- a/ui/v2.5/src/components/Shared/ScraperMenu.tsx +++ b/ui/v2.5/src/components/Shared/ScraperMenu.tsx @@ -6,6 +6,8 @@ import { stashboxDisplayName } from "src/utils/stashbox"; import { ScraperSourceInput, StashBox } from "src/core/generated-graphql"; import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { ClearableInput } from "./ClearableInput"; +import useFocus from "src/utils/focus"; +import ScreenUtils from "src/utils/screen"; export const ScraperMenu: React.FC<{ toggle: React.ReactNode; @@ -25,6 +27,10 @@ export const ScraperMenu: React.FC<{ const intl = useIntl(); const [filter, setFilter] = useState(""); + const focusOnOpen = !ScreenUtils.isTouch(); + const focusRef = useFocus(); + const [, setFocus] = focusRef; + const filteredStashboxes = useMemo(() => { if (!stashBoxes) return []; if (!filter) return stashBoxes; @@ -48,25 +54,27 @@ export const ScraperMenu: React.FC<{ { + if (focusOnOpen && v) setTimeout(() => setFocus(true), 0); + }} > {toggle}
    -
    - - -
    + +
    {filteredStashboxes.map((s, index) => ( diff --git a/ui/v2.5/src/components/Shared/Sidebar.tsx b/ui/v2.5/src/components/Shared/Sidebar.tsx index 10dbaaaba..51fddee33 100644 --- a/ui/v2.5/src/components/Shared/Sidebar.tsx +++ b/ui/v2.5/src/components/Shared/Sidebar.tsx @@ -97,15 +97,17 @@ interface IContext { export const SidebarStateContext = React.createContext(null); +export interface ISidebarSectionProps { + text: React.ReactNode; + className?: string; + outsideCollapse?: React.ReactNode; + onOpen?: () => void; + // used to store open/closed state in SidebarStateContext + sectionID?: string; +} + export const SidebarSection: React.FC< - PropsWithChildren<{ - text: React.ReactNode; - className?: string; - outsideCollapse?: React.ReactNode; - onOpen?: () => void; - // used to store open/closed state in SidebarStateContext - sectionID?: string; - }> + PropsWithChildren > = ({ className = "", text, diff --git a/ui/v2.5/src/components/Shared/StashID.tsx b/ui/v2.5/src/components/Shared/StashID.tsx index 847dd7ab2..b19f18eeb 100644 --- a/ui/v2.5/src/components/Shared/StashID.tsx +++ b/ui/v2.5/src/components/Shared/StashID.tsx @@ -31,3 +31,25 @@ export const StashIDPill: React.FC<{ ); }; + +interface IStashIDsField { + values: StashId[]; + linkType: LinkType; +} + +export const StashIDsField: React.FC = ({ + values, + linkType, +}) => { + if (!values.length) return null; + + return ( +
      + {values.map((v) => ( +
    • + +
    • + ))} +
    + ); +}; diff --git a/ui/v2.5/src/components/Shared/StudioLogo.tsx b/ui/v2.5/src/components/Shared/StudioLogo.tsx new file mode 100644 index 000000000..0da9d692a --- /dev/null +++ b/ui/v2.5/src/components/Shared/StudioLogo.tsx @@ -0,0 +1,35 @@ +import { Link } from "react-router-dom"; +import { Studio } from "src/core/generated-graphql"; +import { Icon } from "./Icon"; +import { faVideo } from "@fortawesome/free-solid-svg-icons"; + +export const StudioLogo: React.FC<{ + studio: Pick | undefined | null; + showText?: boolean; +}> = ({ studio, showText = false }) => { + if (!studio) return null; + + const hasLogo = + !showText && + studio.image_path && + !studio.image_path.endsWith("default=true"); + + return ( +

    + + {hasLogo ? ( + {`${studio.name} + ) : ( + + + {studio.name} + + )} + +

    + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index f72bbbeea..acc8556eb 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -127,11 +127,15 @@ .folder-list { list-style-type: none; margin: 0; - max-height: 30vw; + max-height: 300px; overflow-x: auto; padding-bottom: 0.5rem; padding-top: 1rem; + &:not(:last-child) { + margin-bottom: 1rem; + } + &-item { white-space: nowrap; @@ -233,6 +237,19 @@ button.collapse-button { .hover-popover-content { max-width: 32rem; text-align: center; + + .popover-card { + padding: 0.5rem; + } +} + +.warning-hover-popover { + display: inline-flex; + margin: 0 0.25rem; + + .fa-icon { + color: $warning; + } } .ErrorMessage-container { @@ -433,6 +450,13 @@ button.collapse-button { } } +.input-group-append { + .btn { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + } +} + .ModalComponent .modal-footer { justify-content: space-between; } @@ -474,30 +498,10 @@ button.collapse-button { } } -.bulk-update-text-input { - button { - background-color: $secondary; - color: $text-muted; - font-size: $btn-font-size-sm; - margin: $btn-padding-y $btn-padding-x; - padding: 0; - position: absolute; - right: 0; - z-index: 4; - - &:hover, - &:focus, - &:active, - &:not(:disabled):not(.disabled):active, - &:not(:disabled):not(.disabled):active:focus { - background-color: $secondary; - border-color: transparent; - box-shadow: none; - } - } - - &.unset button { - visibility: hidden; +.bulk-update-date-input { + .react-datepicker-wrapper .btn { + border-bottom-right-radius: 0; + border-top-right-radius: 0; } } @@ -646,10 +650,11 @@ div.react-datepicker { } .stash-id-pill { - display: inline-block; + display: inline-flex; font-size: 90%; font-weight: 700; line-height: 1; + max-width: 100%; padding-bottom: 0.25em; padding-top: 0.25em; text-align: center; @@ -665,12 +670,15 @@ div.react-datepicker { span { background-color: $primary; border-radius: 0.25rem 0 0 0.25rem; + flex-shrink: 0; min-width: 5em; } a { background-color: $secondary; border-radius: 0 0.25rem 0.25rem 0; + overflow: hidden; + text-overflow: ellipsis; } } @@ -762,15 +770,14 @@ button.btn.favorite-button { .scraper-filter-container { background-color: $secondary; border-bottom: solid 1px $textfield-bg; + display: flex; padding: 5px; position: sticky; top: 0; z-index: 1; - .btn-group { - border: solid 1px $textfield-bg; - border-radius: 5px; - width: 100%; + .clearable-input-group { + flex-grow: 1; } .clearable-text-field { @@ -795,6 +802,11 @@ button.btn.favorite-button { .detail-item { max-width: 100%; } + + .detail-item-title, + .detail-item-value { + font-family: "Courier New", Courier, monospace; + } } .custom-fields .detail-item .detail-item-title { @@ -816,6 +828,36 @@ button.btn.favorite-button { font-weight: 700; } +.custom-fields-input { + .custom-fields-field { + flex: 0 0 100%; + max-width: 100%; + + @include media-breakpoint-up(sm) { + flex: 0 0 25%; + max-width: 25%; + } + @include media-breakpoint-up(xl) { + flex: 0 0 16.667%; + max-width: 16.667%; + } + } + + .custom-fields-value { + flex: 0 0 100%; + max-width: 100%; + + @include media-breakpoint-up(sm) { + flex: 0 0 75%; + max-width: 75%; + } + @include media-breakpoint-up(xl) { + flex: 0 0 58.33%; + max-width: 58.33%; + } + } +} + .custom-fields-row { align-items: center; font-family: "Courier New", Courier, monospace; @@ -1204,3 +1246,25 @@ input[type="range"].double-range-slider-max { overflow-y: auto; } } + +.reveal-in-filesystem-button { + margin-left: 0.25rem; + padding: 0 0.25rem; +} + +// general styling for appended minimal button to input group +.text-input + .input-group-append .btn.minimal { + background-color: $textfield-bg; +} + +.studio-logo a:hover { + color: inherit; +} + +.studio-logo .studio-name { + color: $text-color; + font-size: 1.5rem; + font-weight: 500; + margin-top: 0.5rem; + text-align: center; +} diff --git a/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx index 1c34dfc36..72fcb3f71 100644 --- a/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx +++ b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from "react"; -import { Col, Form, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; import { useBulkStudioUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "../Shared/Modal"; @@ -13,9 +13,8 @@ import { getAggregateStateObject, } from "src/utils/bulkUpdate"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; -import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; -import * as FormUtils from "src/utils/form"; import { StudioSelect } from "../Shared/Select"; interface IListOperationProps { @@ -47,6 +46,8 @@ export const EditStudiosDialog: React.FC = ( mode: GQL.BulkUpdateIdMode.Add, }); + const unsetDisabled = props.selected.length < 2; + const [updateStudios] = useBulkStudioUpdate(); // Network state @@ -126,27 +127,6 @@ export const EditStudiosDialog: React.FC = ( setIsUpdating(false); } - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void, - area: boolean = false - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - as={area ? "textarea" : undefined} - /> - - ); - } - function render() { return ( = ( show icon={faPencilAlt} header={intl.formatMessage( - { id: "actions.edit_entity" }, - { entityType: intl.formatMessage({ id: "studios" }) } + { id: "dialogs.edit_entity_count_title" }, + { + count: props?.selected?.length ?? 1, + singularEntity: intl.formatMessage({ id: "studio" }), + pluralEntity: intl.formatMessage({ id: "studios" }), + } )} accept={{ onClick: onSave, @@ -168,11 +152,8 @@ export const EditStudiosDialog: React.FC = ( }} isRunning={isUpdating} > - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "parent_studio" }), - })} -
    + + setUpdateField({ @@ -183,13 +164,8 @@ export const EditStudiosDialog: React.FC = ( isDisabled={isUpdating} menuPortalTarget={document.body} /> - - - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "rating" }), - })} - + + @@ -197,9 +173,8 @@ export const EditStudiosDialog: React.FC = ( } disabled={isUpdating} /> - - - + + setUpdateField({ favorite: checked })} @@ -208,30 +183,31 @@ export const EditStudiosDialog: React.FC = ( /> - - - - + setTagIds((v) => ({ ...v, ids: itemIDs }))} - onSetMode={(newMode) => - setTagIds((v) => ({ ...v, mode: newMode })) - } - existingIds={aggregateState.tagIds ?? []} + onUpdate={(itemIDs) => { + setTagIds((c) => ({ ...c, ids: itemIDs })); + }} + onSetMode={(newMode) => { + setTagIds((c) => ({ ...c, mode: newMode })); + }} ids={tagIds.ids ?? []} + existingIds={aggregateState.tagIds} mode={tagIds.mode} menuPortalTarget={document.body} /> - + - {renderTextField( - "details", - updateInput.details, - (newValue) => setUpdateField({ details: newValue }), - true - )} + + setUpdateField({ details: newValue })} + unsetDisabled={unsetDisabled} + as="textarea" + /> + = PatchComponent( value={renderStashIDs()} fullWidth={fullWidth} /> + ); } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index f887e5403..b1de160b1 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -21,6 +21,11 @@ import { Studio, StudioSelect } from "../StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { Icon } from "src/components/Shared/Icon"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import cloneDeep from "lodash-es/cloneDeep"; interface IStudioEditPanel { studio: Partial; @@ -63,6 +68,7 @@ export const StudioEditPanel: React.FC = ({ ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -75,15 +81,26 @@ export const StudioEditPanel: React.FC = ({ tag_ids: (studio.tags ?? []).map((t) => t.id), ignore_auto_tag: studio.ignore_auto_tag ?? false, stash_ids: getStashIDs(studio.stash_ids), + custom_fields: cloneDeep(studio.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); const { tagsControl } = useTagsEdit(studio.tags, (ids) => @@ -144,7 +161,10 @@ export const StudioEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -242,6 +262,14 @@ export const StudioEditPanel: React.FC = ({ )} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> +
    {renderInputField("ignore_auto_tag", "checkbox")} @@ -254,7 +282,11 @@ export const StudioEditPanel: React.FC = ({ onToggleEdit={onCancel} onSave={formik.handleSubmit} onSaveAndNew={isNew ? onSaveAndNewClick : undefined} - saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} + saveDisabled={ + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined + } onImageChange={onImageChange} onImageChangeURL={onImageLoad} onClearImage={() => onImageLoad(null)} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx index a81c91462..f81599ceb 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useStudioFilterHook } from "src/core/studios"; -import { ImageList } from "src/components/Images/ImageList"; +import { FilteredImageList } from "src/components/Images/ImageList"; import { View } from "src/components/List/views"; interface IStudioImagesPanel { @@ -17,7 +17,7 @@ export const StudioImagesPanel: React.FC = ({ }) => { const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( - = PatchComponent( "StudioList", ({ studios, filter, selectedIds, onSelectChange, fromParent }) => { - if (studios.length === 0) { + if (studios.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } @@ -251,7 +251,7 @@ export const FilteredStudioList = PatchComponent( setFilter, }); - useAddKeybinds(filter, totalCount); + useAddKeybinds(effectiveFilter, totalCount); useFilteredSidebarKeybinds({ showSidebar, setShowSidebar, @@ -282,7 +282,7 @@ export const FilteredStudioList = PatchComponent( result, }); - const viewRandom = useViewRandom(filter, totalCount); + const viewRandom = useViewRandom(effectiveFilter, totalCount); function onExport(all: boolean) { showModal( diff --git a/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx b/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx index bede2da1d..3b6c037a8 100644 --- a/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx +++ b/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx @@ -1,13 +1,9 @@ import React from "react"; -import { Link } from "react-router-dom"; import { useFindStudios } from "src/core/StashService"; -import Slider from "@ant-design/react-slick"; import { StudioCard } from "./StudioCard"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { getSlickSliderSettings } from "src/core/recommendations"; -import { RecommendationRow } from "../FrontPage/RecommendationRow"; -import { FormattedMessage } from "react-intl"; import { PatchComponent } from "src/patch"; +import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; @@ -19,40 +15,29 @@ export const StudioRecommendationRow: React.FC = PatchComponent( "StudioRecommendationRow", (props) => { const result = useFindStudios(props.filter); - const cardCount = result.data?.findStudios.count; - - if (!result.loading && !cardCount) { - return null; - } + const count = result.data?.findStudios.count ?? 0; return ( - - - - } + heading={props.header} + url={`/studios?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
    - )) - : result.data?.findStudios.studios.map((s) => ( - - ))} -
    -
    + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
    + )) + : result.data?.findStudios.studios.map((s) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/Studios/StudioSelect.tsx b/ui/v2.5/src/components/Studios/StudioSelect.tsx index 7305aa60d..b80834c84 100644 --- a/ui/v2.5/src/components/Studios/StudioSelect.tsx +++ b/ui/v2.5/src/components/Studios/StudioSelect.tsx @@ -23,11 +23,14 @@ import { IFilterProps, IFilterValueProps, Option as SelectOption, + toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { Placement } from "react-bootstrap/esm/Overlay"; import { sortByRelevance } from "src/utils/query"; import { PatchComponent, PatchFunction } from "src/patch"; +import { isUUID } from "src/utils/stashIds"; +import { filterByStashID } from "src/models/list-filter/utils"; export type SelectObject = { id: string; @@ -74,24 +77,40 @@ const _StudioSelect: React.FC< const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + function filterExcluded(studio: Studio) { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(studio.id.toString()); + } + async function loadStudios(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Studios); - filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; - const query = await queryFindStudiosForSelect(filter); - let ret = query.data.findStudios.studios.filter((studio) => { - // HACK - we should probably exclude these in the backend query, but - // this will do in the short-term - return !exclude.includes(studio.id.toString()); - }); - return studioSelectSort(input, ret).map((studio) => ({ - value: studio.id, - object: studio, - })); + if (isUUID(input)) { + filterByStashID(filter, input); + + const query = await queryFindStudiosForSelect(filter); + const matches = query.data.findStudios.studios.filter(filterExcluded); + + if (matches.length > 0) { + // Matches found, return them immediately. + return matches.map(toOption); + } + + // If no stash_id matches found, continue with standard name/alias search. + filter.criteria = []; // Clear stash_id criterion to search by name/alias below. + } + + filter.searchTerm = input; + + const query = await queryFindStudiosForSelect(filter); + const ret = query.data.findStudios.studios.filter(filterExcluded); + + return studioSelectSort(input, ret).map(toOption); } const StudioOption: React.FC> = ( diff --git a/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx b/ui/v2.5/src/components/Tagger/FieldSelector.tsx similarity index 84% rename from ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx rename to ui/v2.5/src/components/Tagger/FieldSelector.tsx index b50716511..7a47862b5 100644 --- a/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx +++ b/ui/v2.5/src/components/Tagger/FieldSelector.tsx @@ -5,22 +5,25 @@ import { useIntl } from "react-intl"; import { ModalComponent } from "../Shared/Modal"; import { Icon } from "../Shared/Icon"; -import { PERFORMER_FIELDS } from "./constants"; interface IProps { show: boolean; + fields: string[]; excludedFields: string[]; onSelect: (fields: string[]) => void; } -const PerformerFieldSelect: React.FC = ({ +const FieldSelector: React.FC = ({ show, + fields, excludedFields, onSelect, }) => { const intl = useIntl(); const [excluded, setExcluded] = useState>( - excludedFields.reduce((dict, field) => ({ ...dict, [field]: true }), {}) + excludedFields + .filter((field) => fields.includes(field)) + .reduce((dict, field) => ({ ...dict, [field]: true }), {}) ); const toggleField = (field: string) => @@ -57,9 +60,9 @@ const PerformerFieldSelect: React.FC = ({
    These fields will be tagged by default. Click the button to toggle.
    - {PERFORMER_FIELDS.map((f) => renderField(f))} + {fields.map((f) => renderField(f))} ); }; -export default PerformerFieldSelect; +export default FieldSelector; diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index ac9444c5b..b872bcd31 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -15,10 +15,10 @@ import { faArrowLeft, faArrowRight, faCheck, - faExternalLinkAlt, faTimes, } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "../Shared/ExternalLink"; +import { StashIDPill } from "../Shared/StashID"; interface IPerformerModalProps { performer: GQL.ScrapedScenePerformerDataFragment; @@ -92,7 +92,7 @@ const PerformerModal: React.FC = ({ return (
    -
    +
    {!create && (
    {truncate ? ( -
    +
    ) : ( - {text} + {text} )}
    ); @@ -126,7 +126,7 @@ const PerformerModal: React.FC = ({ return (
    -
    +
    {!create && (
    -
    +
      {text.map((t, i) => (
    • @@ -208,15 +208,13 @@ const PerformerModal: React.FC = ({ function maybeRenderStashBoxLink() { const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; - if (!base) return; + if (!base || !performer.remote_site_id) return; return ( -
      - - - - -
      + ); } diff --git a/ui/v2.5/src/components/Tagger/StashBoxSelector.tsx b/ui/v2.5/src/components/Tagger/StashBoxSelector.tsx new file mode 100644 index 000000000..c47bc73f1 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/StashBoxSelector.tsx @@ -0,0 +1,61 @@ +import { Form } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; +import { StashBox } from "src/core/generated-graphql"; +import { useConfigurationContext } from "src/hooks/Config"; + +interface IStashBoxSelectorProps { + stashBoxes: StashBox[]; + selectedEndpoint: string; + onEndpointChange: (endpoint: string) => void; +} + +export const StashBoxSelector: React.FC = ({ + stashBoxes, + selectedEndpoint, + onEndpointChange, +}) => { + const { configuration } = useConfigurationContext(); + + function stashboxNameForEndpoint(endpoint: string) { + let box = configuration?.general.stashBoxes.find( + (sb) => sb.endpoint === endpoint + ); + return `stash-box: ${box?.name ?? endpoint}`; + } + + return ( + onEndpointChange(e.target.value)} + > + {!stashBoxes.length && ( + + )} + {stashBoxes.map((i) => ( + + ))} + + ); +}; + +export const StashBoxSelectorField: React.FC = ( + props +) => { + return ( + + + + +
      + +
      +
      + ); +}; diff --git a/ui/v2.5/src/components/Tagger/TaggerConfig.tsx b/ui/v2.5/src/components/Tagger/TaggerConfig.tsx new file mode 100644 index 000000000..45445f147 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/TaggerConfig.tsx @@ -0,0 +1,105 @@ +import React, { useState } from "react"; +import { Badge, Button, Card, Collapse, Form } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import FieldSelector from "./FieldSelector"; +import { Icon } from "../Shared/Icon"; +import { faCog } from "@fortawesome/free-solid-svg-icons"; + +interface ITaggerConfigProps { + show: boolean; + excludedFields: string[]; + onFieldsChange: (fields: string[]) => void; + fields: string[]; + entityName: string; + extraConfig?: React.ReactNode; +} + +const TaggerConfig: React.FC = ({ + show, + excludedFields, + onFieldsChange, + fields, + entityName, + extraConfig, +}) => { + const [showExclusionModal, setShowExclusionModal] = useState(false); + + const handleFieldSelect = (selectedFields: string[]) => { + onFieldsChange(selectedFields); + setShowExclusionModal(false); + }; + + return ( + <> + + +
      +

      + +

      +
      +
      + {extraConfig} + +
      + +
      + + {excludedFields.length > 0 ? ( + excludedFields.map((f) => ( + + + + )) + ) : ( + + )} + + + + + +
      +
      +
      +
      +
      + + + ); +}; + +export default TaggerConfig; + +export const ConfigButton: React.FC<{ + onClick: () => void; + showConfig: boolean; +}> = ({ onClick, showConfig }) => { + const intl = useIntl(); + + const showHideConfigId = showConfig + ? "actions.hide_configuration" + : "actions.show_configuration"; + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index d59a6d3d5..646dbf4c3 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -24,6 +24,7 @@ export const DEFAULT_BLACKLIST = [ ]; export const DEFAULT_EXCLUDED_PERFORMER_FIELDS = ["name"]; export const DEFAULT_EXCLUDED_STUDIO_FIELDS = ["name"]; +export const DEFAULT_EXCLUDED_TAG_FIELDS = ["name"]; export const initialConfig: ITaggerConfig = { blacklist: DEFAULT_BLACKLIST, @@ -35,7 +36,9 @@ export const initialConfig: ITaggerConfig = { excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS, markSceneAsOrganizedOnSave: false, excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS, + excludedTagFields: DEFAULT_EXCLUDED_TAG_FIELDS, createParentStudios: true, + createParentTags: true, }; export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata"; @@ -52,7 +55,9 @@ export interface ITaggerConfig { excludedPerformerFields?: string[]; markSceneAsOrganizedOnSave?: boolean; excludedStudioFields?: string[]; + excludedTagFields?: string[]; createParentStudios: boolean; + createParentTags: boolean; } export const PERFORMER_FIELDS = [ @@ -82,3 +87,4 @@ export const PERFORMER_FIELDS = [ ]; export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"]; +export const TAG_FIELDS = ["name", "description", "aliases", "parent_tags"]; diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index fb73f21e3..f1aa9dc22 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -303,8 +303,6 @@ export const TaggerContext: React.FC = ({ children }) => { if (results.error) { newResult = { error: results.error.message }; - } else if (results.errors) { - newResult = { error: results.errors.toString() }; } else { newResult = { results: results.data.scrapeSingleScene.map((r) => ({ @@ -339,8 +337,6 @@ export const TaggerContext: React.FC = ({ children }) => { if (results.error) { newResult = { error: results.error.message }; - } else if (results.errors) { - newResult = { error: results.errors.toString() }; } else { newResult = { results: results.data.scrapeSingleScene.map((r) => ({ @@ -401,8 +397,6 @@ export const TaggerContext: React.FC = ({ children }) => { if (results.error) { setMultiError(results.error.message); - } else if (results.errors) { - setMultiError(results.errors.toString()); } else { const newSearchResults = { ...searchResults }; sceneIDs.forEach((sceneID, index) => { diff --git a/ui/v2.5/src/components/Tagger/performers/Config.tsx b/ui/v2.5/src/components/Tagger/performers/Config.tsx deleted file mode 100644 index 0d5316735..000000000 --- a/ui/v2.5/src/components/Tagger/performers/Config.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, { Dispatch, useState } from "react"; -import { Badge, Button, Card, Collapse, Form } from "react-bootstrap"; -import { FormattedMessage } from "react-intl"; -import { useConfigurationContext } from "src/hooks/Config"; - -import { ITaggerConfig } from "../constants"; -import PerformerFieldSelector from "../PerformerFieldSelector"; - -interface IConfigProps { - show: boolean; - config: ITaggerConfig; - setConfig: Dispatch; -} - -const Config: React.FC = ({ show, config, setConfig }) => { - const { configuration: stashConfig } = useConfigurationContext(); - const [showExclusionModal, setShowExclusionModal] = useState(false); - - const excludedFields = config.excludedPerformerFields ?? []; - - const handleInstanceSelect = (e: React.ChangeEvent) => { - const selectedEndpoint = e.currentTarget.value; - setConfig({ - ...config, - selectedEndpoint, - }); - }; - - const stashBoxes = stashConfig?.general.stashBoxes ?? []; - - const handleFieldSelect = (fields: string[]) => { - setConfig({ ...config, excludedPerformerFields: fields }); - setShowExclusionModal(false); - }; - - return ( - <> - - -
      -

      - -

      -
      -
      - -
      - -
      - - {excludedFields.length > 0 ? ( - excludedFields.map((f) => ( - - - - )) - ) : ( - - )} - - - - - -
      - - - - - - {!stashBoxes.length && ( - - )} - {stashConfig?.general.stashBoxes.map((i) => ( - - ))} - - -
      -
      -
      -
      - - - ); -}; - -export default Config; diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index bb934a241..0e5ac2d9d 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -15,12 +15,11 @@ import { evictQueries, performerMutationImpactedQueries, } from "src/core/StashService"; -import { Manual } from "src/components/Help/Manual"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; -import PerformerConfig from "./Config"; -import { ITaggerConfig } from "../constants"; +import TaggerConfig, { ConfigButton } from "../TaggerConfig"; +import { ITaggerConfig, PERFORMER_FIELDS } from "../constants"; import PerformerModal from "../PerformerModal"; import { useUpdatePerformer } from "../queries"; import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; @@ -28,6 +27,7 @@ import { mergeStashIDs } from "src/utils/stashbox"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { useTaggerConfig } from "../config"; +import { StashBoxSelectorField } from "../StashBoxSelector"; type JobFragment = Pick< GQL.Job, @@ -348,6 +348,13 @@ const PerformerTaggerList: React.FC = ({ }); }; + // clear tagged performers when source is changed + useEffect(() => { + setTaggedPerformers({}); + setSearchResults({}); + setSearchErrors({}); + }, [selectedEndpoint]); + const updatePerformer = useUpdatePerformer(); function handleSaveError(performerID: string, name: string, message: string) { @@ -620,11 +627,9 @@ interface ITaggerProps { export const PerformerTagger: React.FC = ({ performers }) => { const jobsSubscribe = useJobsSubscribe(); - const intl = useIntl(); const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); - const [showManual, setShowManual] = useState(false); const [batchJobID, setBatchJobID] = useState(); const [batchJob, setBatchJob] = useState(); @@ -652,8 +657,6 @@ export const PerformerTagger: React.FC = ({ performers }) => { } }, [jobsSubscribe, batchJobID]); - if (!config) return ; - const savedEndpointIndex = stashConfig?.general.stashBoxes.findIndex( (s) => s.endpoint === config.selectedEndpoint @@ -665,6 +668,16 @@ export const PerformerTagger: React.FC = ({ performers }) => { const selectedEndpoint = stashConfig?.general.stashBoxes[selectedEndpointIndex]; + const selectedEndpointInput = useMemo( + () => ({ + endpoint: selectedEndpoint.endpoint, + index: selectedEndpointIndex, + }), + [selectedEndpoint, selectedEndpointIndex] + ); + + if (!config) return ; + async function batchAdd(performerInput: string) { if (performerInput && selectedEndpoint) { const inputs = performerInput @@ -742,70 +755,77 @@ export const PerformerTagger: React.FC = ({ performers }) => { } } - const showHideConfigId = showConfig - ? "actions.hide_configuration" - : "actions.show_configuration"; + if (selectedEndpointIndex === -1 || !selectedEndpoint) { + return ( +
      +

      + +

      +
      + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + + + ), + }} + /> +
      +
      + ); + } return ( <> - setShowManual(false)} - defaultActiveTab="Tagger.md" - /> {renderStatus()}
      - {selectedEndpointIndex !== -1 && selectedEndpoint ? ( - <> -
      - - -
      - - - - - ) : ( -
      -

      - -

      -
      - Please see{" "} - - el.scrollIntoView({ behavior: "smooth", block: "center" }) +
      +
      +
      + + setConfig({ ...config, selectedEndpoint: endpoint }) } - > - Settings. - -
      + /> +
      +
      +
      + setShowConfig(!showConfig)} + /> +
      +
      - )} + + + setConfig({ ...config, excludedPerformerFields: fields }) + } + fields={PERFORMER_FIELDS} + entityName="performers" + /> +
    + +
    ); diff --git a/ui/v2.5/src/components/Tagger/queries.ts b/ui/v2.5/src/components/Tagger/queries.ts index 734a6f736..1d98c2a76 100644 --- a/ui/v2.5/src/components/Tagger/queries.ts +++ b/ui/v2.5/src/components/Tagger/queries.ts @@ -97,3 +97,44 @@ export const useUpdateStudio = () => { return updateStudioHandler; }; + +export const useUpdateTag = () => { + const [updateTag] = GQL.useTagUpdateMutation({ + onError: (errors) => errors, + errorPolicy: "all", + }); + + const updateTagHandler = (input: GQL.TagUpdateInput) => + updateTag({ + variables: { + input, + }, + update: (store, updatedTag) => { + if (!updatedTag.data?.tagUpdate) return; + + updatedTag.data.tagUpdate.stash_ids.forEach((id) => { + store.writeQuery({ + query: GQL.FindTagsDocument, + variables: { + tag_filter: { + stash_id_endpoint: { + stash_id: id.stash_id, + endpoint: id.endpoint, + modifier: GQL.CriterionModifier.Equals, + }, + }, + }, + data: { + findTags: { + count: 1, + tags: [updatedTag.data!.tagUpdate!], + __typename: "FindTagsResultType", + }, + }, + }); + }); + }, + }); + + return updateTagHandler; +}; diff --git a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx index 76a67e306..a0ee46733 100755 --- a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx @@ -4,7 +4,6 @@ import { SceneQueue } from "src/models/sceneQueue"; import { Button, Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; -import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { OperationButton } from "src/components/Shared/OperationButton"; import { ISceneQueryResult, TaggerStateContext } from "../context"; @@ -13,8 +12,8 @@ import { TaggerScene } from "./TaggerScene"; import { SceneTaggerModals } from "./sceneTaggerModals"; import { SceneSearchResults } from "./StashSearchResult"; import { useConfigurationContext } from "src/hooks/Config"; -import { faCog } from "@fortawesome/free-solid-svg-icons"; import { useLightbox } from "src/hooks/Lightbox/hooks"; +import { ConfigButton } from "../TaggerConfig"; const Scene: React.FC<{ scene: GQL.SlimSceneDataFragment; @@ -154,16 +153,6 @@ export const Tagger: React.FC = ({ ); } - function renderConfigButton() { - return ( -
    - -
    - ); - } - const [spriteImage, setSpriteImage] = useState(null); const lightboxImage = useMemo( () => [{ paths: { thumbnail: spriteImage, image: spriteImage } }], @@ -293,7 +282,12 @@ export const Tagger: React.FC = ({ {maybeRenderShowHideUnmatchedButton()} {maybeRenderSubmitFingerprintsButton()} {renderFragmentScrapeButton()} - {renderConfigButton()} +
    + setShowConfig(!showConfig)} + /> +
    diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index dc0a616d6..add295c49 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -29,9 +29,9 @@ import { SceneTaggerModalsState } from "./sceneTaggerModals"; import PerformerResult from "./PerformerResult"; import StudioResult from "./StudioResult"; import { useInitialState } from "src/hooks/state"; -import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { compareScenesForSort } from "./utils"; +import { StashIDPill } from "src/components/Shared/StashID"; const getDurationIcon = (matchPercentage: number) => { if (matchPercentage > 65) @@ -201,7 +201,7 @@ const getFingerprintStatus = ( , + hash_type: , }} />
    @@ -325,15 +325,6 @@ const StashSearchResult: React.FC = ({ } }, [isActive, loading, stashScene, index, resolveScene, scene]); - const stashBoxBaseURL = currentSource?.sourceInput.stash_box_endpoint - ? getStashboxBase(currentSource.sourceInput.stash_box_endpoint) - : undefined; - const stashBoxURL = useMemo(() => { - if (stashBoxBaseURL) { - return `${stashBoxBaseURL}scenes/${scene.remote_site_id}`; - } - }, [scene, stashBoxBaseURL]); - const setExcludedField = (name: string, value: boolean) => setExcludedFields({ ...excludedFields, @@ -680,16 +671,20 @@ const StashSearchResult: React.FC = ({ }; const maybeRenderStashBoxID = () => { - if (scene.remote_site_id && stashBoxURL) { + if (scene.remote_site_id && currentSource?.sourceInput.stash_box_endpoint) { return (
    setExcludedField(fields.stash_ids, v)} > - - {scene.remote_site_id} - +
    ); diff --git a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx index a77025d57..1d5149b79 100644 --- a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import cx from "classnames"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; @@ -7,19 +7,16 @@ import * as GQL from "src/core/generated-graphql"; import { useFindStudio } from "src/core/StashService"; import { Icon } from "src/components/Shared/Icon"; import { ModalComponent } from "src/components/Shared/Modal"; -import { - faCheck, - faExternalLinkAlt, - faTimes, -} from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faTimes } from "@fortawesome/free-solid-svg-icons"; import { Button, Form } from "react-bootstrap"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { excludeFields } from "src/utils/data"; import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { StashIDPill } from "src/components/Shared/StashID"; interface IStudioDetailsProps { studio: GQL.ScrapedSceneStudioDataFragment; - link?: string; + endpoint?: string; excluded: Record; toggleField: (field: string) => void; isNew?: boolean; @@ -27,7 +24,7 @@ interface IStudioDetailsProps { const StudioDetails: React.FC = ({ studio, - link, + endpoint, excluded, toggleField, isNew = false, @@ -59,13 +56,15 @@ const StudioDetails: React.FC = ({ function maybeRenderField( id: string, text: string | null | undefined, - isSelectable: boolean = true + isSelectable: boolean = true, + messageId?: string ) { if (!text) return; + if (!messageId) messageId = id; return (
    -
    +
    {isSelectable && ( )} - : + :
    @@ -93,7 +92,7 @@ const StudioDetails: React.FC = ({ return (
    -
    +
    {!isNew && (
    -
    +
      {text.map((t, i) => (
    • @@ -123,15 +122,14 @@ const StudioDetails: React.FC = ({ } function maybeRenderStashBoxLink() { - if (!link) return; + const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; + if (!base || !studio.remote_site_id) return; return ( -
      - - - - -
      + ); } @@ -145,7 +143,12 @@ const StudioDetails: React.FC = ({ {maybeRenderField("details", studio.details)} {maybeRenderField("aliases", studio.aliases)} {maybeRenderField("tags", studio.tags?.map((t) => t.name).join(", "))} - {maybeRenderField("parent_studio", studio.parent?.name, false)} + {maybeRenderField( + "parent_id", + studio.parent?.name, + true, + "parent_studio" + )} {maybeRenderStashBoxLink()}
    @@ -207,6 +210,10 @@ const StudioModal: React.FC = ({ !!studio.parent ); + useEffect(() => { + setCreateParentStudio(!excluded.parent_id && !!studio.parent); + }, [excluded.parent_id, studio.parent]); + let sendParentStudio = true; // The parent studio exists, need to check if it has a Stash ID. const queryResult = useFindStudio(studio.parent?.stored_id ?? ""); @@ -303,30 +310,28 @@ const StudioModal: React.FC = ({ handleStudioCreate(studioData, parentData); } - const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; - const link = base ? `${base}studios/${studio.remote_site_id}` : undefined; - const parentLink = base - ? `${base}studios/${studio.parent?.remote_site_id}` - : undefined; - function maybeRenderParentStudio() { // There is no parent studio or it already has a Stash ID - if (!studio.parent || !sendParentStudio) { + if (!studio.parent || !sendParentStudio || excluded.parent_id) { return; } + // force create if there is no current parent studio and parent studio is not excluded + const mustCreateParent = !studio.parent.stored_id; + return (
    -
    + setCreateParentStudio(!createParentStudio)} /> -
    + {maybeRenderParentStudioDetails()}
    ); @@ -342,7 +347,7 @@ const StudioModal: React.FC = ({ studio={studio.parent} excluded={parentExcluded} toggleField={(field) => toggleParentField(field)} - link={parentLink} + endpoint={endpoint} isNew /> ); @@ -365,7 +370,7 @@ const StudioModal: React.FC = ({ studio={studio} excluded={excluded} toggleField={(field) => toggleField(field)} - link={link} + endpoint={endpoint} /> {maybeRenderParentStudio()} diff --git a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx index 5446257e5..5ad895fc2 100644 --- a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx @@ -11,7 +11,10 @@ import { StashIDPill } from "src/components/Shared/StashID"; import { PerformerLink, TagLink } from "src/components/Shared/TagLink"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { parsePath, prepareQueryString } from "src/components/Tagger/utils"; -import { ScenePreview } from "src/components/Scenes/SceneCard"; +import { + ScenePreview, + SceneSpecsOverlay, +} from "src/components/Scenes/SceneCard"; import { TaggerStateContext } from "../context"; import { faChevronDown, @@ -271,6 +274,7 @@ export const TaggerScene: React.FC> = ({ vttPath={scene.paths.vtt ?? undefined} onScrubberClick={onScrubberClick} /> + {maybeRenderSpriteIcon()}
    diff --git a/ui/v2.5/src/components/Tagger/studios/Config.tsx b/ui/v2.5/src/components/Tagger/studios/Config.tsx deleted file mode 100644 index ddfd17b1e..000000000 --- a/ui/v2.5/src/components/Tagger/studios/Config.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { Dispatch, useState } from "react"; -import { Badge, Button, Card, Collapse, Form } from "react-bootstrap"; -import { FormattedMessage } from "react-intl"; -import { useConfigurationContext } from "src/hooks/Config"; - -import { ITaggerConfig } from "../constants"; -import StudioFieldSelector from "./StudioFieldSelector"; - -interface IConfigProps { - show: boolean; - config: ITaggerConfig; - setConfig: Dispatch; -} - -const Config: React.FC = ({ show, config, setConfig }) => { - const { configuration: stashConfig } = useConfigurationContext(); - const [showExclusionModal, setShowExclusionModal] = useState(false); - - const excludedFields = config.excludedStudioFields ?? []; - - const handleInstanceSelect = (e: React.ChangeEvent) => { - const selectedEndpoint = e.currentTarget.value; - setConfig({ - ...config, - selectedEndpoint, - }); - }; - - const stashBoxes = stashConfig?.general.stashBoxes ?? []; - - const handleFieldSelect = (fields: string[]) => { - setConfig({ ...config, excludedStudioFields: fields }); - setShowExclusionModal(false); - }; - - return ( - <> - - -
    -

    - -

    -
    -
    - - - } - checked={config.createParentStudios} - onChange={(e: React.ChangeEvent) => - setConfig({ - ...config, - createParentStudios: e.currentTarget.checked, - }) - } - /> - - - - - -
    - -
    - - {excludedFields.length > 0 ? ( - excludedFields.map((f) => ( - - - - )) - ) : ( - - )} - - - - - -
    - - - - - - {!stashBoxes.length && ( - - )} - {stashConfig?.general.stashBoxes.map((i) => ( - - ))} - - -
    -
    -
    -
    - - - ); -}; - -export default Config; diff --git a/ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx b/ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx deleted file mode 100644 index 658f23510..000000000 --- a/ui/v2.5/src/components/Tagger/studios/StudioFieldSelector.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { faCheck, faList, faTimes } from "@fortawesome/free-solid-svg-icons"; -import React, { useState } from "react"; -import { Button, Row, Col } from "react-bootstrap"; -import { useIntl } from "react-intl"; - -import { ModalComponent } from "../../Shared/Modal"; -import { Icon } from "../../Shared/Icon"; -import { STUDIO_FIELDS } from "../constants"; - -interface IProps { - show: boolean; - excludedFields: string[]; - onSelect: (fields: string[]) => void; -} - -const StudioFieldSelect: React.FC = ({ - show, - excludedFields, - onSelect, -}) => { - const intl = useIntl(); - const [excluded, setExcluded] = useState>( - // filter out fields that aren't in STUDIO_FIELDS - excludedFields - .filter((field) => STUDIO_FIELDS.includes(field)) - .reduce((dict, field) => ({ ...dict, [field]: true }), {}) - ); - - const toggleField = (field: string) => - setExcluded({ - ...excluded, - [field]: !excluded[field], - }); - - const renderField = (field: string) => ( -
    - - {intl.formatMessage({ id: field })} - - ); - - return ( - - onSelect(Object.keys(excluded).filter((f) => excluded[f])), - }} - > -

    Select tagged fields

    -
    - These fields will be tagged by default. Click the button to toggle. -
    - {STUDIO_FIELDS.map((f) => renderField(f))} -
    - ); -}; - -export default StudioFieldSelect; diff --git a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx index ed9570431..968b66b57 100644 --- a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx +++ b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; @@ -6,7 +6,6 @@ import { HashLink } from "react-router-hash-link"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { ModalComponent } from "src/components/Shared/Modal"; import { stashBoxStudioQuery, useJobsSubscribe, @@ -16,20 +15,24 @@ import { useStudioCreate, evictQueries, } from "src/core/StashService"; -import { Manual } from "src/components/Help/Manual"; import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; -import StudioConfig from "./Config"; -import { ITaggerConfig } from "../constants"; +import TaggerConfig, { ConfigButton } from "../TaggerConfig"; +import { ITaggerConfig, STUDIO_FIELDS } from "../constants"; import StudioModal from "../scenes/StudioModal"; import { useUpdateStudio } from "../queries"; import { apolloError } from "src/utils"; -import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; +import { faTags } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { mergeStudioStashIDs } from "../utils"; import { separateNamesAndStashIds } from "src/utils/stashIds"; import { useTaggerConfig } from "../config"; +import { + BatchUpdateModal, + BatchAddModal, +} from "src/components/Shared/BatchModals"; +import { StashBoxSelectorField } from "../StashBoxSelector"; type JobFragment = Pick< GQL.Job, @@ -38,232 +41,6 @@ type JobFragment = Pick< const CLASSNAME = "StudioTagger"; -interface IStudioBatchUpdateModal { - studios: GQL.StudioDataFragment[]; - isIdle: boolean; - selectedEndpoint: { endpoint: string; index: number }; - onBatchUpdate: (queryAll: boolean, refresh: boolean) => void; - batchAddParents: boolean; - setBatchAddParents: (addParents: boolean) => void; - close: () => void; -} - -const StudioBatchUpdateModal: React.FC = ({ - studios, - isIdle, - selectedEndpoint, - onBatchUpdate, - batchAddParents, - setBatchAddParents, - close, -}) => { - const intl = useIntl(); - - const [queryAll, setQueryAll] = useState(false); - - const [refresh, setRefresh] = useState(false); - const { data: allStudios } = GQL.useFindStudiosQuery({ - variables: { - studio_filter: { - stash_id_endpoint: { - endpoint: selectedEndpoint.endpoint, - modifier: refresh - ? GQL.CriterionModifier.NotNull - : GQL.CriterionModifier.IsNull, - }, - }, - filter: { - per_page: 0, - }, - }, - }); - - const studioCount = useMemo(() => { - // get all stash ids for the selected endpoint - const filteredStashIDs = studios.map((p) => - p.stash_ids.filter((s) => s.endpoint === selectedEndpoint.endpoint) - ); - - return queryAll - ? allStudios?.findStudios.count - : filteredStashIDs.filter((s) => - // if refresh, then we filter out the studios without a stash id - // otherwise, we want untagged studios, filtering out those with a stash id - refresh ? s.length > 0 : s.length === 0 - ).length; - }, [queryAll, refresh, studios, allStudios, selectedEndpoint.endpoint]); - - return ( - onBatchUpdate(queryAll, refresh), - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - -
    - -
    -
    - } - checked={!queryAll} - onChange={() => setQueryAll(false)} - /> - setQueryAll(true)} - /> -
    - - -
    - -
    -
    - setRefresh(false)} - /> - - - - setRefresh(true)} - /> - - - -
    - setBatchAddParents(!batchAddParents)} - /> -
    -
    - - - -
    - ); -}; - -interface IStudioBatchAddModal { - isIdle: boolean; - onBatchAdd: (input: string) => void; - batchAddParents: boolean; - setBatchAddParents: (addParents: boolean) => void; - close: () => void; -} - -const StudioBatchAddModal: React.FC = ({ - isIdle, - onBatchAdd, - batchAddParents, - setBatchAddParents, - close, -}) => { - const intl = useIntl(); - - const studioInput = useRef(null); - - return ( - { - if (studioInput.current) { - onBatchAdd(studioInput.current.value); - } else { - close(); - } - }, - }} - cancel={{ - text: intl.formatMessage({ id: "actions.cancel" }), - variant: "danger", - onClick: () => close(), - }} - disabled={!isIdle} - > - - - - -
    - setBatchAddParents(!batchAddParents)} - /> -
    -
    - ); -}; - interface IStudioTaggerListProps { studios: GQL.StudioDataFragment[]; selectedEndpoint: { endpoint: string; index: number }; @@ -305,6 +82,24 @@ const StudioTaggerList: React.FC = ({ config.createParentStudios || false ); + const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false); + const { data: allStudios } = GQL.useFindStudiosQuery({ + skip: !showBatchUpdate, + variables: { + studio_filter: { + stash_id_endpoint: { + endpoint: selectedEndpoint.endpoint, + modifier: batchUpdateRefresh + ? GQL.CriterionModifier.NotNull + : GQL.CriterionModifier.IsNull, + }, + }, + filter: { + per_page: 0, + }, + }, + }); + const [error, setError] = useState< Record >({}); @@ -386,6 +181,13 @@ const StudioTaggerList: React.FC = ({ }); }; + // clear tagged studios when source is changed + useEffect(() => { + setTaggedStudios({}); + setSearchResults({}); + setSearchErrors({}); + }, [selectedEndpoint]); + const [createStudio] = useStudioCreate(); const updateStudio = useUpdateStudio(); @@ -590,20 +392,6 @@ const StudioTaggerList: React.FC = ({ return (
    - {modalStudio && ( - setModalStudio(undefined)} - modalVisible={modalStudio.stored_id === studio.id} - studio={modalStudio} - handleStudioCreate={handleStudioUpdate} - excludedStudioFields={config.excludedStudioFields} - icon={faTags} - header={intl.formatMessage({ - id: "studio_tagger.update_studio", - })} - endpoint={selectedEndpoint.endpoint} - /> - )}
    @@ -630,24 +418,45 @@ const StudioTaggerList: React.FC = ({ return ( {showBatchUpdate && ( - setShowBatchUpdate(false)} isIdle={isIdle} selectedEndpoint={selectedEndpoint} - studios={studios} + entities={studios} + allCount={allStudios?.findStudios.count} onBatchUpdate={handleBatchUpdate} + onRefreshChange={setBatchUpdateRefresh} batchAddParents={batchAddParents} setBatchAddParents={setBatchAddParents} + localePrefix="studio_tagger" + entityName="studio" + countVariableName="studio_count" /> )} {showBatchAdd && ( - setShowBatchAdd(false)} isIdle={isIdle} onBatchAdd={handleBatchAdd} batchAddParents={batchAddParents} setBatchAddParents={setBatchAddParents} + localePrefix="studio_tagger" + entityName="studio" + /> + )} + {modalStudio && ( + setModalStudio(undefined)} + modalVisible={!!modalStudio.stored_id} + studio={modalStudio} + handleStudioCreate={handleStudioUpdate} + excludedStudioFields={config.excludedStudioFields} + icon={faTags} + header={intl.formatMessage({ + id: "studio_tagger.update_studio", + })} + endpoint={selectedEndpoint.endpoint} /> )}
    @@ -669,11 +478,9 @@ interface ITaggerProps { export const StudioTagger: React.FC = ({ studios }) => { const jobsSubscribe = useJobsSubscribe(); - const intl = useIntl(); const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); - const [showManual, setShowManual] = useState(false); const [batchJobID, setBatchJobID] = useState(); const [batchJob, setBatchJob] = useState(); @@ -701,8 +508,6 @@ export const StudioTagger: React.FC = ({ studios }) => { } }, [jobsSubscribe, batchJobID]); - if (!config) return ; - const savedEndpointIndex = stashConfig?.general.stashBoxes.findIndex( (s) => s.endpoint === config.selectedEndpoint @@ -714,6 +519,16 @@ export const StudioTagger: React.FC = ({ studios }) => { const selectedEndpoint = stashConfig?.general.stashBoxes[selectedEndpointIndex]; + const selectedEndpointInput = useMemo( + () => ({ + endpoint: selectedEndpoint.endpoint, + index: selectedEndpointIndex, + }), + [selectedEndpoint, selectedEndpointIndex] + ); + + if (!config) return ; + async function batchAdd(studioInput: string, createParent: boolean) { if (studioInput && selectedEndpoint) { const inputs = studioInput @@ -796,70 +611,99 @@ export const StudioTagger: React.FC = ({ studios }) => { } } - const showHideConfigId = showConfig - ? "actions.hide_configuration" - : "actions.show_configuration"; + if (selectedEndpointIndex === -1 || !selectedEndpoint) { + return ( +
    +

    + +

    +
    + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + + + ), + }} + /> +
    +
    + ); + } return ( <> - setShowManual(false)} - defaultActiveTab="Tagger.md" - /> {renderStatus()}
    - {selectedEndpointIndex !== -1 && selectedEndpoint ? ( - <> -
    - - -
    - - - - - ) : ( -
    -

    - -

    -
    - Please see{" "} - - el.scrollIntoView({ behavior: "smooth", block: "center" }) +
    +
    +
    + + setConfig({ ...config, selectedEndpoint: endpoint }) } - > - Settings. - -
    + /> +
    +
    +
    + setShowConfig(!showConfig)} + /> +
    +
    - )} + + + setConfig({ ...config, excludedStudioFields: fields }) + } + fields={STUDIO_FIELDS} + entityName="studios" + extraConfig={ + + + } + checked={config.createParentStudios} + onChange={(e: React.ChangeEvent) => + setConfig({ + ...config, + createParentStudios: e.currentTarget.checked, + }) + } + /> + + + + + } + /> +
    + +
    ); diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 8861d0043..0e9db45a6 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -8,6 +8,11 @@ .scene-card { position: relative; + + .scene-specs-overlay { + bottom: 5px; + right: 5px; + } } .scene-card-preview { @@ -46,6 +51,10 @@ flex-direction: column; overflow-wrap: anywhere; width: 100%; + + .optional-field-content { + min-width: 0; + } } .original-scene-details { @@ -126,6 +135,24 @@ font-weight: 500; } +.create-modal-field { + margin-bottom: 5px; + + .btn { + margin-right: 5px; + } + + .fa-icon { + width: 12px; + } +} + +.create-modal-value ul { + font-size: 0.8em; + list-style-type: none; + padding-inline-start: 0; +} + .performer-create-modal { font-size: 1.2rem; max-width: 800px; @@ -154,24 +181,6 @@ .LoadingIndicator { height: 100%; } - - &-field { - margin-bottom: 5px; - - .btn { - margin-right: 5px; - } - - .fa-icon { - width: 12px; - } - } - - &-value ul { - font-size: 0.8em; - list-style-type: none; - padding-inline-start: 0; - } } .PerformerTagger { @@ -241,7 +250,8 @@ } } -.studio-create-modal { +.studio-create-modal, +.tag-create-modal { font-size: 1.2rem; max-width: 800px; @@ -269,20 +279,17 @@ height: 100%; } - &-field { - margin-bottom: 5px; + .form-check { + font-size: 1rem; + } - .btn { - margin-right: 5px; - } - - .fa-icon { - width: 12px; - } + p.lead { + margin-top: 1rem; } } -.StudioTagger { +.StudioTagger, +.TagTagger { display: flex; flex-wrap: wrap; justify-content: center; @@ -296,7 +303,8 @@ } } - &-studio { + &-studio, + &-tag { background-color: #495b68; border-radius: 3px; display: flex; @@ -304,7 +312,8 @@ max-width: 100%; padding: 1rem; - .studio-card { + .studio-card, + .tag-card { box-shadow: none; flex-shrink: 0; margin: 0; @@ -337,7 +346,8 @@ vertical-align: bottom; } - &-studio-search { + &-studio-search, + &-tag-search { display: flex; flex-wrap: wrap; diff --git a/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx new file mode 100644 index 000000000..55b86c931 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/tags/StashSearchResult.tsx @@ -0,0 +1,146 @@ +import React, { useState } from "react"; +import { Button } from "react-bootstrap"; + +import * as GQL from "src/core/generated-graphql"; +import { useUpdateTag } from "../queries"; +import TagModal from "./TagModal"; +import { faTags } from "@fortawesome/free-solid-svg-icons"; +import { useIntl } from "react-intl"; +import { mergeTagStashIDs } from "../utils"; +import { useTagCreate } from "src/core/StashService"; +import { apolloError } from "src/utils"; + +interface IStashSearchResultProps { + tag: GQL.TagListDataFragment; + stashboxTags: GQL.ScrapedSceneTagDataFragment[]; + endpoint: string; + onTagTagged: ( + tag: Pick & + Partial> + ) => void; + excludedTagFields: string[]; +} + +const StashSearchResult: React.FC = ({ + tag, + stashboxTags, + onTagTagged, + excludedTagFields, + endpoint, +}) => { + const intl = useIntl(); + + const [modalTag, setModalTag] = useState(); + const [saveState, setSaveState] = useState(""); + const [error, setError] = useState<{ message?: string; details?: string }>( + {} + ); + + const [createTag] = useTagCreate(); + const updateTag = useUpdateTag(); + + function handleSaveError(name: string, message: string) { + setError({ + message: intl.formatMessage( + { id: "tag_tagger.failed_to_save_tag" }, + { tag: name } + ), + details: + message === "UNIQUE constraint failed: tags.name" + ? intl.formatMessage({ + id: "tag_tagger.name_already_exists", + }) + : message, + }); + } + + const handleSave = async ( + input: GQL.TagCreateInput, + parentInput?: GQL.TagCreateInput + ) => { + setError({}); + setModalTag(undefined); + + if (parentInput) { + setSaveState("Saving parent tag"); + + try { + const parentRes = await createTag({ + variables: { input: parentInput }, + }); + input.parent_ids = [parentRes.data?.tagCreate?.id].filter( + Boolean + ) as string[]; + } catch (e) { + handleSaveError(parentInput.name, apolloError(e)); + setSaveState(""); + return; + } + } + + setSaveState("Saving tag"); + const updateData: GQL.TagUpdateInput = { + ...input, + id: tag.id, + }; + + updateData.stash_ids = await mergeTagStashIDs( + tag.id, + input.stash_ids ?? [] + ); + + const res = await updateTag(updateData); + + if (!res?.data?.tagUpdate) { + handleSaveError(input.name ?? tag.name, res?.errors?.[0]?.message ?? ""); + } else { + onTagTagged(tag); + } + setSaveState(""); + }; + + const tags = stashboxTags.map((p) => ( + + )); + + return ( + <> + {modalTag && ( + setModalTag(undefined)} + modalVisible={modalTag !== undefined} + tag={modalTag} + onSave={handleSave} + icon={faTags} + header="Update Tag" + excludedTagFields={excludedTagFields} + endpoint={endpoint} + /> + )} +
    {tags}
    +
    + {error.message && ( +
    + + Error: + {error.message} + +
    {error.details}
    +
    + )} + {saveState && ( + {saveState} + )} +
    + + ); +}; + +export default StashSearchResult; diff --git a/ui/v2.5/src/components/Tagger/tags/TagModal.tsx b/ui/v2.5/src/components/Tagger/tags/TagModal.tsx new file mode 100644 index 000000000..ba0b2f2d2 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/tags/TagModal.tsx @@ -0,0 +1,299 @@ +import React, { useEffect, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; + +import * as GQL from "src/core/generated-graphql"; +import { Icon } from "src/components/Shared/Icon"; +import { ModalComponent } from "src/components/Shared/Modal"; +import { + faCheck, + faExclamationTriangle, + faTimes, +} from "@fortawesome/free-solid-svg-icons"; +import { Button, Form } from "react-bootstrap"; +import { TruncatedText } from "src/components/Shared/TruncatedText"; +import { excludeFields } from "src/utils/data"; +import { StashIDPill } from "src/components/Shared/StashID"; + +interface ITagModalProps { + tag: GQL.ScrapedTag; + modalVisible: boolean; + closeModal: () => void; + onSave: (input: GQL.TagCreateInput, parentInput?: GQL.TagCreateInput) => void; + excludedTagFields?: string[]; + header: string; + icon: IconDefinition; + endpoint?: string; +} + +const TagModal: React.FC = ({ + modalVisible, + tag, + onSave, + closeModal, + excludedTagFields = [], + header, + icon, + endpoint, +}) => { + const intl = useIntl(); + + const [excluded, setExcluded] = useState>( + excludedTagFields.reduce((dict, field) => ({ ...dict, [field]: true }), {}) + ); + const toggleField = (name: string) => + setExcluded({ + ...excluded, + [name]: !excluded[name], + }); + + const [createParentTag, setCreateParentTag] = useState( + !!tag.parent && !tag.parent.stored_id + ); + + useEffect(() => { + setCreateParentTag(!excluded.parent_ids && !!tag.parent); + }, [excluded.parent_ids, tag.parent]); + + // Check if a tag with the parent name already exists locally. + // Categories don't have stash IDs, so stored_id may be null even when the + // parent tag has already been created (e.g. by tagging a sibling tag first). + const parentNameQuery = GQL.useFindTagsQuery({ + skip: !tag.parent || !!tag.parent.stored_id, + variables: { + tag_filter: { + name: { + value: tag.parent?.name ?? "", + modifier: GQL.CriterionModifier.Equals, + }, + }, + filter: { per_page: 1 }, + }, + }); + const existingParentId = parentNameQuery.data?.findTags.tags[0]?.id; + + // If the parent already exists locally, don't offer to create it + const sendParentTag = !existingParentId; + + const [parentExcluded, setParentExcluded] = useState>( + excludedTagFields.reduce((dict, field) => ({ ...dict, [field]: true }), {}) + ); + + const toggleParentField = (name: string) => + setParentExcluded({ + ...parentExcluded, + [name]: !parentExcluded[name], + }); + + function maybeRenderField( + id: string, + text: string | null | undefined, + isSelectable: boolean = true, + messageId?: string + ) { + if (!text) return; + if (!messageId) messageId = id; + + return ( +
    +
    + {isSelectable && ( + + )} + + : + +
    + +
    + ); + } + + function maybeRenderStashBoxLink() { + const base = endpoint?.match(/https?:\/\/.*?\//)?.[0]; + if (!base || !tag.remote_site_id) return; + + return ( + + ); + } + + function maybeRenderParentField( + id: string, + text: string | null | undefined, + isSelectable: boolean = true + ) { + if (!text) return; + + return ( +
    +
    + {isSelectable && ( + + )} + + : + +
    + +
    + ); + } + + function maybeRenderParentTagDetails() { + if (!createParentTag || !tag.parent) { + return; + } + + return ( +
    + {maybeRenderParentField("name", tag.parent.name, false)} + {maybeRenderParentField("description", tag.parent.description)} +
    + ); + } + + function maybeRenderParentTag() { + // No parent tag, or parent already exists locally + if ( + !tag.parent || + tag.parent.stored_id || + !sendParentTag || + excluded.parent_ids + ) { + return; + } + + // force create if there is no current parent tag and parent tag is not excluded + const mustCreateParent = true; + + // warn the user if the parent tag does not have a remote_site_id, + // which means it won't be automatically linked to the source tag + const missingStashIDWarning = !tag.parent.remote_site_id && ( +

    + + +

    + ); + + return ( +
    +
    + setCreateParentTag(!createParentTag)} + /> +
    + {maybeRenderParentTagDetails()} + {missingStashIDWarning} +
    + ); + } + + function handleSave() { + if (!tag.name) { + throw new Error("tag name must be set"); + } + + const parentId = tag.parent?.stored_id ?? existingParentId; + + const tagData: GQL.TagCreateInput = { + name: tag.name, + description: tag.description ?? undefined, + aliases: tag.alias_list?.filter((a) => a) ?? undefined, + parent_ids: parentId ? [parentId] : undefined, + }; + + // stashid handling code + const remoteSiteID = tag.remote_site_id; + if (remoteSiteID && endpoint) { + tagData.stash_ids = [ + { + endpoint, + stash_id: remoteSiteID, + updated_at: new Date().toISOString(), + }, + ]; + } + + // handle exclusions + excludeFields(tagData, excluded); + + let parentData: GQL.TagCreateInput | undefined = undefined; + + // Categories don't have stash IDs, so we only create new parent tags + if ( + createParentTag && + sendParentTag && + tag.parent && + !tag.parent.stored_id + ) { + parentData = { + name: tag.parent.name, + description: tag.parent.description ?? undefined, + }; + + // handle exclusions + // Can't exclude parent tag name when creating a new one + parentExcluded.name = false; + excludeFields(parentData, parentExcluded); + } + + onSave(tagData, parentData); + } + + return ( + closeModal(), variant: "secondary" }} + onHide={() => closeModal()} + dialogClassName="tag-create-modal" + icon={icon} + header={header} + > +
    +
    +
    + {maybeRenderField("name", tag.name)} + {maybeRenderField("description", tag.description)} + {maybeRenderField("aliases", tag.alias_list?.join(", "))} + {maybeRenderField( + "parent_ids", + tag.parent?.name, + true, + "parent_tags" + )} + {maybeRenderStashBoxLink()} +
    +
    +
    + {maybeRenderParentTag()} +
    + ); +}; + +export default TagModal; diff --git a/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx new file mode 100644 index 000000000..cb2d20590 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/tags/TagTagger.tsx @@ -0,0 +1,706 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { Link } from "react-router-dom"; +import { HashLink } from "react-router-hash-link"; + +import * as GQL from "src/core/generated-graphql"; +import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; +import { + stashBoxTagQuery, + useJobsSubscribe, + mutateStashBoxBatchTagTag, + getClient, + useTagCreate, +} from "src/core/StashService"; +import { useConfigurationContext } from "src/hooks/Config"; + +import StashSearchResult from "./StashSearchResult"; +import TaggerConfig, { ConfigButton } from "../TaggerConfig"; +import { ITaggerConfig, TAG_FIELDS } from "../constants"; +import { useUpdateTag } from "../queries"; +import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { mergeTagStashIDs } from "../utils"; +import { separateNamesAndStashIds } from "src/utils/stashIds"; +import { useTaggerConfig } from "../config"; +import { + BatchUpdateModal, + BatchAddModal, +} from "src/components/Shared/BatchModals"; +import { StashBoxSelectorField } from "../StashBoxSelector"; +import { apolloError } from "src/utils"; +import TagModal from "./TagModal"; +import { faTags } from "@fortawesome/free-solid-svg-icons"; +import { uniq } from "lodash-es"; + +type JobFragment = Pick< + GQL.Job, + "id" | "status" | "subTasks" | "description" | "progress" +>; + +const CLASSNAME = "TagTagger"; + +interface ITagTaggerListProps { + tags: GQL.TagListDataFragment[]; + selectedEndpoint: { endpoint: string; index: number }; + isIdle: boolean; + config: ITaggerConfig; + onBatchAdd: (tagInput: string, createParent: boolean) => void; + onBatchUpdate: ( + ids: string[] | undefined, + refresh: boolean, + createParent: boolean + ) => void; +} + +const TagTaggerList: React.FC = ({ + tags, + selectedEndpoint, + isIdle, + config, + onBatchAdd, + onBatchUpdate, +}) => { + const intl = useIntl(); + + const [loading, setLoading] = useState(false); + + const [searchResults, setSearchResults] = useState< + Record + >({}); + const [searchErrors, setSearchErrors] = useState< + Record + >({}); + const [taggedTags, setTaggedTags] = useState< + Record> + >({}); + const [queries, setQueries] = useState>({}); + + const [showBatchAdd, setShowBatchAdd] = useState(false); + const [showBatchUpdate, setShowBatchUpdate] = useState(false); + const [batchAddParents, setBatchAddParents] = useState( + config.createParentTags || false + ); + + const [batchUpdateRefresh, setBatchUpdateRefresh] = useState(false); + const { data: allTags } = GQL.useFindTagsQuery({ + skip: !showBatchUpdate, + variables: { + tag_filter: { + stash_id_endpoint: { + endpoint: selectedEndpoint.endpoint, + modifier: batchUpdateRefresh + ? GQL.CriterionModifier.NotNull + : GQL.CriterionModifier.IsNull, + }, + }, + filter: { + per_page: 0, + }, + }, + }); + + const [modalTag, setModalTag] = useState< + | { + existingTag: GQL.TagListDataFragment; + scrapedTag: GQL.ScrapedTag; + } + | undefined + >(); + const [error, setError] = useState< + Record + >({}); + const [loadingUpdate, setLoadingUpdate] = useState(); + + const doBoxSearch = (tagID: string, searchVal: string) => { + stashBoxTagQuery(searchVal, selectedEndpoint.endpoint) + .then((queryData) => { + const s = queryData.data?.scrapeSingleTag ?? []; + setSearchResults({ + ...searchResults, + [tagID]: s, + }); + setSearchErrors({ + ...searchErrors, + [tagID]: undefined, + }); + setLoading(false); + }) + .catch(() => { + setLoading(false); + const { [tagID]: unassign, ...results } = searchResults; + setSearchResults(results); + setSearchErrors({ + ...searchErrors, + [tagID]: intl.formatMessage({ + id: "tag_tagger.network_error", + }), + }); + }); + + setLoading(true); + }; + + const [createTag] = useTagCreate(); + const updateTag = useUpdateTag(); + + const doBoxUpdate = ( + tag: GQL.TagListDataFragment, + stashID: string, + endpoint: string + ) => { + setLoadingUpdate(stashID); + setError({ + ...error, + [tag.id]: undefined, + }); + stashBoxTagQuery(stashID, endpoint) + .then(async (queryData) => { + const data = queryData.data?.scrapeSingleTag ?? []; + if (data.length > 0) { + setModalTag({ + scrapedTag: { + ...data[0], + stored_id: tag.id, + }, + existingTag: tag, + }); + } + }) + .finally(() => setLoadingUpdate(undefined)); + }; + + async function handleBatchAdd(input: string) { + onBatchAdd(input, batchAddParents); + setShowBatchAdd(false); + } + + const handleBatchUpdate = (queryAll: boolean, refresh: boolean) => { + onBatchUpdate( + !queryAll ? tags.map((t) => t.id) : undefined, + refresh, + batchAddParents + ); + setShowBatchUpdate(false); + }; + + function handleSaveError(tagID: string, name: string, message: string) { + setError({ + ...error, + [tagID]: { + message: intl.formatMessage( + { id: "tag_tagger.failed_to_save_tag" }, + { tag: name } + ), + details: + message === "UNIQUE constraint failed: tags.name" + ? intl.formatMessage({ + id: "tag_tagger.name_already_exists", + }) + : message, + }, + }); + } + + const handleTagUpdate = async ( + input: GQL.TagCreateInput, + parentInput?: GQL.TagCreateInput + ) => { + const { existingTag, scrapedTag: tag } = modalTag!; + const tagID = existingTag.id; + setModalTag(undefined); + + if (tagID) { + if (parentInput) { + try { + // cannot update parent tags, since there may be many + if (!!input.parent_ids?.length) { + // ignore + } else { + const parentRes = await createTag({ + variables: { input: parentInput }, + }); + const parentID = parentRes.data?.tagCreate?.id; + if (parentID) { + // merge parent ids below + input.parent_ids = [parentID]; + } + } + } catch (e) { + handleSaveError(tagID, parentInput.name, apolloError(e)); + } + } + + // always merge parent ids if included + if (input.parent_ids) { + input.parent_ids = uniq( + existingTag.parents.map((p) => p.id).concat(input.parent_ids) + ); + } + + const updateData: GQL.TagUpdateInput = { + ...input, + id: tagID, + }; + updateData.stash_ids = await mergeTagStashIDs( + tagID, + input.stash_ids ?? [] + ); + + const res = await updateTag(updateData); + if (!res?.data?.tagUpdate) + handleSaveError(tagID, tag.name ?? "", res?.errors?.[0]?.message ?? ""); + } + }; + + const handleTaggedTag = ( + tag: Pick & + Partial> + ) => { + setTaggedTags({ + ...taggedTags, + [tag.id]: tag, + }); + }; + + // clear tagged tags when source is changed + useEffect(() => { + setTaggedTags({}); + setSearchResults({}); + setSearchErrors({}); + }, [selectedEndpoint]); + + const renderTags = () => + tags.map((tag) => { + const isTagged = taggedTags[tag.id]; + + const stashID = tag.stash_ids.find((s) => { + return s.endpoint === selectedEndpoint.endpoint; + }); + + let mainContent; + if (!isTagged && stashID !== undefined) { + mainContent = ( +
    +
    + +
    +
    + ); + } else if (!isTagged && !stashID) { + mainContent = ( + + + setQueries({ + ...queries, + [tag.id]: e.currentTarget.value, + }) + } + onKeyPress={(e: React.KeyboardEvent) => + e.key === "Enter" && + doBoxSearch(tag.id, queries[tag.id] ?? tag.name ?? "") + } + /> + + + + + ); + } else if (isTagged) { + mainContent = ( +
    +
    + +
    +
    + ); + } + + let subContent; + if (stashID !== undefined) { + const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? ( + + {stashID.stash_id} + + ) : ( +
    {stashID.stash_id}
    + ); + + subContent = ( +
    + + {link} + + + + + {error[tag.id] && ( +
    + + Error: + {error[tag.id]?.message} + +
    {error[tag.id]?.details}
    +
    + )} +
    + ); + } else if (searchErrors[tag.id]) { + subContent = ( +
    + {searchErrors[tag.id]} +
    + ); + } else if (searchResults[tag.id]?.length === 0) { + subContent = ( +
    + +
    + ); + } + + let searchResult; + if (searchResults[tag.id]?.length > 0 && !isTagged) { + searchResult = ( + + ); + } + + return ( +
    +
    +
    +
    + + + +
    +
    + +

    {tag.name}

    + + {mainContent} +
    {subContent}
    + {searchResult} +
    +
    +
    + ); + }); + + return ( + + {showBatchUpdate && ( + setShowBatchUpdate(false)} + isIdle={isIdle} + selectedEndpoint={selectedEndpoint} + entities={tags} + allCount={allTags?.findTags.count} + onBatchUpdate={handleBatchUpdate} + onRefreshChange={setBatchUpdateRefresh} + batchAddParents={batchAddParents} + setBatchAddParents={setBatchAddParents} + localePrefix="tag_tagger" + entityName="tag" + countVariableName="tag_count" + /> + )} + + {showBatchAdd && ( + setShowBatchAdd(false)} + isIdle={isIdle} + onBatchAdd={handleBatchAdd} + batchAddParents={batchAddParents} + setBatchAddParents={setBatchAddParents} + localePrefix="tag_tagger" + entityName="tag" + /> + )} + + {modalTag && ( + setModalTag(undefined)} + modalVisible={modalTag !== undefined} + tag={modalTag.scrapedTag} + onSave={handleTagUpdate} + icon={faTags} + header="Update Tag" + excludedTagFields={config.excludedTagFields} + endpoint={selectedEndpoint.endpoint} + /> + )} +
    + + +
    +
    {renderTags()}
    +
    + ); +}; + +interface ITaggerProps { + tags: GQL.TagListDataFragment[]; +} + +export const TagTagger: React.FC = ({ tags }) => { + const jobsSubscribe = useJobsSubscribe(); + const { configuration: stashConfig } = useConfigurationContext(); + const { config, setConfig } = useTaggerConfig(); + const [showConfig, setShowConfig] = useState(false); + + const [batchJobID, setBatchJobID] = useState(); + const [batchJob, setBatchJob] = useState(); + + useEffect(() => { + if (!jobsSubscribe.data) { + return; + } + + const event = jobsSubscribe.data.jobsSubscribe; + if (event.job.id !== batchJobID) { + return; + } + + if (event.type !== GQL.JobStatusUpdateType.Remove) { + setBatchJob(event.job); + } else { + setBatchJob(undefined); + setBatchJobID(undefined); + + const ac = getClient(); + ac.cache.evict({ fieldName: "findTags" }); + ac.cache.gc(); + } + }, [jobsSubscribe, batchJobID]); + + const savedEndpointIndex = + stashConfig?.general.stashBoxes.findIndex( + (s) => s.endpoint === config.selectedEndpoint + ) ?? -1; + const selectedEndpointIndex = + savedEndpointIndex === -1 && stashConfig?.general.stashBoxes.length + ? 0 + : savedEndpointIndex; + const selectedEndpoint = + stashConfig?.general.stashBoxes[selectedEndpointIndex]; + + const selectedEndpointInput = useMemo( + () => ({ + endpoint: selectedEndpoint.endpoint, + index: selectedEndpointIndex, + }), + [selectedEndpoint, selectedEndpointIndex] + ); + + if (!config) return ; + + async function batchAdd(tagInput: string, createParent: boolean) { + if (tagInput && selectedEndpoint) { + const inputs = tagInput + .split(",") + .map((n) => n.trim()) + .filter((n) => n.length > 0); + + const { names, stashIds } = separateNamesAndStashIds(inputs); + + if (names.length > 0 || stashIds.length > 0) { + const ret = await mutateStashBoxBatchTagTag({ + names: names.length > 0 ? names : undefined, + stash_ids: stashIds.length > 0 ? stashIds : undefined, + endpoint: selectedEndpointIndex, + refresh: false, + createParent: createParent, + exclude_fields: config?.excludedTagFields ?? [], + }); + + setBatchJobID(ret.data?.stashBoxBatchTagTag); + } + } + } + + async function batchUpdate( + ids: string[] | undefined, + refresh: boolean, + createParent: boolean + ) { + if (selectedEndpoint) { + const ret = await mutateStashBoxBatchTagTag({ + ids: ids, + endpoint: selectedEndpointIndex, + refresh, + createParent: createParent, + exclude_fields: config?.excludedTagFields ?? [], + }); + + setBatchJobID(ret.data?.stashBoxBatchTagTag); + } + } + + function renderStatus() { + if (batchJob) { + const progress = + batchJob.progress !== undefined && batchJob.progress !== null + ? batchJob.progress * 100 + : undefined; + return ( + +
    + +
    + {progress !== undefined && ( + + )} +
    + ); + } + + if (batchJobID !== undefined) { + return ( + +
    + +
    +
    + ); + } + } + + if (selectedEndpointIndex === -1 || !selectedEndpoint) { + return ( +
    +

    + +

    +
    + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + + + ), + }} + /> +
    +
    + ); + } + + return ( + <> + {renderStatus()} +
    +
    +
    +
    + + setConfig({ ...config, selectedEndpoint: endpoint }) + } + /> +
    +
    +
    + setShowConfig(!showConfig)} + /> +
    +
    +
    + + + setConfig({ ...config, excludedTagFields: fields }) + } + fields={TAG_FIELDS} + entityName="tags" + extraConfig={ + + + } + checked={config.createParentTags} + onChange={(e: React.ChangeEvent) => + setConfig({ + ...config, + createParentTags: e.currentTarget.checked, + }) + } + /> + + + + + } + /> +
    + + +
    + + ); +}; diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts index 8c1cf54e5..cddad33d9 100644 --- a/ui/v2.5/src/components/Tagger/utils.ts +++ b/ui/v2.5/src/components/Tagger/utils.ts @@ -1,6 +1,6 @@ import * as GQL from "src/core/generated-graphql"; import { ParseMode } from "./constants"; -import { queryFindStudio } from "src/core/StashService"; +import { queryFindStudio, queryFindTag } from "src/core/StashService"; import { mergeStashIDs } from "src/utils/stashbox"; const months = [ @@ -173,14 +173,32 @@ export const parsePath = (filePath: string) => { return { paths, file, ext }; }; -export async function mergeStudioStashIDs( +async function mergeEntityStashIDs( + fetchExisting: (id: string) => Promise, id: string, newStashIDs: GQL.StashIdInput[] ) { - const existing = await queryFindStudio(id); - if (existing?.data?.findStudio?.stash_ids) { - return mergeStashIDs(existing.data.findStudio.stash_ids, newStashIDs); + const existing = await fetchExisting(id); + if (existing) { + return mergeStashIDs(existing, newStashIDs); } - return newStashIDs; } + +export const mergeStudioStashIDs = ( + id: string, + newStashIDs: GQL.StashIdInput[] +) => + mergeEntityStashIDs( + async (studioId) => + (await queryFindStudio(studioId))?.data?.findStudio?.stash_ids, + id, + newStashIDs + ); + +export const mergeTagStashIDs = (id: string, newStashIDs: GQL.StashIdInput[]) => + mergeEntityStashIDs( + async (tagId) => (await queryFindTag(tagId))?.data?.findTag?.stash_ids, + id, + newStashIDs + ); diff --git a/ui/v2.5/src/components/Tags/EditTagsDialog.tsx b/ui/v2.5/src/components/Tags/EditTagsDialog.tsx index 896016098..9b3dc0660 100644 --- a/ui/v2.5/src/components/Tags/EditTagsDialog.tsx +++ b/ui/v2.5/src/components/Tags/EditTagsDialog.tsx @@ -11,7 +11,7 @@ import { getAggregateStateObject, } from "src/utils/bulkUpdate"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; -import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; +import { BulkUpdateFormGroup, BulkUpdateTextInput } from "../Shared/BulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; function Tags(props: { @@ -85,6 +85,8 @@ export const EditTagsDialog: React.FC = ( const [updateInput, setUpdateInput] = useState({}); + const unsetDisabled = props.selected.length < 2; + const [updateTags] = useBulkTagUpdate(getTagInput()); // Network state @@ -153,33 +155,18 @@ export const EditTagsDialog: React.FC = ( setUpdateInput(updateState); }, [props.selected]); - function renderTextField( - name: string, - value: string | undefined | null, - setter: (newValue: string | undefined) => void - ) { - return ( - - - - - setter(newValue)} - unsetDisabled={props.selected.length < 2} - /> - - ); - } - return ( = ( /> - {renderTextField("description", updateInput.description, (v) => - setUpdateField({ description: v }) - )} + + + setUpdateField({ description: newValue }) + } + unsetDisabled={unsetDisabled} + as="textarea" + /> + = ({ tag, fullWidth }) => { value={renderStashIDs()} fullWidth={fullWidth} /> +
    ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 22c99b80e..1f683e2dd 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -20,6 +20,11 @@ import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { Tag, TagSelect } from "../TagSelect"; import { Icon } from "src/components/Shared/Icon"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; +import { + CustomFieldsInput, + formatCustomFieldInput, +} from "src/components/Shared/CustomFields"; +import cloneDeep from "lodash-es/cloneDeep"; interface ITagEditPanel { tag: Partial; @@ -63,6 +68,7 @@ export const TagEditPanel: React.FC = ({ ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), + custom_fields: yup.object().required().defined(), }); const initialValues = { @@ -74,15 +80,26 @@ export const TagEditPanel: React.FC = ({ child_ids: (tag?.children ?? []).map((t) => t.id), ignore_auto_tag: tag?.ignore_auto_tag ?? false, stash_ids: getStashIDs(tag?.stash_ids), + custom_fields: cloneDeep(tag?.custom_fields ?? {}), }; type InputValues = yup.InferType; + const [customFieldsError, setCustomFieldsError] = useState(); + + function submit(values: InputValues) { + const input = { + ...schema.cast(values), + custom_fields: formatCustomFieldInput(isNew, values.custom_fields), + }; + onSave(input); + } + const formik = useFormik({ initialValues, enableReinitialize: true, validate: yupFormikValidate(schema), - onSubmit: (values) => onSave(schema.cast(values)), + onSubmit: submit, }); function onSetParentTags(items: Tag[]) { @@ -134,7 +151,10 @@ export const TagEditPanel: React.FC = ({ } async function onSaveAndNewClick() { - const input = schema.cast(formik.values); + const input = { + ...schema.cast(formik.values), + custom_fields: formatCustomFieldInput(isNew, formik.values.custom_fields), + }; onSave(input, true); } @@ -266,6 +286,14 @@ export const TagEditPanel: React.FC = ({ )} + + formik.setFieldValue("custom_fields", v)} + error={customFieldsError} + setError={(e) => setCustomFieldsError(e)} + /> +
    {renderInputField("ignore_auto_tag", "checkbox")} @@ -279,7 +307,9 @@ export const TagEditPanel: React.FC = ({ onSave={formik.handleSubmit} onSaveAndNew={isNew ? onSaveAndNewClick : undefined} saveDisabled={ - (!isNew && !formik.dirty) || !isEqual(formik.errors, {}) + (!isNew && !formik.dirty) || + !isEqual(formik.errors, {}) || + customFieldsError !== undefined } onImageChange={onImageChange} onImageChangeURL={onImageLoad} diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx index 19ceb5431..406f924e4 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; -import { ImageList } from "src/components/Images/ImageList"; +import { FilteredImageList } from "src/components/Images/ImageList"; import { View } from "src/components/List/views"; interface ITagImagesPanel { @@ -17,7 +17,7 @@ export const TagImagesPanel: React.FC = ({ }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); return ( - = ({ const filterHook = useFilterHook(tag, showSubTagContent); return ( - ; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +}> = PatchComponent( + "TagList", + ({ tags, filter, selectedIds, onSelectChange }) => { + if (tags.length === 0 && filter.displayMode !== DisplayMode.Tagger) { + return null; + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.List) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.Tagger) { + return ; + } + + return null; + } +); + +const TagFilterSidebarSections = PatchContainerComponent( + "FilteredTagList.SidebarSections" +); + +const SidebarContent: React.FC<{ + filter: ListFilterModel; + setFilter: (filter: ListFilterModel) => void; + filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; + sidebarOpen: boolean; + onClose?: () => void; + showEditFilter: (editingCriterion?: string) => void; + count?: number; + focus?: ReturnType; +}> = ({ + filter, + setFilter, + // filterHook, + view, + showEditFilter, + sidebarOpen, + onClose, + count, + focus, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + + return ( + <> + + + + {/* */} + } + filter={filter} + setFilter={setFilter} + option={FavoriteTagCriterionOption} + sectionID="favourite" + /> + + +
    + +
    + + ); +}; + +function useViewRandom(filter: ListFilterModel, count: number) { + const history = useHistory(); + + const viewRandom = useCallback(async () => { + // query for a random tag + if (count === 0) { + return; + } + + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindTagsForList(filterCopy); + if (singleResult.data.findTags.tags.length === 1) { + const { id } = singleResult.data.findTags.tags[0]; + // navigate to the tag page + history.push(`/tags/${id}`); + } + }, [history, filter, count]); + + return viewRandom; } -function getCount(result: GQL.FindTagsForListQueryResult) { - return result?.data?.findTags?.count ?? 0; +function useAddKeybinds(filter: ListFilterModel, count: number) { + const viewRandom = useViewRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + viewRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [viewRandom]); } interface ITagList { @@ -45,346 +194,130 @@ interface ITagList { extraOperations?: IItemListOperation[]; } -export const TagList: React.FC = PatchComponent( - "TagList", - ({ filterHook, alterQuery, extraOperations = [] }) => { - const Toast = useToast(); - const [deletingTag, setDeletingTag] = - useState | null>(null); - - const filterMode = GQL.FilterMode.Tags; - const view = View.Tags; - - function getDeleteTagInput() { - const tagInput: Partial = {}; - if (deletingTag) { - tagInput.id = deletingTag.id; - } - return tagInput as GQL.TagDestroyInput; - } - const [deleteTag] = useTagDestroy(getDeleteTagInput()); - +export const FilteredTagList = PatchComponent( + "FilteredTagList", + (props: ITagList) => { const intl = useIntl(); const history = useHistory(); - const [mergeTags, setMergeTags] = useState(undefined); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExportAll, setIsExportAll] = useState(false); - const otherOperations = [ - ...extraOperations, - { - text: intl.formatMessage({ id: "actions.view_random" }), - onClick: viewRandom, - }, - { - text: `${intl.formatMessage({ id: "actions.merge" })}…`, - onClick: merge, - isDisplayed: showWhenSelected, - }, - { - text: intl.formatMessage({ id: "actions.export" }), - onClick: onExport, - isDisplayed: showWhenSelected, - }, - { - text: intl.formatMessage({ id: "actions.export_all" }), - onClick: onExportAll, - }, - ]; + const searchFocus = useFocus(); - function addKeybinds( - result: GQL.FindTagsForListQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - viewRandom(result, filter); + const { filterHook, alterQuery, extraOperations = [] } = props; + + const view = View.Tags; + + // States + const { + showSidebar, + setShowSidebar, + sectionOpen, + setSectionOpen, + loading: sidebarStateLoading, + } = useSidebarState(view); + + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.Tags, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindTagsForList, + getCount: (r) => r.data?.findTags.count ?? 0, + getItems: (r) => r.data?.findTags.tags ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, + }); + + useAddKeybinds(effectiveFilter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } }); return () => { - Mousetrap.unbind("p r"); + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); }; - } + }); - async function viewRandom( - result: GQL.FindTagsForListQueryResult, - filter: ListFilterModel - ) { - // query for a random tag - if (result.data?.findTags) { - const { count } = result.data.findTags; + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindTagsForList(filterCopy); - if (singleResult.data.findTags.tags.length === 1) { - const { id } = singleResult.data.findTags.tags[0]; - // navigate to the tag page - history.push(`/tags/${id}`); - } - } - } + const viewRandom = useViewRandom(effectiveFilter, totalCount); - async function merge( - result: GQL.FindTagsForListQueryResult, - filter: ListFilterModel, - selectedIds: Set - ) { - const selected = - result.data?.findTags.tags.filter((t) => selectedIds.has(t.id)) ?? []; - setMergeTags(selected); - } - - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } - - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } - - async function onAutoTag(tag: GQL.TagListDataFragment) { - if (!tag) return; - try { - await mutateMetadataAutoTag({ tags: [tag.id] }); - Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" })); - } catch (e) { - Toast.error(e); - } - } - - async function onDelete() { - try { - const oldRelations = { - parents: deletingTag?.parents ?? [], - children: deletingTag?.children ?? [], - }; - await deleteTag(); - tagRelationHook(deletingTag as GQL.TagListDataFragment, oldRelations, { - parents: [], - children: [], - }); - Toast.success( - intl.formatMessage( - { id: "toast.delete_past_tense" }, - { - count: 1, - singularEntity: intl.formatMessage({ id: "tag" }), - pluralEntity: intl.formatMessage({ id: "tags" }), - } - ) - ); - setDeletingTag(null); - } catch (e) { - Toast.error(e); - } - } - - function renderContent( - result: GQL.FindTagsForListQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - function renderMergeDialog() { - if (mergeTags) { - return ( - { - setMergeTags(undefined); - if (mergedId) { - history.push(`/tags/${mergedId}`); - } - }} - show - /> - ); - } - } - - function maybeRenderExportDialog() { - if (isExportDialogOpen) { - return ( - setIsExportDialogOpen(false)} - /> - ); - } - } - - function renderTags() { - if (!result.data?.findTags) return; - - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.List) { - const deleteAlert = ( - {}} - show={!!deletingTag} - icon={faTrashAlt} - accept={{ - onClick: onDelete, - variant: "danger", - text: intl.formatMessage({ id: "actions.delete" }), - }} - cancel={{ onClick: () => setDeletingTag(null) }} - > - - - - - ); - - const tagElements = result.data.findTags.tags.map((tag) => { - return ( -
    - {tag.name} - -
    - - - - - - - :{" "} - - - -
    -
    - ); - }); - - return ( -
    - {tagElements} - {deleteAlert} -
    - ); - } - if (filter.displayMode === DisplayMode.Wall) { - return

    TODO

    ; - } - } - return ( - <> - {renderMergeDialog()} - {maybeRenderExportDialog()} - {renderTags()} - + function onExport(all: boolean) { + showModal( + closeModal()} + /> ); } - function renderEditDialog( - selectedTags: GQL.TagListDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ; + function onEdit() { + showModal( + + ); } - function renderDeleteDialog( - selectedTags: GQL.TagListDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( + function onDelete(tag?: GQL.TagListDataFragment) { + const itemsToDelete = tag ? [tag] : selectedItems; + + showModal( { - selectedTags.forEach((t) => + itemsToDelete.forEach((t) => tagRelationHook( t, { parents: t.parents ?? [], children: t.children ?? [] }, @@ -396,26 +329,164 @@ export const TagList: React.FC = PatchComponent( ); } - return ( - - { + onCloseEditDelete(); + if (mergedId) { + history.push(`/tags/${mergedId}`); + } + }} + show /> - + ); + } + + const convertedExtraOperations = extraOperations.map((op) => ({ + text: op.text, + onClick: () => op.onClick(result, filter, selectedIds), + isDisplayed: () => op.isDisplayed?.(result, filter, selectedIds) ?? true, + })); + + const otherOperations = [ + ...convertedExtraOperations, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.view_random" }), + onClick: viewRandom, + }, + { + text: `${intl.formatMessage({ id: "actions.merge" })}…`, + onClick: () => onMerge(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export" }), + onClick: () => onExport(false), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export_all" }), + onClick: () => onExport(true), + }, + ]; + + // render + if (sidebarStateLoading) return null; + + const operations = ( + + ); + + return ( +
    + {modal} + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> + +
    + setFilter(filter.changePage(page))} + /> + +
    + + + + + + {totalCount > filter.itemsPerPage && ( +
    +
    + +
    +
    + )} +
    +
    +
    +
    ); } ); diff --git a/ui/v2.5/src/components/Tags/TagListTable.tsx b/ui/v2.5/src/components/Tags/TagListTable.tsx new file mode 100644 index 000000000..f593c0d1f --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagListTable.tsx @@ -0,0 +1,230 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ + +import React from "react"; +import { useIntl } from "react-intl"; +import { Button } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import * as GQL from "src/core/generated-graphql"; +import { Icon } from "../Shared/Icon"; +import NavUtils from "src/utils/navigation"; +import { faHeart } from "@fortawesome/free-solid-svg-icons"; +import { useTagUpdate } from "src/core/StashService"; +import { useTableColumns } from "src/hooks/useTableColumns"; +import cx from "classnames"; +import { IColumn, ListTable } from "../List/ListTable"; + +interface ITagListTableProps { + tags: GQL.TagListDataFragment[]; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +} + +const TABLE_NAME = "tags"; + +export const TagListTable: React.FC = ( + props: ITagListTableProps +) => { + const intl = useIntl(); + + const [updateTag] = useTagUpdate(); + + function setFavorite(v: boolean, tagId: string) { + if (tagId) { + updateTag({ + variables: { + input: { + id: tagId, + favorite: v, + }, + }, + }); + } + } + + const ImageCell = (tag: GQL.TagListDataFragment) => ( + + {tag.name + + ); + + const NameCell = (tag: GQL.TagListDataFragment) => ( + +
    + {tag.name} +
    + + ); + + const AliasesCell = (tag: GQL.TagListDataFragment) => { + let aliases = tag.aliases ? tag.aliases.join(", ") : ""; + return ( + + {aliases} + + ); + }; + + const FavoriteCell = (tag: GQL.TagListDataFragment) => ( + + ); + + const SceneCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.scene_count} + + ); + + const GalleryCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.gallery_count} + + ); + + const ImageCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.image_count} + + ); + + const GroupCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.group_count} + + ); + + const StudioCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.studio_count} + + ); + + const PerformerCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.performer_count} + + ); + + interface IColumnSpec { + value: string; + label: string; + defaultShow?: boolean; + mandatory?: boolean; + render?: (tag: GQL.TagListDataFragment, index: number) => React.ReactNode; + } + + const allColumns: IColumnSpec[] = [ + { + value: "image", + label: intl.formatMessage({ id: "image" }), + defaultShow: true, + render: ImageCell, + }, + { + value: "name", + label: intl.formatMessage({ id: "name" }), + mandatory: true, + defaultShow: true, + render: NameCell, + }, + { + value: "aliases", + label: intl.formatMessage({ id: "aliases" }), + defaultShow: true, + render: AliasesCell, + }, + { + value: "favourite", + label: intl.formatMessage({ id: "favourite" }), + defaultShow: true, + render: FavoriteCell, + }, + { + value: "scene_count", + label: intl.formatMessage({ id: "scenes" }), + defaultShow: true, + render: SceneCountCell, + }, + { + value: "gallery_count", + label: intl.formatMessage({ id: "galleries" }), + defaultShow: true, + render: GalleryCountCell, + }, + { + value: "image_count", + label: intl.formatMessage({ id: "images" }), + defaultShow: true, + render: ImageCountCell, + }, + { + value: "group_count", + label: intl.formatMessage({ id: "groups" }), + defaultShow: true, + render: GroupCountCell, + }, + { + value: "performer_count", + label: intl.formatMessage({ id: "performers" }), + defaultShow: true, + render: PerformerCountCell, + }, + { + value: "studio_count", + label: intl.formatMessage({ id: "studios" }), + defaultShow: true, + render: StudioCountCell, + }, + ]; + + const defaultColumns = allColumns + .filter((col) => col.defaultShow) + .map((col) => col.value); + + const { selectedColumns, saveColumns } = useTableColumns( + TABLE_NAME, + defaultColumns + ); + + const columnRenderFuncs: Record< + string, + (tag: GQL.TagListDataFragment, index: number) => React.ReactNode + > = {}; + allColumns.forEach((col) => { + if (col.render) { + columnRenderFuncs[col.value] = col.render; + } + }); + + function renderCell( + column: IColumn, + tag: GQL.TagListDataFragment, + index: number + ) { + const render = columnRenderFuncs[column.value]; + + if (render) return render(tag, index); + } + + return ( + saveColumns(c)} + selectedIds={props.selectedIds} + onSelectChange={props.onSelectChange} + renderCell={renderCell} + /> + ); +}; diff --git a/ui/v2.5/src/components/Tags/TagMergeDialog.tsx b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx index a66ce5789..effc1ec07 100644 --- a/ui/v2.5/src/components/Tags/TagMergeDialog.tsx +++ b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx @@ -28,16 +28,8 @@ import { ScrapedTextAreaRow, } from "../Shared/ScrapeDialog/ScrapeDialogRow"; import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; -import { StringListSelect } from "../Shared/Select"; import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog"; - -interface IStashIDsField { - values: GQL.StashId[]; -} - -const StashIDsField: React.FC = ({ values }) => { - return v.stash_id)} />; -}; +import { StashIDsField } from "../Shared/StashID"; interface ITagMergeDetailsProps { sources: GQL.TagDataFragment[]; @@ -329,10 +321,18 @@ const TagMergeDetails: React.FC = ({ title={intl.formatMessage({ id: "stash_id" })} result={stashIDs} originalField={ - + + } + newField={ + } - newField={} onChange={(value) => setStashIDs(value)} + alwaysShow={ + !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length + } /> = ({ : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" - : intl.formatMessage({ id: "dialogs.merge.source" }); + : intl.formatMessage({ id: "dialogs.merge.combined" }); return ( = ({ id: "actions.merge", }); + const srcIDs = useMemo(() => src.map((s) => s.id), [src]); + const destID = useMemo(() => (dest ? [dest.id] : []), [dest]); + useEffect(() => { if (tags.length > 0) { setDest(tags[0]); @@ -549,6 +552,7 @@ export const TagMergeModal: React.FC = ({ creatable={false} onSelect={(items) => setSrc(items)} values={src} + excludeIds={destID} menuPortalTarget={document.body} /> @@ -584,6 +588,7 @@ export const TagMergeModal: React.FC = ({ creatable={false} onSelect={(items) => setDest(items[0])} values={dest ? [dest] : undefined} + excludeIds={srcIDs} menuPortalTarget={document.body} /> diff --git a/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx b/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx index 27e9e8dce..741c83245 100644 --- a/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx +++ b/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx @@ -1,13 +1,9 @@ import React from "react"; -import { Link } from "react-router-dom"; import { useFindTags } from "src/core/StashService"; -import Slider from "@ant-design/react-slick"; import { TagCard } from "./TagCard"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { getSlickSliderSettings } from "src/core/recommendations"; -import { RecommendationRow } from "../FrontPage/RecommendationRow"; -import { FormattedMessage } from "react-intl"; import { PatchComponent } from "src/patch"; +import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow"; interface IProps { isTouch: boolean; @@ -19,37 +15,26 @@ export const TagRecommendationRow: React.FC = PatchComponent( "TagRecommendationRow", (props) => { const result = useFindTags(props.filter); - const cardCount = result.data?.findTags.count; - - if (!result.loading && !cardCount) { - return null; - } + const count = result.data?.findTags.count ?? 0; return ( - - - - } + heading={props.header} + url={`/tags?${props.filter.makeQueryParameters()}`} + count={count} + loading={result.loading} + isTouch={props.isTouch} + filter={props.filter} > - - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
    - )) - : result.data?.findTags.tags.map((p) => ( - - ))} -
    -
    + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
    + )) + : result.data?.findTags.tags.map((p) => ( + + ))} + ); } ); diff --git a/ui/v2.5/src/components/Tags/TagSelect.tsx b/ui/v2.5/src/components/Tags/TagSelect.tsx index c9ed83fea..b79915261 100644 --- a/ui/v2.5/src/components/Tags/TagSelect.tsx +++ b/ui/v2.5/src/components/Tags/TagSelect.tsx @@ -23,12 +23,15 @@ import { IFilterProps, IFilterValueProps, Option as SelectOption, + toOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; import { TagPopover } from "./TagPopover"; import { Placement } from "react-bootstrap/esm/Overlay"; import { sortByRelevance } from "src/utils/query"; import { PatchComponent, PatchFunction } from "src/patch"; +import { isUUID } from "src/utils/stashIds"; +import { filterByStashID } from "src/models/list-filter/utils"; export type SelectObject = { id: string; @@ -75,24 +78,40 @@ const _TagSelect: React.FC = (props) => { const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + function filterExcluded(tag: Tag) { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(tag.id.toString()); + } + async function loadTags(input: string): Promise { const filter = new ListFilterModel(GQL.FilterMode.Tags); - filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; - const query = await queryFindTagsForSelect(filter); - let ret = query.data.findTags.tags.filter((tag) => { - // HACK - we should probably exclude these in the backend query, but - // this will do in the short-term - return !exclude.includes(tag.id.toString()); - }); - return tagSelectSort(input, ret).map((tag) => ({ - value: tag.id, - object: tag, - })); + if (isUUID(input)) { + filterByStashID(filter, input); + + const query = await queryFindTagsForSelect(filter); + const matches = query.data.findTags.tags.filter(filterExcluded); + + if (matches.length > 0) { + // Matches found, return them immediately. + return matches.map(toOption); + } + + // If no stash_id matches found, continue with standard name/alias search. + filter.criteria = []; // Clear stash_id criterion to search by name/alias below. + } + + filter.searchTerm = input; + + const query = await queryFindTagsForSelect(filter); + const ret = query.data.findTags.tags.filter(filterExcluded); + + return tagSelectSort(input, ret).map(toOption); } const TagOption: React.FC> = (optionProps) => { diff --git a/ui/v2.5/src/components/Tags/Tags.tsx b/ui/v2.5/src/components/Tags/Tags.tsx index 806a0f7a6..a4336fea9 100644 --- a/ui/v2.5/src/components/Tags/Tags.tsx +++ b/ui/v2.5/src/components/Tags/Tags.tsx @@ -4,10 +4,10 @@ import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Tag from "./TagDetails/Tag"; import TagCreate from "./TagDetails/TagCreate"; -import { TagList } from "./TagList"; +import { FilteredTagList } from "./TagList"; const Tags: React.FC = () => { - return ; + return ; }; const TagRoutes: React.FC = () => { diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 58b1aae42..33b778343 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -166,6 +166,14 @@ export const queryFindScenesByID = (sceneIDs: number[]) => }, }); +export const queryFindFullScenesByID = (sceneIDs: number[]) => + client.query({ + query: GQL.FindFullScenesDocument, + variables: { + ids: sceneIDs, + }, + }); + export const queryFindScenesForSelect = (filter: ListFilterModel) => client.query({ query: GQL.FindScenesForSelectDocument, @@ -507,6 +515,24 @@ export const useFindSavedFilters = (mode?: GQL.FilterMode) => variables: { mode }, }); +export const queryFindSubFolders = (id: string, excludeZipFolders?: boolean) => + client.query({ + query: GQL.FindFoldersForQueryDocument, + variables: { + folder_filter: { + parent_folder: { value: id, modifier: GQL.CriterionModifier.Equals }, + zip_file: excludeZipFolders + ? { modifier: GQL.CriterionModifier.IsNull } + : undefined, + }, + filter: { + per_page: -1, + sort: "basename", + direction: GQL.SortDirectionEnum.Asc, + }, + }, + }); + /// Object Mutations // Increases/decreases the given field of the Stats query by diff @@ -602,9 +628,8 @@ export const useSceneUpdate = () => }, }); -export const useBulkSceneUpdate = (input: GQL.BulkSceneUpdateInput) => +export const useBulkSceneUpdate = () => GQL.useBulkSceneUpdateMutation({ - variables: { input }, update(cache, result) { if (!result.data?.bulkSceneUpdate) return; @@ -1380,9 +1405,8 @@ export const useGroupUpdate = () => }, }); -export const useBulkGroupUpdate = (input: GQL.BulkGroupUpdateInput) => +export const useBulkGroupUpdate = () => GQL.useBulkGroupUpdateMutation({ - variables: { input }, update(cache, result) { if (!result.data?.bulkGroupUpdate) return; @@ -2248,6 +2272,18 @@ export const mutateDeleteFiles = (ids: string[]) => }, }); +export const mutateRevealFileInFileManager = (id: string) => + client.mutate({ + mutation: GQL.RevealFileInFileManagerDocument, + variables: { id }, + }); + +export const mutateRevealFolderInFileManager = (id: string) => + client.mutate({ + mutation: GQL.RevealFolderInFileManagerDocument, + variables: { id }, + }); + /// Scrapers export const useListSceneScrapers = () => GQL.useListSceneScrapersQuery(); @@ -2451,6 +2487,12 @@ export const mutateStashBoxBatchStudioTag = ( variables: { input }, }); +export const mutateStashBoxBatchTagTag = (input: GQL.StashBoxBatchTagInput) => + client.mutate({ + mutation: GQL.StashBoxBatchTagTagDocument, + variables: { input }, + }); + export const useListGroupScrapers = () => GQL.useListGroupScrapersQuery(); export const queryScrapeGroupURL = (url: string) => diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index 5807821f5..bed34f531 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -38,6 +38,7 @@ export type DefaultFilters = { export type FrontPageContent = ISavedFilterRow | ICustomFilter; export const defaultMaxOptionsShown = 200; +export const defaultPreviewVolume = 25; export interface IUIConfig { // unknown to prevent direct access - use getFrontPageContent @@ -48,6 +49,10 @@ export interface IUIConfig { showLinksOnPerformerCard?: boolean; showTagCardOnHover?: boolean; + showStudioText?: boolean; + + previewVolume?: number; + abbreviateCounters?: boolean; ratingSystemOptions?: RatingSystemOptions; diff --git a/ui/v2.5/src/core/createClient.ts b/ui/v2.5/src/core/createClient.ts index 4fbcd9183..f2a6ff5fd 100644 --- a/ui/v2.5/src/core/createClient.ts +++ b/ui/v2.5/src/core/createClient.ts @@ -1,8 +1,7 @@ import { ApolloClient, InMemoryCache, - split, - from, + ApolloLink, ServerError, TypePolicies, } from "@apollo/client"; @@ -171,7 +170,7 @@ Please disable it on the server and refresh the page.`); } }); - const splitLink = split( + const splitLink = ApolloLink.split( ({ query }) => { const definition = getMainDefinition(query); return ( @@ -183,7 +182,7 @@ Please disable it on the server and refresh the page.`); httpLink ); - const link = from([errorLink, splitLink]); + const link = ApolloLink.from([errorLink, splitLink]); const cache = new InMemoryCache({ typePolicies, diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts index 016e9e13f..e11130abf 100644 --- a/ui/v2.5/src/core/performers.ts +++ b/ui/v2.5/src/core/performers.ts @@ -104,10 +104,8 @@ export const scrapedPerformerToCreateInput = ( height_cm: toCreate.height ? Number(toCreate.height) : undefined, measurements: toCreate.measurements, fake_tits: toCreate.fake_tits, - career_start: toCreate.career_start - ? Number(toCreate.career_start) - : undefined, - career_end: toCreate.career_end ? Number(toCreate.career_end) : undefined, + career_start: toCreate.career_start, + career_end: toCreate.career_end, tattoos: toCreate.tattoos, piercings: toCreate.piercings, alias_list: aliases, diff --git a/ui/v2.5/src/docs/en/Changelog/v0310.md b/ui/v2.5/src/docs/en/Changelog/v0310.md new file mode 100644 index 000000000..5db15a51a --- /dev/null +++ b/ui/v2.5/src/docs/en/Changelog/v0310.md @@ -0,0 +1,108 @@ +### ✨ New Features + +* Added support for image phash generation and filtering. ([#6497](https://github.com/stashapp/stash/pull/6497)) +* Added minimum/maximum number of sprites and sprite size options to support customised scene sprite generation. ([#6588](https://github.com/stashapp/stash/pull/6588)) +* Added support for merging performers. ([#5910](https://github.com/stashapp/stash/pull/5910)) +* Added `Reveal in file manager` button to file info panel when running locally. ([#6587](https://github.com/stashapp/stash/pull/6587)) +* Added `.stashignore` support for gitignore-style scan exclusions. ([#6485](https://github.com/stashapp/stash/pull/6485)) +* Added Selective generate option. ([#6621](https://github.com/stashapp/stash/pull/6621)) +* Added `From Clipboard` option to Set Image dropdown button (on secure connections). ([#6637](https://github.com/stashapp/stash/pull/6637)) +* Added Tags tagger view. ([#6559](https://github.com/stashapp/stash/pull/6559), [#6620](https://github.com/stashapp/stash/pull/6620)) +* Added loop option for markers. ([#6510](https://github.com/stashapp/stash/pull/6510)) +* Added support for custom favicon and title. ([#6366](https://github.com/stashapp/stash/pull/6366)) +* Added Troubleshooting Mode to help identify and resolve common issues. ([#6343](https://github.com/stashapp/stash/pull/6343)) + +### 🎨 Improvements + +* **[0.31.1]** Added warning when creating a parent tag using the tag tagger where the parent tag has no remote site id. ([#6805](https://github.com/stashapp/stash/pull/6805)) +* Sidebars are now used for lists of galleries ([#6157](https://github.com/stashapp/stash/pull/6157)), images ([#6607](https://github.com/stashapp/stash/pull/6607)), groups ([#6573](https://github.com/stashapp/stash/pull/6573)), performers ([#6547](https://github.com/stashapp/stash/pull/6547)), studios ([#6549](https://github.com/stashapp/stash/pull/6549)), tags ([#6610](https://github.com/stashapp/stash/pull/6610)), and scene markers ([#6603](https://github.com/stashapp/stash/pull/6603)). +* Added folder sidebar criterion option for scenes, images and galleries. ([#6636](https://github.com/stashapp/stash/pull/6636)) +* Custom field support has been added to scenes ([#6584](https://github.com/stashapp/stash/pull/6584)), galleries ([#6592](https://github.com/stashapp/stash/pull/6592)), images ([#6598](https://github.com/stashapp/stash/pull/6598)), groups ([#6596](https://github.com/stashapp/stash/pull/6596)) studios ([#6156](https://github.com/stashapp/stash/pull/6156)) and tags ([#6546](https://github.com/stashapp/stash/pull/6546)). +* Bulk edit dialogs have been refactored to include more fields. ([#6647](https://github.com/stashapp/stash/pull/6647)) +* Extended duplicate criterion to filter by duplicated titles and stash IDs. ([#6344](https://github.com/stashapp/stash/pull/6344)) +* Extended missing criterion to add full coverage of fields. ([#6565](https://github.com/stashapp/stash/pull/6565)) +* Identify settings now allows for selecting included genders. ([#6557](https://github.com/stashapp/stash/pull/6557)) +* Added option to ignore files in zip files while cleaning. ([#6700](https://github.com/stashapp/stash/pull/6700)) +* Backup now provides an option to include blobs in a backup zip. ([#6586](https://github.com/stashapp/stash/pull/6586)) +* Added checkbox selection on wall and tagger views. ([#6476](https://github.com/stashapp/stash/pull/6476)) +* Performer career length field has been replaced with career start and end fields. ([#6449](https://github.com/stashapp/stash/pull/6449)) +* Added organised flag to studios. ([#6303](https://github.com/stashapp/stash/pull/6303)) +* Merging tags now shows a dialog to edit the merged tag's details. ([#6552](https://github.com/stashapp/stash/pull/6552)) +* New object pages now support for saving and creating another object. ([#6438](https://github.com/stashapp/stash/pull/6438)) +* Default performer images have been updated to be consistent with other card images. ([#6566](https://github.com/stashapp/stash/pull/6566)) +* Unsupported filter criteria are now indicated in the UI. ([#6604](https://github.com/stashapp/stash/pull/6604)) +* Marker screenshots can now be generated independently of marker previews. ([#6433](https://github.com/stashapp/stash/pull/6433)) +* Added invert selection option to list menus. ([#6491](https://github.com/stashapp/stash/pull/6491)) +* Added Generate task option for galleries. ([#6442](https://github.com/stashapp/stash/pull/6442)) +* Scene resolution and duration is now shown in the tagger view. ([#6663](https://github.com/stashapp/stash/pull/6663)) +* Added button to delete scene cover. ([#6444](https://github.com/stashapp/stash/pull/6444)) +* Duplicate aliases are now silently removed. ([#6514](https://github.com/stashapp/stash/pull/6514)) +* Image query now includes image details field. ([#6673](https://github.com/stashapp/stash/pull/6673)) +* Select scene/performer/studio/tag dropdowns now accept stash-ids as input. ([#6709](https://github.com/stashapp/stash/pull/6709)) +* Volume when hovering over a scene preview is now configurable. ([#6712](https://github.com/stashapp/stash/pull/6712)) +* Added non-binary gender icon. ([#6489](https://github.com/stashapp/stash/pull/6489)) +* Transgender icons are now coloured by their presented gender. ([#6489](https://github.com/stashapp/stash/pull/6489)) +* It is now possible to add a library path to a non-existing directory (useful for disconnected network paths). ([#6644](https://github.com/stashapp/stash/pull/6644)) +* Added activity tracking for DLNA resume/view counts. ([#6407](https://github.com/stashapp/stash/pull/6407), [#6483](https://github.com/stashapp/stash/pull/6483)) +* SFW Mode now shows performer ages. ([#6450](https://github.com/stashapp/stash/pull/6450)) +* Added support for sorting scenes and images by resolution. ([#6441](https://github.com/stashapp/stash/pull/6441)) +* Added support for sorting performers and studios by latest scene. ([#6501](https://github.com/stashapp/stash/pull/6501)) +* Added support for sorting performers, studios and tags by total scene file size. ([#6642](https://github.com/stashapp/stash/pull/6642)) +* Added support for filtering by stash ID count. ([#6437](https://github.com/stashapp/stash/pull/6437)) +* Added support for filtering group by scene count. ([#6593](https://github.com/stashapp/stash/pull/6593)) +* Updated Tag list view to be consistent with other list views. ([#6703](https://github.com/stashapp/stash/pull/6703)) +* Added confirmation dialog to Auto Tag task. ([#6735](https://github.com/stashapp/stash/pull/6735)) +* Studio now shows the studio name instead of the studio image if the image is not set or if (new) `Show studio as text` is true. ([#6716](https://github.com/stashapp/stash/pull/6716)) +* Installed plugins/scrapers no longer show in the available list. ([#6443](https://github.com/stashapp/stash/pull/6443)) +* Name is now populated when searching by stash-box. ([#6447](https://github.com/stashapp/stash/pull/6447)) +* Improved performance of group queries on large systems. ([#6478](https://github.com/stashapp/stash/pull/6478)) +* Search input is now focused when opening the scraper menu. ([#6704](https://github.com/stashapp/stash/pull/6704)) +* Added `d d` keyboard shortcut to delete scene in scene details page. ([#6755](https://github.com/stashapp/stash/pull/6755)) +* VAAPI dri device can now be overridden using `STASH_HW_DRI_DEVICE` environment variable. ([#6728](https://github.com/stashapp/stash/pull/6728)) +* Added support for `{phash}` in `queryURL` scraper field. ([#6701](https://github.com/stashapp/stash/pull/6701)) +* Systray notification now shows the port stash is running on. ([#6448](https://github.com/stashapp/stash/pull/6448)) + +### 🐛 Bug fixes + +* **[0.31.1]** Fixed tag export outputting studios instead of tags. ([#6819](https://github.com/stashapp/stash/pull/6819)) +* **[0.31.1]** Fixed memory leak in scanning process. ([#6796](https://github.com/stashapp/stash/pull/6796)) +* **[0.31.1]** Schema migration 84 now attempts to de-duplicate folder entries to prevent unique constraint violations. ([#6792](https://github.com/stashapp/stash/pull/6792)) +* **[0.31.1]** Fixed issue where navigating to a scene from the wall view on the scene or marker list page would require clicking Back twice to return to the previous page. ([#6803](https://github.com/stashapp/stash/pull/6803)) +* **[0.31.1]** Page is now reset when changing the selected folder in the folder sidebar filter. ([#6804](https://github.com/stashapp/stash/pull/6804)) +* **[0.31.1]** Fixed stash ID pill overflowing on mobile viewports. ([#6807](https://github.com/stashapp/stash/pull/6807)) +* **[0.31.1]** Migration process now attempts to create the backup directory if it does not exist. ([#6808](https://github.com/stashapp/stash/pull/6808)) +* **[0.31.1]** Fixed tag uniqueness check incorrectly interpreting `_` as a wildcard. ([#6809](https://github.com/stashapp/stash/pull/6809)) +* **[0.31.1]** Fixed websocket connection error when sending messages containing certain unicode sequences. ([#6810](https://github.com/stashapp/stash/pull/6810)) +* Fixed certain unicode characters in library path causing panic in scan task. ([#6431](https://github.com/stashapp/stash/pull/6431), [#6589](https://github.com/stashapp/stash/pull/6589), [#6635](https://github.com/stashapp/stash/pull/6635)) +* Fixed bad network path error preventing rename detection during scanning. ([#6680](https://github.com/stashapp/stash/pull/6680)) +* Fixed duplicate files in zips being incorrectly reported as renames. ([#6493](https://github.com/stashapp/stash/pull/6493)) +* Fixed merging scene causing cover to be lost. ([#6542](https://github.com/stashapp/stash/pull/6542)) +* Improved scanning algorithm to prevent creation of orphaned folders and handle missing parent folders. ([#6608](https://github.com/stashapp/stash/pull/6608)) +* Scanning no longer scans zip contents when the zip file is unchanged. ([#6633](https://github.com/stashapp/stash/pull/6633)) +* Captions are now correctly detected in a single scan. ([#6634](https://github.com/stashapp/stash/pull/6634)) +* Fixed galleries not being linked to scenes when scanning a matching file. ([#6705](https://github.com/stashapp/stash/pull/6705)) +* Fixed mis-clicks on cards navigating to new page when selecting items. ([#6599](https://github.com/stashapp/stash/pull/6599), [#6649](https://github.com/stashapp/stash/pull/6649)) +* Select dropdown now retains focus after creating a new option. ([#6697](https://github.com/stashapp/stash/pull/6697)) +* Fixed custom field filtering not working correctly when query value was provided. ([#6614](https://github.com/stashapp/stash/pull/6614)) +* Fixed `not equals` custom field filtering to include results where the field is not set. ([#6742](https://github.com/stashapp/stash/pull/6742)) +* Fixed `Scale up to fit` lightbox option not persisting correctly in some circumstances. ([#6743](https://github.com/stashapp/stash/pull/6743)) +* Fixed stale thumbnails after file content is changed. ([#6622](https://github.com/stashapp/stash/pull/6622)) +* Clicking on the scrubber in the scene player no longer pauses the video. ([#6336](https://github.com/stashapp/stash/pull/6336)) +* Tagger search results and states are now refreshed when changing the selected source in the tagger views. ([#6766](https://github.com/stashapp/stash/pull/6766)) +* Current selected source items are now excluded from the destination selector and vice versa in the merge dialogs. ([#6764](https://github.com/stashapp/stash/pull/6764)) +* Fixed heatmap still appearing in the scene detail page after associated funscript was removed from scene. ([#6746](https://github.com/stashapp/stash/pull/6746)) +* Fixed string-based hash filtering not functioning correctly. ([#6654](https://github.com/stashapp/stash/pull/6654)) +* Fixed hardware decoding detection for 10-bit videos on rkmpp. ([#6420](https://github.com/stashapp/stash/pull/6420)) +* Fixed race condition in package cache initialisation. ([#6741](https://github.com/stashapp/stash/pull/6741)) +* Unicode characters are no longer stripped when performing a metadata export. ([#6748](https://github.com/stashapp/stash/pull/6748)) + +### Api Changes + +* Many new components are now patchable. ([#6468](https://github.com/stashapp/stash/pull/6468), [#6463](https://github.com/stashapp/stash/pull/6463), [#6482](https://github.com/stashapp/stash/pull/6482), [#6492](https://github.com/stashapp/stash/pull/6492), [#6470](https://github.com/stashapp/stash/pull/6470)) +* Added access to `ReactFontAwesome` in the plugin API. ([#6487](https://github.com/stashapp/stash/pull/6487)) +* Added `destroyFiles` mutation to delete file entries from the database. ([#6437](https://github.com/stashapp/stash/pull/6437)) +* Added `destroy_file_entry` flag to destroy inputs to destroy file entries when destroying scenes, images, and galleries. ([#6437](https://github.com/stashapp/stash/pull/6437)) +* Added `basename` and `parent_folders` fields to the `folder` type. ([#6494](https://github.com/stashapp/stash/pull/6494)) +* Added `basename` filter field to `FolderFilterType`. ([#6494](https://github.com/stashapp/stash/pull/6494)) +* Added `parent_folder` filter field to `GalleryFilterType`. ([#6636](https://github.com/stashapp/stash/pull/6636)) +* Performer `career_length` field is deprecated in favour of `career_start` and `career_end`. diff --git a/ui/v2.5/src/docs/en/Manual/AutoTagging.md b/ui/v2.5/src/docs/en/Manual/AutoTagging.md index c3ef00971..4725020d1 100644 --- a/ui/v2.5/src/docs/en/Manual/AutoTagging.md +++ b/ui/v2.5/src/docs/en/Manual/AutoTagging.md @@ -1,4 +1,4 @@ -# Auto Tag +# Auto tag Auto tag automatically assigns Performers, Studios, and Tags to your media based on their names found in file paths or filenames. This task works for scenes, images, and galleries. @@ -39,7 +39,7 @@ Scenes, images, and galleries that have the Organized flag added to them will no Studios also support the Organized flag, however it is purely informational. It serves as a front-end indicator for the user to mark that a studio's collection is complete and does not affect Auto tag behavior. The Ignore Auto tag flag should be used to exclude a studio from Auto tag. -### Ignore Auto tag flag +### Ignore auto tag flag Performers or Tags that have Ignore Auto tag flag added to them will be skipped by the Auto tag task. diff --git a/ui/v2.5/src/docs/en/Manual/Browsing.md b/ui/v2.5/src/docs/en/Manual/Browsing.md index 69277146e..525cca222 100644 --- a/ui/v2.5/src/docs/en/Manual/Browsing.md +++ b/ui/v2.5/src/docs/en/Manual/Browsing.md @@ -1,6 +1,6 @@ # Browsing -## Querying and Filtering +## Querying and filtering ### Keyword searching @@ -9,7 +9,7 @@ The text field allows you to search using keywords. Keyword searching matches on | Type | Fields searched | |------|-----------------| | Scene | Title, Details, Path, OSHash, Checksum, Marker titles | -| Image | Title, Path, Checksum | +| Image | Title, Details, Path, Checksum | | Group | Title | | Marker | Title, Scene title | | Gallery | Title, Path, Checksum | @@ -17,15 +17,34 @@ The text field allows you to search using keywords. Keyword searching matches on | Studio | Name, Aliases | | Tag | Name, Aliases | +### Rules + Keyword matching uses the following rules: -* all words are required in the matching field. For example, `foo bar` matches scenes with both `foo` and `bar` in the title. -* the `or` keyword or symbol (`|`) is used to match either fields. For example, `foo or bar` (or `foo | bar`) matches scenes with `foo` or `bar` in the title. Or sets can be combined. For example, `foo or bar or baz xyz or zyx` matches scenes with one of `foo`, `bar` and `baz`, *and* `xyz` or `zyx`. -* the not symbol (`-`) is used to exclude terms. For example, `foo -bar` matches scenes with `foo` and excludes those with `bar`. The not symbol cannot be combined with an or operand. That is, `-foo or bar` will be interpreted to match `-foo` or `bar`. On the other hand, `foo or bar -baz` will match `foo` or `bar` and exclude `baz`. -* surrounding a phrase in quotes (`"`) matches on that exact phrase. For example, `"foo bar"` matches scenes with `foo bar` in the title. Quotes may also be used to escape the keywords and symbols. For example, `foo "-bar"` will match scenes with `foo` and `-bar`. -* quoted phrases may be used with the or and not operators. For example, `"foo bar" or baz -"xyz zyx"` will match scenes with `foo bar` *or* `baz`, and exclude those with `xyz zyx`. -* `or` keywords or symbols at the start or end of a line will be treated literally. That is, `or foo` will match scenes with `or` and `foo`. -* all keyword matching is case-insensitive +- By default, all terms are required in the same matching field. +- Use the `or` keyword or `|` symbol to match either term. +- You can combine `or` sets in one query. +- Use the `-` symbol to exclude terms. +- The `-` symbol cannot be combined with an `or` operand. +- Use quotes (`"`) to match an exact phrase. +- Quotes can also escape keywords and symbols. +- `or` at the start or end of a query is treated literally. +- Keyword matching is case-insensitive. + +#### Examples + +| Query | Behavior | Explanation | +|---|---|---| +| `foo bar` | Requires both `foo` and `bar`. | Both terms must match in the same field. | +| `foo or bar` or `foo | bar` | Matches either `foo` or `bar`. | `or` and `|` are equivalent. | +| `foo or bar or baz xyz or zyx` | Matches one of `foo`, `bar`, or `baz`, and either `xyz` or `zyx`. | Multiple `or` sets can be combined. | +| `foo -bar` | Matches `foo`, excludes `bar`. | `-` excludes terms. | +| `-foo or bar` | Interpreted as `-foo` or `bar`. | `-` cannot be combined with an `or` operand. | +| `foo or bar -baz` | Matches `foo` or `bar`, excludes `baz`. | Exclusion is applied alongside the `or` set. | +| `"foo bar"` | Matches the exact phrase `foo bar`. | Quotes perform exact phrase matching. | +| `foo "-bar"` | Matches `foo` and the literal text `-bar`. | Quotes escape keyword/operator parsing. | +| `"foo bar" or baz -"xyz zyx"` | Matches `foo bar` or `baz`, excludes `xyz zyx`. | Quoted phrases can be used with `or` and `-`. | +| `or foo` | Matches literal `or` and `foo`. | `or` is literal at the start or end of a query. | ### Filters @@ -50,3 +69,9 @@ Saved filters are sorted alphabetically by title with capitalized titles sorted ### Default filter The default filter for the top-level pages may be set to the current filter by clicking the `Set as default` button in the saved filter menu. + +## Reveal file in file manager + +The `Reveal in file manager` action is available for file-based scenes, galleries and images in the `File Info` tab. This action will open the file manager to the location of the file on disk. The file will be selected if supported by the file manager. + +This button will only be available when accessing stash from a local loopback address (e.g. `localhost` or `127.0.0.1`), and will not be shown when accessing stash from a remote address. \ No newline at end of file diff --git a/ui/v2.5/src/docs/en/Manual/Captions.md b/ui/v2.5/src/docs/en/Manual/Captions.md index a575f915b..a92e8a7ea 100644 --- a/ui/v2.5/src/docs/en/Manual/Captions.md +++ b/ui/v2.5/src/docs/en/Manual/Captions.md @@ -8,10 +8,10 @@ Ensure the caption files follow these naming conventions: ## Scene -- {scene_file_name}.{language_code}.ext -- {scene_file_name}.ext +- {scene_file_name}.{language_code}.{ext} +- {scene_file_name}.{ext} -Where `{language_code}` is defined by the [ISO-6399-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (2 letters) standard and `ext` is the file extension. Captions files without a language code will be labeled as Unknown in the video player but will work fine. +Where `{language_code}` is defined by the [ISO-6399-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (2 letters) standard and `{ext}` is the file extension. Captions files without a language code will be labeled as Unknown in the video player but will work fine. Scenes with captions can be filtered with the `captions` criterion. diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index 2d08f9750..3a856b2d4 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -194,6 +194,8 @@ The following environment variables are also supported: | Environment variable | Remarks | |----------------------|---------| | `STASH_SQLITE_CACHE_SIZE` | Sets the SQLite cache size. See https://www.sqlite.org/pragma.html#pragma_cache_size. Default is `-2000` which is 2MB. | +| `STASH_HW_TEST_TIMEOUT` | Sets the Hardware Acceleration test timeout in seconds. Default is 10 seconds +| `STASH_HW_DRI_DEVICE` | Overrides the default `/dev/dri` device used for VAAPI hardware acceleration. Default is `/dev/dri/renderD128` ### Custom favicon diff --git a/ui/v2.5/src/docs/en/Manual/Contributing.md b/ui/v2.5/src/docs/en/Manual/Contributing.md index 2d62dde08..7e5e5b91b 100644 --- a/ui/v2.5/src/docs/en/Manual/Contributing.md +++ b/ui/v2.5/src/docs/en/Manual/Contributing.md @@ -2,7 +2,7 @@ ## Financial -Financial contributions are welcomed and are accepted using [Open Collective](https://opencollective.com/stashapp). +Financial contributions are welcomed and are accepted using [Open Collective](https://opencollective.com/stashapp) or [GitHub Sponsors](https://github.com/sponsors/stashapp). ## Development-related diff --git a/ui/v2.5/src/docs/en/Manual/Deduplication.md b/ui/v2.5/src/docs/en/Manual/Deduplication.md index d842fcc68..c24ec328f 100644 --- a/ui/v2.5/src/docs/en/Manual/Deduplication.md +++ b/ui/v2.5/src/docs/en/Manual/Deduplication.md @@ -1,4 +1,4 @@ -# Dupe Checker +# Dupe checker [The dupe checker](/sceneDuplicateChecker) searches your collection for scenes that are perceptually similar. This means that the files don't need to be identical, and will be identified even with different bitrates, resolutions, and intros/outros. diff --git a/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md b/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md index 9d54010e6..b3cf09766 100644 --- a/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md +++ b/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md @@ -1,26 +1,27 @@ -# Embedded Plugin Tasks +# Embedded plugin tasks Embedded plugin tasks are executed within the stash process using a scripting system. ## Supported script languages -Stash currently supports Javascript embedded plugin tasks using [goja](https://github.com/dop251/goja). +Stash currently supports JavaScript embedded plugin tasks using [goja](https://github.com/dop251/goja). -## Javascript plugins +## JavaScript plugins ### Plugin input -The input is provided to Javascript plugin tasks using the `input` global variable, and is an object based on the structure provided in the `Plugin input` section of the [Plugins](/help/Plugins.md) page. +The input is provided to JavaScript plugin tasks using the `input` global variable, and is an object based on the structure provided in the `Plugin input` section of the [Plugins](/help/Plugins.md) page. > **⚠️ Note:** `server_connection` field should not be necessary in most embedded plugins. ### Plugin output -The output of a Javascript plugin task is derived from the evaluated value of the script. The output should conform to the structure provided in the `Plugin output` section of the [Plugins](/help/Plugins.md) page. +The output of a JavaScript plugin task is derived from the evaluated value of the script. The output should conform to the structure provided in the `Plugin output` section of the [Plugins](/help/Plugins.md) page. There are a number of ways to return the plugin output: #### Example #1 + ``` (function() { return { @@ -30,6 +31,7 @@ There are a number of ways to return the plugin output: ``` #### Example #2 + ``` function main() { return { @@ -41,6 +43,7 @@ main(); ``` #### Example #3 + ``` var output = { Output: "ok" @@ -62,13 +65,14 @@ For embedded plugins, the `exec` field is a list with the first element being th ### interface For embedded plugins, the `interface` field must be set to one of the following values: -* `js` -## Javascript API +- `js` + +## JavaScript API ### Logging -Stash provides the following API for logging in Javascript plugins: +Stash provides the following API for logging in JavaScript plugins: | Method | Description | |--------|-------------| diff --git a/ui/v2.5/src/docs/en/Manual/ExternalPlugins.md b/ui/v2.5/src/docs/en/Manual/ExternalPlugins.md index 11db639e0..0cf1a2294 100644 --- a/ui/v2.5/src/docs/en/Manual/ExternalPlugins.md +++ b/ui/v2.5/src/docs/en/Manual/ExternalPlugins.md @@ -1,4 +1,4 @@ -# External Plugin Tasks +# External plugin tasks External plugin tasks are executed by running an external binary. diff --git a/ui/v2.5/src/docs/en/Manual/Images.md b/ui/v2.5/src/docs/en/Manual/Images.md index 5be7beba5..4bdebc094 100644 --- a/ui/v2.5/src/docs/en/Manual/Images.md +++ b/ui/v2.5/src/docs/en/Manual/Images.md @@ -1,4 +1,4 @@ -# Images and Galleries +# Images and galleries Images are the parts which make up galleries, but you can also have them be scanned independently. To declare an image part of a gallery, there are four ways: @@ -28,4 +28,3 @@ A clip/gif will be a stillframe in the wall and grid view by default. To view th If you want the loop to be used as a preview on the wall and grid view, you will have to generate them. You can do this as you scan for the new clip file by activating **Generate previews for image clips** on the scan settings, or do it after by going to the **Generated Content** section in the task section of your settings, activating **Image clip previews** and clicking generate. This takes a while, as the files are transcoded. - diff --git a/ui/v2.5/src/docs/en/Manual/Interface.md b/ui/v2.5/src/docs/en/Manual/Interface.md index 951fb3323..7e1a608c3 100644 --- a/ui/v2.5/src/docs/en/Manual/Interface.md +++ b/ui/v2.5/src/docs/en/Manual/Interface.md @@ -1,4 +1,4 @@ -# Interface Options +# Interface options ## Language @@ -13,17 +13,17 @@ When SFW content mode is enabled, the following changes are made to the UI: - certain adult-specific metadata fields are hidden (e.g. performer genital fields) - `O`-Counter is replaced with `Like`-counter -## Scene/Marker Wall Preview type +## Scene/marker wall preview type The Scene Wall and Marker pages display scene preview videos (mp4) by default. This can be changed to animated image (webp) or static image. > **⚠️ Note:** scene/marker preview videos must be generated to see them in the applicable wall page if Video preview type is selected. Likewise, if Animated image is selected, then Image Previews must be generated. -## Show Studios as text +## Show studio overlay as text -By default, a scene's studio will be shown as an image overlay. Checking this option changes this to display studios as a text name instead. +By default, in the grid card view the studio will be shown as an image overlay of the studio logo. Checking this option changes this to display studios as a text name instead. -## Scene Player options +## Scene player options By default, scene videos do not automatically start when navigating to the scenes page. Checking the "Auto-start video" option changes this to auto play scene videos. @@ -47,7 +47,7 @@ There is also a [collection of community-created themes](https://discourse.stash Stash supports the injection of custom JavaScript to assist with theming or adding additional functionality. Be aware that bad JavaScript could break the UI or worse. -## Custom Locales +## Custom locales The localisation strings can be customised. The master list of default (en-GB) locale strings can be found [here](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json). The custom locale format is the same as this json file. diff --git a/ui/v2.5/src/docs/en/Manual/Introduction.md b/ui/v2.5/src/docs/en/Manual/Introduction.md index f32b84681..45a3221ec 100644 --- a/ui/v2.5/src/docs/en/Manual/Introduction.md +++ b/ui/v2.5/src/docs/en/Manual/Introduction.md @@ -1,9 +1,9 @@ # Introduction -Stash works by cataloging your media using the paths that you provide. Once you have [configured](/settings?tab=library) the locations where your media is stored, you can click the Scan button in [`Settings -> Tasks`](/settings?tab=tasks) and stash will begin scanning and importing your media into its library. +Stash works by cataloging your media using the paths that you provide. Once you have [configured](/settings?tab=library) the locations where your media is stored, you can click the Scan button in [`Settings -> Tasks`](/settings?tab=tasks) and Stash will begin scanning and importing your media into its library. -For the best experience, it is recommended that after a scan is finished, that video previews and sprites are generated. You can do this in [`Settings -> Tasks`](/settings?tab=tasks). +For the best experience, it is recommended that after a scan is finished, you also generate video previews and sprites. You can do this in [`Settings -> Tasks`](/settings?tab=tasks). -> **⚠️ Note:** Currently it is only possible to perform one task at a time and but there is a task queue, so the generate tasks should be performed after scan is complete. +> **⚠️ Note:** Currently, it is only possible to perform one task at a time. However, there is a task queue, so you can queue generation tasks to be performed immediately after the scan is complete. -Once your media is imported, you are ready to begin creating Performers, Studios and Tags, and curating your content! \ No newline at end of file +Once your media is imported, you are ready to begin creating Performers, Studios, and Tags, and curating your content! \ No newline at end of file diff --git a/ui/v2.5/src/docs/en/Manual/JSONSpec.md b/ui/v2.5/src/docs/en/Manual/JSONSpec.md index b071f26cc..d1a69c40a 100644 --- a/ui/v2.5/src/docs/en/Manual/JSONSpec.md +++ b/ui/v2.5/src/docs/en/Manual/JSONSpec.md @@ -1,4 +1,4 @@ -# Import/Export JSON Specification +# Import/export JSON specification The metadata given to Stash can be exported into the JSON format. This structure can be modified, or replicated by other means. The resulting data can then be imported again, giving the possibility for automatic scraping of all kinds. The format of this metadata bulk is a folder structure, containing the following folders: @@ -26,7 +26,7 @@ When exported, files are named with different formats depending on the object ty > **⚠️ Note:** The file naming is not significant when importing. All json files will be read from the subdirectories. -## Content of the json files +## Content of the JSON files In the following, the values of the according jsons will be shown. If the value should be a number, it is written with after comma values (like `29.98` or `50.0`), but still as a string. The meaning from most of them should be obvious due to the previous explanation or from the possible values stash offers when editing, otherwise a short comment will be added. @@ -43,6 +43,7 @@ Example: ``` ### Performer + ``` name url @@ -69,6 +70,7 @@ details ``` ### Studio + ``` name url @@ -80,6 +82,7 @@ details ``` ### Scene + ``` title studio @@ -111,6 +114,7 @@ updated_at ### Image + ``` title studio @@ -127,6 +131,7 @@ updated_at ``` ### Gallery + ``` title studio @@ -145,6 +150,7 @@ updated_at ## Files ### Folder + ``` zip_file (path to containing zip file) mod_time @@ -155,6 +161,7 @@ updated_at ``` ### Video file + ``` zip_file (path to containing zip file) mod_time @@ -179,6 +186,7 @@ updated_at ``` ### Image file + ``` zip_file (path to containing zip file) mod_time @@ -196,6 +204,7 @@ updated_at ``` ### Other files + ``` zip_file (path to containing zip file) mod_time diff --git a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md index f6cd29334..ba49c5a61 100644 --- a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md +++ b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md @@ -1,4 +1,4 @@ -# Keyboard Shortcuts +# Keyboard shortcuts ## Global shortcuts @@ -6,7 +6,7 @@ |-------------------|--------| | `?` | Display manual | -### Global Navigation +### Global navigation | Keyboard sequence | Target page | |-------------------|--------| @@ -64,6 +64,7 @@ | `,` | Hide/Show sidebar | | `.` | Hide/Show scene scrubber | | `o` | Increment O-Counter | +| `d d` | Delete scene | | Ratings || | `r {1-5}` | Set rating (stars) | | `r 0` | Unset rating (stars) | @@ -93,13 +94,13 @@ | `l` | A/B looping toggle. Press once to set start point. Press again to set end point. Press again to disable loop. | | `Shift + l` | Toggle looping of scene when it's over | -### Scene Markers tab shortcuts +### Scene markers tab shortcuts | Keyboard sequence | Action | |-------------------|--------| | `n` | Display Create Markers dialog | -### Scene Edit tab shortcuts +### Scene edit tab shortcuts | Keyboard sequence | Action | |-------------------|--------| @@ -114,7 +115,7 @@ [//]: # "(| `v` | Focus Groups selector |)" [//]: # "(| `t` | Focus Tags selector |)" -## Image Page shortcuts +## Image page shortcuts | Keyboard sequence | Action | |-------------------|--------| @@ -126,20 +127,20 @@ | `r {0-9} {0-9}` | Set rating (decimal - `00` for `10.0`) | | ``r ` `` | Unset rating (decimal) | -### Image Edit tab shortcuts +### Image edit tab shortcuts | Keyboard sequence | Action | |-------------------|--------| | `s s` | Save Scene | | `d d` | Delete Scene | -## Groups Page shortcuts +## Groups page shortcuts | Keyboard sequence | Action | |-------------------|--------| | `n` | New Group | -## Group Page shortcuts +## Group page shortcuts | Keyboard sequence | Action | |-------------------|--------| @@ -157,20 +158,20 @@ [//]: # "Commented until implementation is dealt with" [//]: # "(| `u` | Focus Studio selector (in edit mode) |)" -## Markers Page shortcuts +## Markers page shortcuts | Keyboard sequence | Action | |-------------------|--------| | `p r` | Play random marker | -## Performers Page shortcuts +## Performers page shortcuts | Keyboard sequence | Action | |-------------------|--------| | `n` | New Performer | | `p r` | Open random Performer | -## Performer Page shortcuts +## Performer page shortcuts | Keyboard sequence | Action | |-------------------|--------| @@ -180,7 +181,7 @@ | `f` | Toggle favourite | | `,` | Expand/Collapse Details | -### Performer Edit tab shortcuts +### Performer edit tab shortcuts | Keyboard sequence | Action | |-------------------|--------| @@ -188,13 +189,13 @@ | `d d` | Delete Performer | | `Ctrl + v` | Paste Performer image | -## Studios Page shortcuts +## Studios page shortcuts | Keyboard sequence | Action | |-------------------|--------| | `n` | New Studio | -## Studio Page shortcuts +## Studio page shortcuts | Keyboard sequence | Action | |-------------------|--------| @@ -204,13 +205,13 @@ | `,` | Expand/Collapse Details | | `Ctrl + v` | Paste Studio image | -## Tags Page shortcuts +## Tags page shortcuts | Keyboard sequence | Action | |-------------------|--------| | `n` | New Tag | -## Tag Page shortcuts +## Tag page shortcuts | Keyboard sequence | Action | |-------------------|--------| diff --git a/ui/v2.5/src/docs/en/Manual/Plugins.md b/ui/v2.5/src/docs/en/Manual/Plugins.md index 5e403af92..6a13487bc 100644 --- a/ui/v2.5/src/docs/en/Manual/Plugins.md +++ b/ui/v2.5/src/docs/en/Manual/Plugins.md @@ -2,16 +2,16 @@ Stash supports plugins that can do the following: -- perform custom tasks when triggered by the user from the Tasks page -- perform custom tasks when triggered from specific events -- add custom CSS to the UI -- add custom JavaScript to the UI +- Perform custom tasks when triggered by the user from the Tasks page +- Perform custom tasks when triggered from specific events +- Add custom CSS to the UI +- Add custom JavaScript to the UI Plugin tasks can be implemented using embedded Javascript, or by calling an external binary. > **⚠️ Note:** Plugin support is still experimental and is likely to change. -## Managing Plugins +## Managing plugins Plugins can be installed and managed from the `Settings > Plugins` page. @@ -130,7 +130,7 @@ The `exec`, `interface`, `errLog` and `tasks` fields are used only for plugins w The `settings` field is used to display plugin settings on the plugins page. Plugin settings can also be set using the graphql mutation `configurePlugin` - the settings set this way do _not_ need to be specified in the `settings` field unless they are to be displayed in the stock plugin settings UI. -### UI Configuration +### UI configuration The `css` and `javascript` field values may be relative paths to the plugin configuration file, or may be full external URLs. diff --git a/ui/v2.5/src/docs/en/Manual/SceneFilenameParser.md b/ui/v2.5/src/docs/en/Manual/SceneFilenameParser.md index ff630f77f..896334b1a 100644 --- a/ui/v2.5/src/docs/en/Manual/SceneFilenameParser.md +++ b/ui/v2.5/src/docs/en/Manual/SceneFilenameParser.md @@ -1,23 +1,23 @@ -# Scene Filename Parser +# Scene filename parser [This tool](/sceneFilenameParser) parses the scene filenames in your library and allows setting the metadata from those filenames. -## Parser Options +## Parser options To use this tool, a filename pattern must be entered. The pattern accepts the following fields: | Field | Remark | |-------|--------| | `title` | Text captured within is set as the title of the scene. | -|`ext`|Matches the end of the filename. It is not captured. Does not include the last `.` character.| -|`d`|Matches delimiter characters (`-_.`). Not captured.| -|`i`|Matches any ignored word entered in the `Ignored words` field. Ignored words are entered as space-delimited words. Not captured. Use this to match release artifacts like `DVDRip` or release groups.| -|`date`|Matches `yyyy-mm-dd` and sets the date of the scene.| -|`rating`|Matches a single digit and sets the rating of the scene.| -|`performer`| Sets the scene performer, based on the text captured.| -|`tag`| Sets the scene tag, based on the text captured.| -|`studio`| Sets the studio performer, based on the text captured.| -|`{}`|Matches any characters. Not captured.| +| `ext` | Matches the end of the filename. It is not captured. Does not include the last `.` character. | +| `d` | Matches delimiter characters (`-_.`). Not captured. | +| `i` | Matches any ignored word entered in the `Ignored words` field. Ignored words are entered as space-delimited words. Not captured. Use this to match release artifacts like `DVDRip` or release groups. | +| `date` | Matches `yyyy-mm-dd` and sets the date of the scene. | +| `rating` | Matches a single digit and sets the rating of the scene. | +| `performer` | Sets the scene performer, based on the text captured. | +| `tag` | Sets the scene tag, based on the text captured. | +| `studio` | Sets the studio performer, based on the text captured. | +| `{}` | Matches any characters. Not captured. | > **⚠️ Note:** `performer`, `tag` and `studio` fields will only match against Performers/Tags/Studios that already exist in the system. @@ -27,11 +27,11 @@ The following partial date fields are also supported. The date will only be set | Field | Remark | |-------|--------| -|`yyyy`|Four digit year| -|`yy`|Two digit year. Assumes the first two digits are `20`| -|`mm`|Two digit month| -|`mmm`|Three letter month, such as `Jan` (case-insensitive)| -|`dd`|Two digit date| +| `yyyy` | Four digit year | +| `yy` | Two digit year. Assumes the first two digits are `20` | +| `mm` | Two digit month | +| `mmm` | Three letter month, such as `Jan` (case-insensitive) | +| `dd` | Two digit date | The following full date fields are supported, using the same partial date rules as above: @@ -48,8 +48,8 @@ Title generation also has the following options: | Option | Remark | |--------|--------| -|Whitespace characters| These characters are replaced with whitespace (defaults to `._`, to handle filenames like `three.word.title.avi`| -|Capitalize title| capitalises the first letter of each word| +| Whitespace characters | These characters are replaced with whitespace (defaults to `._`, to handle filenames like `three.word.title.avi`) | +| Capitalize title | Capitalises the first letter of each word | The fields to display can be customised with the `Display Fields` drop-down section. By default, any field with new/different values will be displayed. diff --git a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md index 4c97e3fcf..858fb89a0 100644 --- a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md +++ b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md @@ -1,4 +1,4 @@ -# Contributing Scrapers +# Contributing scrapers Scrapers can be contributed to the community by creating a PR in [this repository](https://github.com/stashapp/CommunityScrapers/pulls). @@ -56,7 +56,6 @@ The scraping types and their required fields are outlined in the following table URL-based scraping accepts multiple scrape configurations, and each configuration requires a `url` field. stash iterates through these configurations, attempting to match the entered URL against the `url` fields in the configuration. It executes the first scraping configuration where the entered URL contains the value of the `url` field. - ## Actions ### Script @@ -94,7 +93,7 @@ The script is sent input and expects output based on the scraping type, as detai For `performerByName`, only `name` is required in the returned performer fragments. One entire object is sent back to `performerByFragment` to scrape a specific performer, so the other fields may be included to assist in scraping a performer. For example, the `url` field may be filled in for the specific performer page, then `performerByFragment` can extract by using its value. -Python example of a performer Scraper: +Python example of a performer scraper: ```python import json @@ -212,15 +211,14 @@ xPathScrapers: For `sceneByFragment` and `sceneByQueryFragment`, the `queryURL` field must also be present. This field is used to build a query URL for scenes. For `sceneByFragment`, the `queryURL` field supports the following placeholder fields: -* `{checksum}` - the MD5 checksum of the scene -* `{oshash}` - the oshash of the scene -* `{filename}` - the base filename of the scene -* `{title}` - the title of the scene -* `{url}` - the url of the scene +- `{checksum}` - the MD5 checksum of the scene +- `{oshash}` - the oshash of the scene +- `{phash}` - the phash of the scene +- `{filename}` - the base filename of the scene +- `{title}` - the title of the scene +- `{url}` - the url of the scene -These placeholder field values may be manipulated with regex replacements by adding a `queryURLReplace` section, containing a map of placeholder field to regex configuration which uses the same format as the `replace` post-process action covered below. - -For example: +These placeholder field values may be manipulated with regex replacements by adding a `queryURLReplace` section, containing a map of placeholder field to regex configuration which uses the same format as the `replace` post-process action covered below. For example: ```yaml sceneByFragment: @@ -239,7 +237,7 @@ The above configuration would scrape from the value of `queryURL`, replacing `{f For `sceneByURL`, `performerByURL`, `galleryByURL` the `queryURL` can also be present if we want to use `queryURLReplace`. The functionality is the same as `sceneByFragment`, the only placeholder field available though is the `url`: -* `{url}` - the url of the scene/performer/gallery +- `{url}` - the url of the scene/performer/gallery ```yaml sceneByURL: @@ -335,9 +333,7 @@ The `{inputURL}` and `{inputHostname}` placeholders can be used in both `fixed` #### {inputURL} -The `{inputURL}` placeholder provides access to the full URL. This is useful when you want to return or reference the source URL as part of the scraped data. - -For example: +The `{inputURL}` placeholder provides access to the full URL. This is useful when you want to return or reference the source URL as part of the scraped data. For example: ```yaml scene: @@ -351,9 +347,7 @@ When scraping from `https://example.com/scene/12345`, the `{inputURL}` placehold #### {inputHostname} -The `{inputHostname}` placeholder extracts just the hostname from the URL. This is useful when you need to reference the domain without manually parsing the URL. - -For example: +The `{inputHostname}` placeholder extracts just the hostname from the URL. This is useful when you need to reference the domain without manually parsing the URL. For example: ```yaml scene: @@ -409,8 +403,10 @@ scene: Post-processing operations are contained in the `postProcess` key. Post-processing operations are performed in the order they are specified. The following post-processing operations are available: -* `javascript`: accepts a javascript code block, that must return a string value. The input string is declared in the `value` variable. If an error occurs while compiling or running the script, then the original value is returned. -Example: +#### `javascript` + +`javascript`: accepts a javascript code block, that must return a string value. The input string is declared in the `value` variable. If an error occurs while compiling or running the script, then the original value is returned. For example: + ```yaml performer: Name: @@ -425,10 +421,18 @@ performer: We use [`goja` javascript engine](https://github.com/dop251/goja) which is missing a few built-in methods and may not be consistent with other modern javascript implementations. -* `feetToCm`: converts a string containing feet and inches numbers into centimeters. Looks for up to two separate integers and interprets the first as the number of feet, and the second as the number of inches. The numbers can be separated by any non-numeric character including the `.` character. It does not handle decimal numbers. For example `6.3` and `6ft3.3` would both be interpreted as 6 feet, 3 inches before converting into centimeters. -* `lbToKg`: converts a string containing lbs to kg. -* `map`: contains a map of input values to output values. Where a value matches one of the input values, it is replaced with the matching output value. If no value is matched, then value is unmodified. -Example: +#### `feetToCm` + +`feetToCm`: converts a string containing feet and inches numbers into centimeters. Looks for up to two separate integers and interprets the first as the number of feet, and the second as the number of inches. The numbers can be separated by any non-numeric character including the `.` character. It does not handle decimal numbers. For example `6.3` and `6ft3.3` would both be interpreted as 6 feet, 3 inches before converting into centimeters. + +#### `lbToKg` + +`lbToKg`: converts a string containing lbs to kg. + +#### `map` + +`map`: contains a map of input values to output values. Where a value matches one of the input values, it is replaced with the matching output value. If no value is matched, then value is unmodified.For example: + ```yaml performer: Gender: @@ -446,15 +450,19 @@ performer: postProcess: - lbToKg: true ``` + Gets the contents of the selected div element, and sets the returned value to: - `Female` if the scraped value is `F`; - `Male` if the scraped value is `M`. Height and weight are extracted from the selected spans and converted to `cm` and `kg`. -* `parseDate`: if present, the value is the date format using go's reference date (2006-01-02). For example, if an example date was `14-Mar-2003`, then the date format would be `02-Jan-2006`. See the [time.Parse documentation](https://golang.org/pkg/time/#Parse) for details. When present, the scraper will convert the input string into a date, then convert it to the string format used by stash (`YYYY-MM-DD`). Strings "Today", "Yesterday" are matched (case insensitive) and converted by the scraper so you don't need to edit/replace them. -Unix timestamps (example: 1660169451) can also be parsed by selecting `unix` as the date format. -Example: +#### `parseDate` + +`parseDate`: if present, the value is the date format using go's reference date (2006-01-02). For example, if an example date was `14-Mar-2003`, then the date format would be `02-Jan-2006`. See the [time.Parse documentation](https://golang.org/pkg/time/#Parse) for details. When present, the scraper will convert the input string into a date, then convert it to the string format used by stash (`YYYY-MM-DD`). Strings "Today", "Yesterday" are matched (case insensitive) and converted by the scraper so you don't need to edit/replace them. + +Unix timestamps (example: 1660169451) can also be parsed by selecting `unix` as the date format.For example: + ```yaml Date: selector: //div[@class="value epoch"]/text() @@ -462,8 +470,9 @@ Date: - parseDate: unix ``` -* `subtractDays`: if set to `true` it subtracts the value in days from the current date and returns the resulting date in stash's date format. -Example: +#### `subtractDays` + +`subtractDays`: if set to `true` it subtracts the value in days from the current date and returns the resulting date in stash's date format. For example: ```yaml Date: selector: //strong[contains(text(),"Added:")]/following-sibling::text() @@ -474,8 +483,10 @@ Date: - subtractDays: true ``` -* `replace`: contains an array of sub-objects. Each sub-object must have a `regex` and `with` field. The `regex` field is the regex pattern to replace, and `with` is the string to replace it with. `$` is used to reference capture groups - `$1` is the first capture group, `$2` the second and so on. Replacements are performed in order of the array. -Example: +#### `replace` + +`replace`: contains an array of sub-objects. Each sub-object must have a `regex` and `with` field. The `regex` field is the regex pattern to replace, and `with` is the string to replace it with. `$` is used to reference capture groups - `$1` is the first capture group, `$2` the second and so on. Replacements are performed in order of the array. For example: + ```yaml CareerLength: selector: $infoPiece[text() = 'Career Start and End:']/../span[@class="smallInfo"] @@ -486,37 +497,43 @@ CareerLength: ``` Replaces `2001 to 2003` with `2001-2003`. -* `subScraper`: if present, the sub-scraper will be executed after all other post-processes are complete and before parseDate. It then takes the value and performs an http request, using the value as the URL. Within the `subScraper` config is a nested scraping configuration. This allows you to traverse to other webpages to get the attribute value you are after. For more info and examples have a look at [#370](https://github.com/stashapp/stash/pull/370), [#606](https://github.com/stashapp/stash/pull/606) +#### `subScraper` -Additionally, there are a number of fixed post-processing fields that are specified at the attribute level (not in `postProcess`) that are performed after the `postProcess` operations: +`subScraper`: if present, the sub-scraper will be executed after all other post-processes are complete and before parseDate. It then takes the value and performs an http request, using the value as the URL. Within the `subScraper` config is a nested scraping configuration. This allows you to traverse to other webpages to get the attribute value you are after. For more info and examples have a look at [#370](https://github.com/stashapp/stash/pull/370), [#606](https://github.com/stashapp/stash/pull/606). + +### `concat` and `split` attributes + +These are fixed post-processing fields that are specified at the attribute level (not in `postProcess`) that are performed after the `postProcess` operations: + +- `concat`: if an xpath matches multiple elements, and `concat` is present, then all of the elements will be concatenated together. +- `split`: the inverse of `concat`. Splits a string to more elements using the separator given. For more info and examples have a look at PR [#579](https://github.com/stashapp/stash/pull/579). For example: -* `concat`: if an xpath matches multiple elements, and `concat` is present, then all of the elements will be concatenated together -* `split`: the inverse of `concat`. Splits a string to more elements using the separator given. For more info and examples have a look at PR [#579](https://github.com/stashapp/stash/pull/579) -Example: ```yaml Tags: Name: selector: //span[@class="list_attributes"] split: "," ``` -Splits a comma separated list of tags located in the span and returns the tags. +Splits a comma separated list of tags located in the span and returns the tags. For backwards compatibility, `replace`, `subscraper` and `parseDate` are also allowed as keys for the attribute. Post-processing on attribute post-process is done in the following order: `concat`, `replace`, `subscraper`, `parseDate` and then `split`. -### XPath resources: +### XPath resources - Test XPaths in Firefox: https://addons.mozilla.org/en-US/firefox/addon/try-xpath/ - XPath cheatsheet: https://devhints.io/xpath -### GJSON resources: +### GJSON resources - GJSON Path Syntax: https://github.com/tidwall/gjson/blob/master/SYNTAX.md ### Debugging support + To print the received html/json from a scraper request to the log file, add the following to your scraper yml file: + ```yaml debug: printHTML: true @@ -527,6 +544,7 @@ debug: Some websites deliver content that cannot be scraped using the raw html file alone. These websites use javascript to dynamically load the content. As such, direct xpath scraping will not work on these websites. There is an option to use Chrome DevTools Protocol to load the webpage using an instance of Chrome, then scrape the result. Chrome CDP support can be enabled for a specific scraping configuration by adding the following to the root of the yml configuration: + ```yaml driver: useCDP: true @@ -538,9 +556,9 @@ When `useCDP` is set to true, stash will execute or connect to an instance of Ch `Chrome CDP path` can be set to a path to the chrome executable, or an http(s) address to remote chrome instance (for example: `http://localhost:9222/json/version`). As remote instance a docker container can also be used with the `chromedp/headless-shell` image being highly recommended. -### CDP Click support +### CDP `clicks` support -When using CDP you can use the `clicks` part of the `driver` section to do Mouse Clicks on elements you need to collapse or toggle. Each click element has an `xpath` value that holds the XPath for the button/element you need to click and an optional `sleep` value that is the time in seconds to wait for after clicking. +When using CDP you can use the `clicks` part of the `driver` section to do Mouse Clicks on elements you need to collapse or toggle. Each click element has an `xpath` value that holds the XPath for the button/element you need to click and an optional `sleep` value that is the time in seconds to wait for after clicking. If the `sleep` value is not set it defaults to `2` seconds. A demo scraper using `clicks` follows. @@ -583,9 +601,8 @@ In some websites the use of cookies is needed to bypass a welcoming message or s To use the cookie functionality a `cookies` sub section needs to be added to the `driver` section. Each cookie element can consist of a `CookieURL` and a number of `Cookies`. -* `CookieURL` is only needed if you are using the direct / native scraper method. It is the request url that we expect from the site we scrape. It must be in the same domain as the cookies we try to set otherwise all cookies in the same group will fail to set. If the `CookieURL` is not a valid URL then again the cookies of that group will fail. - -* `Cookies` are the actual cookies we set. When using CDP that's the only part required. They have `Name`, `Value`, `Domain`, `Path` values. +- `CookieURL` is only needed if you are using the direct / native scraper method. It is the request url that we expect from the site we scrape. It must be in the same domain as the cookies we try to set otherwise all cookies in the same group will fail to set. If the `CookieURL` is not a valid URL then again the cookies of that group will fail. +- `Cookies` are the actual cookies we set. When using CDP that's the only part required. They have `Name`, `Value`, `Domain`, `Path` values. In the following example we use cookies for a site using the direct / native xpath scraper. We expect requests to come from `https://www.example.com` and `https://api.somewhere.com` that look for a `_warning` and a `_warn` cookie. A `_test2` cookie is also set just as a demo. @@ -659,9 +676,8 @@ driver: When developing a scraper you can have a look at the cookies set by a site by adding -* a `CookieURL` if you use the direct xpath scraper - -* a `Domain` if you use the CDP scraper +- a `CookieURL` if you use the direct xpath scraper +- a `Domain` if you use the CDP scraper and having a look at the log / console in debug mode. @@ -680,7 +696,7 @@ driver: Value: Bearer ds3sdfcFdfY17p4qBkTVF03zscUU2glSjWF17bZyoe8 ``` -* headers are set after stash's `User-Agent` configuration option is applied. +- Headers are set after stash's `User-Agent` configuration option is applied. This means setting a `User-Agent` header from the scraper overrides the one in the configuration settings. ### XPath scraper example @@ -923,7 +939,8 @@ URLs ``` Aliases Birthdate -CareerLength +CareerEnd +CareerStart Circumcised Country DeathDate diff --git a/ui/v2.5/src/docs/en/Manual/Scraping.md b/ui/v2.5/src/docs/en/Manual/Scraping.md index 3c37ff778..05f5c0984 100644 --- a/ui/v2.5/src/docs/en/Manual/Scraping.md +++ b/ui/v2.5/src/docs/en/Manual/Scraping.md @@ -1,8 +1,8 @@ -# Metadata Scraping +# Metadata scraping Stash supports scraping of metadata from various external sources. -## Scraper Types +## Scraper types | Type | Description | |---|:---| @@ -10,7 +10,7 @@ Stash supports scraping of metadata from various external sources. | Search/By Name | Uses a provided query string to search a metadata source for a list of matches for the user to pick from. | | URL | Extracts metadata from a given URL. | -## Supported Scrapers +## Supported scrapers | | Fragment | Search | URL | |---|:---:|:---:|:---:| @@ -20,7 +20,7 @@ Stash supports scraping of metadata from various external sources. | performer | | ✔️ | ✔️ | | scene | ✔️ | ✔️ | ✔️ | -## Included Scrapers +## Included scrapers Stash provides the following built-in scrapers: @@ -29,7 +29,7 @@ Stash provides the following built-in scrapers: | Freeones | `search` Performer scraper for freeones.xxx. | | Auto Tag | Scene `fragment` scraper that matches existing performers, studio and tags using the filename. | -## Managing Scrapers +## Managing scrapers Scrapers can be installed and managed from the `Settings > Metadata Providers` page. @@ -65,7 +65,7 @@ The source URL must return a yaml file containing all the available packages for Path can be a relative path to the zip file or an external URL. -## Adding Scrapers manually +## Adding scrapers manually By default, Stash looks for scraper configurations in the `scrapers` sub-directory of the directory where the stash `config.yml` is read. This will either be the `$HOME/.stash` directory or the current working directory. @@ -75,18 +75,21 @@ Scrapers are added manually by placing yaml configuration files (format: `scrape After the yaml files are added, removed or edited while stash is running, they can be reloaded going to `Settings > Metadata Providers > Scrapers` and clicking `Reload Scrapers`. -## Using Scrapers +## Using scrapers + +#### Fragment scraper -#### Fragment Scraper Click on the `Scrape With...` button in the `edit` tab of an item, then select the scraper you wish to use. -#### Search Scraper +#### Search scraper + Click on the 🔍 button in the `edit` tab of an item. You will be presented with a search dialog with a pre-populated query to search for, after searching you will be presented with a list of results to pick from -#### URL Scraper +#### URL scraper + Enter the URL in the `edit` tab of an Item. If a scraper is installed that supports that url, then a button will appear to scrape the metadata. -## Tagger View +## Tagger view The Tagger view is accessed from the scenes page. It allows the user to run scrapers on all items on the current page. The Tagger presents the user with potential matches for an item from a selected stash-box instance or metadata source if supported. The user needs to select the correct metadata information to save. @@ -99,7 +102,6 @@ When used in combination with stash-box, the user can optionally submit scene fi | performer | ✔️ | | | scene | ✔️ | ✔️ | - -## Identify Task +## Identify task This task iterates through your Scenes and attempts to identify the scene using a selection of scraping sources. This task can be found under `Settings -> Tasks -> "Identify..." (Button)`. For more information see the [Tasks > Identify](/help/Identify.md) page. diff --git a/ui/v2.5/src/docs/en/Manual/Tagger.md b/ui/v2.5/src/docs/en/Manual/Tagger.md index 7c2d12a87..3f4a644e6 100644 --- a/ui/v2.5/src/docs/en/Manual/Tagger.md +++ b/ui/v2.5/src/docs/en/Manual/Tagger.md @@ -1,4 +1,4 @@ -# Scene Tagger +# Scene tagger Stash can be integrated with stash-box which acts as a centralized metadata database. This is in the early stages of development but can be used for fingerprint/keyword lookups and automated tagging of performers and scenes. The batch tagging interface can be accessed from the [scene view](/scenes?disp=3). For more information join our [Discord](https://discord.gg/2TsNFKt). @@ -8,9 +8,10 @@ The fingerprint search matches your current selection of files against the remot If no fingerprint match is found it's possible to search by keywords. The search works by matching the query against a scene's _title_, _release date_, _studio name_, and _performer names_. By default the tagger uses metadata set on the file, or parses the filename, this can be changed in the config. -An important thing to note is that it only returns a match *if all query terms are a match*. As an example, if a scene is titled `"A Trip to the Mall"` with the performer `"Jane Doe"`, a search for `"Trip to the Mall 1080p"` will *not* match, however `"trip mall doe"` would. Usually a few pieces of info is enough, for instance performer name + release date or studio name. To avoid common non-related keywords you can add them to the blacklist in the tagger config. Any items in the blacklist are stripped out of the query. +An important thing to note is that it only returns a match *if all query terms are a match*. As an example, if a scene is titled `"A Trip to the Mall"` with the performer `"Jane Doe"`, a search for `"Trip to the Mall 1080p"` will *not* match, however `"trip mall doe"` would. Usually a few pieces of info are enough, for instance performer name + release date or studio name. To avoid common non-related keywords you can add them to the blacklist in the tagger config. Any items in the blacklist are stripped out of the query. ## Saving + When a scene is matched stash will try to match the studio and performers against your local studios and performers. If you have previously matched them, they will automatically be selected. If not you either have to select the correct performer/studio from the dropdown, choose create to create a new entity, or skip to ignore it. Once a scene is saved the scene and the matched studio/performers will have the `stash_id` saved which will then be used for future tagging. @@ -18,4 +19,7 @@ Once a scene is saved the scene and the matched studio/performers will have the By default male performers are not shown, this can be enabled in the tagger config. Likewise scene tags are by default not saved. They can be set to either merge with existing tags on the scene, or overwrite them. It is not recommended to set tags currently since they are hard to deduplicate and can litter your data. ## Submitting fingerprints -After a scene is saved you will prompted to submit the fingerprint back to the stash-box instance. This is optional, but can be helpful for other users who have an identical copy who will then be able to match via the fingerprint search. No other information than the `stash_id` and file fingerprint is submitted. + +After a scene is saved you will be prompted to submit the fingerprint back to the stash-box instance. This is optional, but can be helpful for other users who have an identical or similar copy which will allow them to be able to match via the fingerprint search. Stash only sends `stash_id` and file fingerprint. + +Submitted fingerprints are linked to your account via your stash-box API key and can be managed on the stash-box website. Stash does not store any additional information about submitted fingerprints. If you delete a fingerprint on the stash-box website, it will also be removed from the instance and will no longer be available for matching. diff --git a/ui/v2.5/src/docs/en/Manual/Tasks.md b/ui/v2.5/src/docs/en/Manual/Tasks.md index 4191afd24..f89ea6e66 100644 --- a/ui/v2.5/src/docs/en/Manual/Tasks.md +++ b/ui/v2.5/src/docs/en/Manual/Tasks.md @@ -10,6 +10,41 @@ Stash currently identifies files by performing a quick file hash. This means tha Stash currently ignores duplicate files. If two files contain identical content, only the first one it comes across is used. +### Ignoring files with `.stashignore` + +You can create `.stashignore` files to exclude specific files or directories from being scanned. These files use gitignore-style pattern matching syntax. + +Place a `.stashignore` file in any directory within your library. The patterns in that file will apply to all files and subdirectories within that directory. You can have multiple `.stashignore` files at different levels of your directory hierarchy - patterns from parent directories cascade down to child directories. + +`.stashignore` files are not read inside zip files. + +**Supported patterns:** + +| Pattern | Description | +|---------|-------------| +| `filename.mp4` | Ignore a specific file. | +| `*.tmp` | Ignore all files with a specific extension. | +| `temp/` | Ignore a directory and all its contents. | +| `**/cache/` | Ignore directories named "cache" at any level. | +| `!important.mp4` | Negation - do not ignore this file even if it matches a previous pattern. | +| `# comment` | Lines starting with # are comments. | +| `\#filename` | Use backslash to match a literal # character. | + +**Example `.stashignore` file:** + +``` +# Ignore temporary files +*.tmp +*.log + +# Ignore specific directories +temp/ +.thumbnails/ + +# But keep this specific file +!important.tmp +``` + The scan task accepts the following options: | Option | Description | @@ -22,15 +57,17 @@ The scan task accepts the following options: | Generate thumbnails for images | Generates thumbnails for image files. | | Generate image perceptual hashes | Generates perceptual hashes for image deduplication and identification. | | Generate previews for image clips | Generates a gif/looping video as thumbnail for image clips/gifs. | -| Rescan | By default, Stash will only rescan existing files if the file's modified date has been updated since its previous scan. Stash will rescan files in the path when this option is enabled, regardless of the file modification time. Only required if Stash needs to recalculate video/image metadata, or to rescan gallery zips. | +| Rescan files | By default, Stash will only rescan existing files if the file's modified date has been updated since its previous scan. Stash will rescan files in the path when this option is enabled, regardless of the file modification time. Only required if Stash needs to recalculate video/image metadata, or to rescan gallery zips. | -## Auto Tagging -See the [Auto Tagging](/help/AutoTagging.md) page. +## Auto tagging -## Scene Filename Parser -See the [Scene Filename Parser](/help/SceneFilenameParser.md) page. +See the [Auto tagging](/help/AutoTagging.md) page. -## Generated Content +## Scene filename parser + +See the [Scene filename parser](/help/SceneFilenameParser.md) page. + +## Generated content The generated content provides the following: @@ -55,12 +92,12 @@ The generate task accepts the following options: | Marker animated image previews | *Accessible in Advanced mode* - Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files. | | Marker screenshots | Generates static JPG images for markers. Only required if Preview type is set to Static image. Requires marker previews to be enabled. | | Transcodes | *Accessible in Advanced mode* - MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. | -| Video Perceptual hashes (for deduplication) | Generates perceptual hashes for scene deduplication and identification. | +| Video perceptual hashes | Generates perceptual hashes for scene deduplication and identification. | | Generate heatmaps and speeds for interactive scenes | Generates heatmaps and speeds for interactive scenes. | | Image clip previews | Generates a gif/looping video as thumbnail for image clips/gifs. | | Image thumbnails | Generates thumbnails for image files. | -| Image Perceptual hashes (for deduplication) | Generates perceptual hashes for image deduplication and identification. | -| Overwrite existing generated files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. | +| Image perceptual hashes | Generates perceptual hashes for image deduplication and identification. | +| Overwrite existing files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. | ### Transcodes @@ -78,7 +115,7 @@ This task will walk through your configured media directories and remove any sce Care should be taken with this task, especially where the configured media directories may be inaccessible due to network issues. -## Exporting and Importing +## Exporting and importing The import and export tasks read and write JSON files to the configured metadata directory. Import from file will merge your database with a file. diff --git a/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md b/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md index 9a5ffd215..b772b3ec4 100644 --- a/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md +++ b/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md @@ -1,4 +1,4 @@ -# Troubleshooting Mode +# Troubleshooting mode Troubleshooting mode disables all plugins and all custom CSS, JavaScript, and locales. It also temporarily sets the log level to `DEBUG`. This is useful when you are experiencing issues with your Stash instance to eliminate the possibility that a plugin or custom code is causing the issue. diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index 54ef3a20f..a2da8d55e 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -1,4 +1,4 @@ -# UI Plugin API +# UI plugin API The `PluginApi` object is a global object in the `window` object. @@ -83,9 +83,11 @@ In general, `PluginApi.hooks.useLoadComponents` hook should be used instead. Returns a `Promise` that resolves when all of the components have been loaded. #### `PluginApi.utils.InteractiveUtils` + This namespace provides access to `interactiveClientProvider` and `getPlayer` - `getPlayer` returns the current `videojs` player object - `interactiveClientProvider` takes `IInteractiveClientProvider` which allows a developer to hook into the lifecycle of funscripts. + ```ts export interface IDeviceSettings { connectionKey: string; @@ -124,6 +126,7 @@ export interface IInteractiveClient { ``` ##### Example + For instance say I wanted to add extra logging when `IInteractiveClient.connect()` is called. In my plugin you would install your own client provider as seen below @@ -147,7 +150,6 @@ InteractiveUtils.interactiveClientProvider = ( ``` - ### `hooks` This namespace provides access to the following core utility hooks: @@ -230,7 +232,13 @@ Returns `void`. - `ExternalLinkButtons` - `ExternalLinksButton` - `FilteredGalleryList` +- `FilteredGroupList` +- `FilteredImageList` +- `FilteredPerformerList` - `FilteredSceneList` +- `FilteredSceneMarkerList` +- `FilteredStudioList` +- `FilteredTagList` - `FolderSelect` - `FrontPage` - `GalleryCard` @@ -248,6 +256,7 @@ Returns `void`. - `GroupCard` - `GroupCardGrid` - `GroupIDSelect` +- `GroupList` - `GroupRecommendationRow` - `GroupSelect` - `GroupSelect.sort` @@ -262,6 +271,7 @@ Returns `void`. - `ImageDetailPanel` - `ImageGridCard` - `ImageInput` +- `ImageList` - `ImageRecommendationRow` - `LightboxLink` - `LoadingIndicator` @@ -286,6 +296,7 @@ Returns `void`. - `PerformerHeaderImage` - `PerformerIDSelect` - `PerformerImagesPanel` +- `PerformerList` - `PerformerPage` - `PerformerRecommendationRow` - `PerformerScenesPanel` @@ -302,6 +313,7 @@ Returns `void`. - `SceneCard.Image` - `SceneCard.Overlays` - `SceneCard.Popovers` +- `SceneCard.SceneSpecs` - `SceneCardsGrid` - `SceneFileInfoPanel` - `SceneIDSelect` @@ -310,6 +322,7 @@ Returns `void`. - `SceneMarkerCard.Image` - `SceneMarkerCard.Popovers` - `SceneMarkerCardsGrid` +- `SceneMarkerList` - `SceneMarkerRecommendationRow` - `SceneList` - `ScenePage` @@ -329,6 +342,7 @@ Returns `void`. - `StudioCardGrid` - `StudioDetailsPanel` - `StudioIDSelect` +- `StudioList` - `StudioRecommendationRow` - `StudioSelect` - `StudioSelect.sort` @@ -343,6 +357,7 @@ Returns `void`. - `TagCardGrid` - `TagIDSelect` - `TagLink` +- `TagList` - `TagRecommendationRow` - `TagSelect` - `TagSelect.sort` diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 65c15024c..41c6d4fad 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -206,8 +206,25 @@ export const LightboxComponent: React.FC = ({ setLightboxSettings({ slideshowDelay: v }); } + const scaleUp = + lightboxSettings?.scaleUp ?? + config?.interface.imageLightbox.scaleUp ?? + false; + + const resetZoomOnNav = + lightboxSettings?.resetZoomOnNav ?? + config?.interface.imageLightbox.resetZoomOnNav ?? + false; + + const scrollMode = + lightboxSettings?.scrollMode ?? + config?.interface.imageLightbox.scrollMode ?? + GQL.ImageLightboxScrollMode.Zoom; + const displayMode = - lightboxSettings?.displayMode ?? GQL.ImageLightboxDisplayMode.FitXy; + lightboxSettings?.displayMode ?? + config?.interface.imageLightbox.displayMode ?? + GQL.ImageLightboxDisplayMode.FitXy; const oldDisplayMode = useRef(displayMode); function setDisplayMode(v: GQL.ImageLightboxDisplayMode) { @@ -250,13 +267,13 @@ export const LightboxComponent: React.FC = ({ // reset zoom status // setResetZoom((r) => !r); // setZoomed(false); - if (lightboxSettings?.resetZoomOnNav) { + if (resetZoomOnNav) { setZoom(1); } setResetPosition((r) => !r); oldIndex.current = index; - }, [index, images.length, lightboxSettings?.resetZoomOnNav]); + }, [index, images.length, resetZoomOnNav]); const getNavOffset = useCallback(() => { if (images.length < 2) return; @@ -288,13 +305,13 @@ export const LightboxComponent: React.FC = ({ // reset zoom status // setResetZoom((r) => !r); // setZoomed(false); - if (lightboxSettings?.resetZoomOnNav) { + if (resetZoomOnNav) { setZoom(1); } setResetPosition((r) => !r); } oldDisplayMode.current = displayMode; - }, [displayMode, lightboxSettings?.resetZoomOnNav]); + }, [displayMode, resetZoomOnNav]); const selectIndex = (e: React.MouseEvent, i: number) => { setIndex(i); @@ -635,7 +652,7 @@ export const LightboxComponent: React.FC = ({ label={intl.formatMessage({ id: "dialogs.lightbox.scale_up.label", })} - checked={lightboxSettings?.scaleUp ?? false} + checked={scaleUp} disabled={displayMode === GQL.ImageLightboxDisplayMode.Original} onChange={(v) => setScaleUp(v.currentTarget.checked)} /> @@ -655,7 +672,7 @@ export const LightboxComponent: React.FC = ({ label={intl.formatMessage({ id: "dialogs.lightbox.reset_zoom_on_nav", })} - checked={lightboxSettings?.resetZoomOnNav ?? false} + checked={resetZoomOnNav} onChange={(v) => setResetZoomOnNav(v.currentTarget.checked)} /> @@ -674,10 +691,7 @@ export const LightboxComponent: React.FC = ({ onChange={(e) => setScrollMode(e.target.value as GQL.ImageLightboxScrollMode) } - value={ - lightboxSettings?.scrollMode ?? - GQL.ImageLightboxScrollMode.Zoom - } + value={scrollMode} className="btn-secondary mx-1 mb-1" >