diff --git a/.codacy.yml b/.codacy.yml index 34ee336..82d75f1 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -5,4 +5,9 @@ exclude_paths: - "pnpm-lock.yaml" - "rimsort-original/**" - "tests/**" - - "**/qwebchannel.js" \ No newline at end of file + - "**/qwebchannel.js" + # SARIF formatting reads reported files as UTF-8; binary artwork is not source. + - "**/*.png" + - "**/*.ico" + - "**/*.icns" + - ".codacy/**" \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 786ca18..3928397 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,12 @@ permissions: jobs: build-linux: runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] + env: + UV_NO_SYNC: '1' steps: - uses: actions/checkout@v7 @@ -43,10 +49,10 @@ jobs: chmod +x /usr/local/bin/appimagetool - name: Install Dependencies - run: uv sync --locked --dev --extra steam + run: uv sync --locked --dev ${{ matrix.workshop == 'NoWorkshop' && '--no-install-package pxsteamdl' || '--extra steam' }} - name: Build with Nuitka - run: just build-release-bundled + run: just build-release-bundled --workshop ${{ matrix.workshop }} - name: Verify glibc floor run: | @@ -58,35 +64,51 @@ jobs: test "$(printf '%s\nGLIBC_2.35\n' "$max" | sort -V | tail -1)" = "GLIBC_2.35" - name: Package tar.gz - run: tar -czf PxModRim-Linux.tar.gz -C dist entrypoint.dist/ + run: tar -czf PxModRim-Linux-${{ matrix.workshop }}.tar.gz -C dist entrypoint.dist/ + + - name: Rename Linux packages + run: | + set -euo pipefail + mv dist/PxModRim-x86_64.AppImage dist/PxModRim-Linux-x86_64-${{ matrix.workshop }}.AppImage + for f in dist/*.deb; do + [ -f "$f" ] || continue + mv "$f" "${f%.deb}-${{ matrix.workshop }}.deb" + done + for f in dist/*.rpm; do + [ -f "$f" ] || continue + mv "$f" "${f%.rpm}-${{ matrix.workshop }}.rpm" + done - name: Upload artifact (tar.gz) uses: actions/upload-artifact@v7 with: - name: PxModRim-Linux - path: PxModRim-Linux.tar.gz + name: PxModRim-Linux-${{ matrix.workshop }} + path: PxModRim-Linux-${{ matrix.workshop }}.tar.gz - name: Upload artifact (AppImage) uses: actions/upload-artifact@v7 with: - name: PxModRim-Linux-AppImage - path: dist/PxModRim-x86_64.AppImage + name: PxModRim-Linux-AppImage-${{ matrix.workshop }} + path: dist/PxModRim-Linux-x86_64-${{ matrix.workshop }}.AppImage - name: Upload artifact (deb) uses: actions/upload-artifact@v7 with: - name: PxModRim-Linux-Deb - path: dist/*.deb + name: PxModRim-Linux-Deb-${{ matrix.workshop }} + path: dist/*-${{ matrix.workshop }}.deb - name: Upload artifact (rpm) uses: actions/upload-artifact@v7 with: - name: PxModRim-Linux-RPM - path: dist/*.rpm - + name: PxModRim-Linux-RPM-${{ matrix.workshop }} + path: dist/*-${{ matrix.workshop }}.rpm build-flatpak: needs: [build-linux] runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] container: image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-24.08 options: --privileged @@ -96,25 +118,30 @@ jobs: - name: Download Linux dist uses: actions/download-artifact@v8 with: - name: PxModRim-Linux + name: PxModRim-Linux-${{ matrix.workshop }} path: dist-archive - name: Extract Linux dist run: | mkdir -p dist - tar -xzf dist-archive/PxModRim-Linux.tar.gz -C dist + tar -xzf dist-archive/PxModRim-Linux-${{ matrix.workshop }}.tar.gz -C dist - name: Build Flatpak bundle uses: flatpak/flatpak-github-actions/flatpak-builder@v6 with: manifest-path: packaging/flatpak/com.github.PyXiion.PxModRim.yaml - bundle: com.github.PyXiion.PxModRim.flatpak + bundle: PxModRim-Linux-${{ matrix.workshop }}.flatpak build-bundle: true upload-artifact: true - artifact-name: PxModRim-Flatpak - + artifact-name: PxModRim-Flatpak-${{ matrix.workshop }} build-windows: runs-on: windows-latest + strategy: + fail-fast: false + matrix: + workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] + env: + UV_NO_SYNC: '1' steps: - uses: actions/checkout@v7 @@ -139,7 +166,7 @@ jobs: tool: just@1.40.0 - name: Install Dependencies - run: uv sync --locked --dev --extra steam + run: uv sync --locked --dev ${{ matrix.workshop == 'NoWorkshop' && '--no-install-package pxsteamdl' || '--extra steam' }} - name: Install NSIS shell: pwsh run: | @@ -154,23 +181,31 @@ jobs: & $makensis /VERSION - name: Build with Nuitka - run: just build-release-bundled + run: just build-release-bundled --workshop ${{ matrix.workshop }} + + - name: Rename Windows installer + shell: bash + run: mv dist/PxModRim-Setup.exe dist/PxModRim-Windows-${{ matrix.workshop }}-Setup.exe - name: Upload artifact (dist) uses: actions/upload-artifact@v7 with: - name: PxModRim-Windows + name: PxModRim-Windows-${{ matrix.workshop }} path: dist/entrypoint.dist/* - name: Upload artifact (installer) uses: actions/upload-artifact@v7 with: - name: PxModRim-Windows-Installer - path: dist/PxModRim-Setup.exe - + name: PxModRim-Windows-Installer-${{ matrix.workshop }} + path: dist/PxModRim-Windows-${{ matrix.workshop }}-Setup.exe build-macos: runs-on: macos-latest + strategy: + fail-fast: false + matrix: + workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] env: + UV_NO_SYNC: '1' MACOS_SIGNING_CONFIGURED: >- ${{ secrets.MACOS_CERT != '' && secrets.MACOS_CERT_PASSWORD != '' && @@ -202,10 +237,10 @@ jobs: tool: just@1.40.0 - name: Install Dependencies - run: uv sync --locked --dev --extra steam + run: uv sync --locked --dev ${{ matrix.workshop == 'NoWorkshop' && '--no-install-package pxsteamdl' || '--extra steam' }} - name: Build with Nuitka - run: just build-release-bundled + run: just build-release-bundled --workshop ${{ matrix.workshop }} - name: Sign and notarize macOS app if: env.MACOS_SIGNING_CONFIGURED == 'true' @@ -258,82 +293,52 @@ jobs: security delete-keychain "$keychain_path" rm -f "$certificate" "$notary_zip" + - name: Rename macOS packages + run: | + set -euo pipefail + mv dist/PxModRim-macOS.zip dist/PxModRim-macOS-${{ matrix.workshop }}.zip + mv dist/PxModRim-macOS.dmg dist/PxModRim-macOS-${{ matrix.workshop }}.dmg + - name: Upload artifact (dmg) uses: actions/upload-artifact@v7 with: - name: PxModRim-macOS-DMG - path: dist/PxModRim-macOS.dmg + name: PxModRim-macOS-DMG-${{ matrix.workshop }} + path: dist/PxModRim-macOS-${{ matrix.workshop }}.dmg - name: Upload artifact (zip) uses: actions/upload-artifact@v7 with: - name: PxModRim-macOS-ZIP - path: dist/PxModRim-macOS.zip - + name: PxModRim-macOS-ZIP-${{ matrix.workshop }} + path: dist/PxModRim-macOS-${{ matrix.workshop }}.zip release: needs: [build-linux, build-flatpak, build-windows, build-macos] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') steps: - - uses: actions/download-artifact@v8 - with: - name: PxModRim-Linux - path: linux-targz - - - uses: actions/download-artifact@v8 - with: - name: PxModRim-Linux-AppImage - path: linux-appimage - - - uses: actions/download-artifact@v8 - with: - name: PxModRim-Linux-Deb - path: linux-deb - - - uses: actions/download-artifact@v8 - with: - name: PxModRim-Linux-RPM - path: linux-rpm - - - uses: actions/download-artifact@v8 - with: - name: PxModRim-Flatpak - path: flatpak - - - uses: actions/download-artifact@v8 - with: - name: PxModRim-Windows - path: windows - - - uses: actions/download-artifact@v8 - with: - name: PxModRim-Windows-Installer - path: windows-installer - - - uses: actions/download-artifact@v8 - with: - name: PxModRim-macOS-DMG - path: macos-dmg - - - uses: actions/download-artifact@v8 + - name: Download all artifacts + uses: actions/download-artifact@v8 with: - name: PxModRim-macOS-ZIP - path: macos-zip + pattern: PxModRim-* + path: artifacts + merge-multiple: false - name: Package Windows release - run: 7z a PxModRim-Windows.zip ./windows/* + run: | + for variant in NoWorkshop SteamWorkshop NativeWorkshop; do + 7z a "PxModRim-Windows-${variant}.zip" "./artifacts/PxModRim-Windows-${variant}/*" + done - name: Create Release uses: softprops/action-gh-release@v3 with: files: | - linux-targz/PxModRim-Linux.tar.gz - linux-appimage/PxModRim-x86_64.AppImage - linux-deb/*.deb - linux-rpm/*.rpm - flatpak/com.github.PyXiion.PxModRim.flatpak - PxModRim-Windows.zip - windows-installer/PxModRim-Setup.exe - macos-dmg/PxModRim-macOS.dmg - macos-zip/PxModRim-macOS.zip + artifacts/PxModRim-Linux-*/*.tar.gz + artifacts/PxModRim-Linux-AppImage-*/*.AppImage + artifacts/PxModRim-Linux-Deb-*/*.deb + artifacts/PxModRim-Linux-RPM-*/*.rpm + artifacts/PxModRim-Flatpak-*/*.flatpak + PxModRim-Windows-*.zip + artifacts/PxModRim-Windows-Installer-*/*-Setup.exe + artifacts/PxModRim-macOS-DMG-*/*.dmg + artifacts/PxModRim-macOS-ZIP-*/*.zip generate_release_notes: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfd1be7..83409cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,3 +58,22 @@ jobs: export QT_QPA_PLATFORM=offscreen fi just test + + workshop-backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: cf-workers + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: "22" + cache: npm + cache-dependency-path: cf-workers/package-lock.json + - name: Install Worker dependencies + run: npm ci + - name: Check TypeScript + run: npm run check + - name: Exercise bundled Worker with D1 + run: npm test diff --git a/AGENTS.md b/AGENTS.md index b6b2522..700f0be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,10 +28,21 @@ src/pxmodrim/ ## Download plugins Downloads are plugins; the UI only talks to `core/downloads/manager.py` (`DownloadManager`, always present, plugin name `downloads`). - Sources subclass `core.downloads.Downloader` (`core/downloads/steam.py` = `steam_downloader`, the only one today) and register with the manager in `setup()`. Third-party imports stay inside the source's own module; `_app.py` imports it lazily and skips it if disabled or missing. -- UI plugins: `downloads_ui` (Downloads tab, source-agnostic, shown when any downloader exists), `steamworkshop` (Steam WebView, needs `steam_downloader`). -- Disable at runtime: `PX_DISABLED_PLUGINS=steam_downloader,steamworkshop,downloads_ui` (comma-separated). +- Catalog: `workshop_catalog` consumes the read-only catalog API with constructor-injected configuration/client factory; it resolves transitive download plans and delegates files to `DownloadManager`, without activating mods. `AppConfig.workshop_catalog_url` defaults to `https://api.modrim.pyxiion.dev`; an empty string disables catalog network requests. Defaulted config fields load from older files without a schema migration. +- UI plugins: `downloads_ui` (Downloads tab, source-agnostic, shown when any downloader exists), `steamworkshop` (Steam WebView, needs `steam_downloader`), `workshop_ui` (native QML Workshop browser, needs `workshop_catalog` and `downloads`). +- Disable at runtime: `PX_DISABLED_PLUGINS=steam_downloader,steamworkshop,downloads_ui,workshop_catalog,workshop_ui` (comma-separated). Disabling the catalog also omits its UI. +- Installed catalog state uses discovered mods' `published_file_id` (About/PublishedFileId.txt or numeric folder); only downloader-managed local copies have known sync times. Steam-client Workshop copies are installed, but cannot be marked outdated without a known sync timestamp. Downloading places mods in Local mods and never subscribes or activates them. +- Catalog requests batch installed metadata by 100 IDs and resolution by 50 mod roots / 10 collection roots; large Update all selections retain dependency-first order and deduplicate shared dependencies across requests. - New source: subclass `Downloader`, register in `_app.py`, add its name to the `downloaders` list; UI needs no change. +## Plugin API +Core and the shell (`ui/window`, `ui/panels`) never name a concrete plugin; only `_app.py` (composition root) does. +- Settings: `PluginConfig(ctx.config_service, name, Struct, legacy={old_config_json_key: field})` in `setup()` → `plugins/.json`; subscribe to `.changed`. +- Settings UI: a UI plugin calls `app_ctx.add_settings_section(factory)` (one Settings tab each); subclass `ui.settings_section.PluginConfigSection` with `title` + `source` (QML whose root has `required property QtObject section`, reading `section.initial` and calling `section.set(key, value)`). Edits apply on Save only. +- Views: `RailView` subclasses on `app_ctx.add_rail_view`; `open_route(path)` handles `modrim:///…`; `lists_downloads = True` marks the download queue. +- `steam_downloader_ui` holds the Steam download settings section. +- Build variants: `packaging/build.py --workshop {NoWorkshop,SteamWorkshop,NativeWorkshop}` bakes `workshop-variant.txt` into the package, which `_app.py` turns into disabled plugins, and `_variant_exclusions` leaves the matching modules out of the Nuitka build. `NoWorkshop` omits PxSteamDL, the Downloads tab and both browsers (CI syncs it with `--no-install-package pxsteamdl`); `SteamWorkshop` omits the native browser; `NativeWorkshop` omits the web browser and WebEngine. CI builds all three on every OS. Build jobs set `UV_NO_SYNC=1` so `uv run` preserves the explicitly synced variant environment; both Workshop variants sync with `--extra steam`. + ## Key conventions - `from __future__ import annotations` in every `.py` file - No comments unless explaining _why_. _What_ comments allowed for long blocks. @@ -58,7 +69,7 @@ Output `inject.js` is gitignored; always build before running. ## Layer dependency checker `just check-deps` (`scripts/check-deps.py`) enforces strict import rules via pydeps. -Defines 24 groups with explicit ALLOWED dependency matrices. +Defines groups with explicit ALLOWED dependency matrices, including `core.workshop`. If a new import violates boundaries, update `scripts/check-deps.py`'s `ALLOWED` dict. ## CI matrix diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b23467..32baae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,17 +5,58 @@ All notable changes to PxModRim will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] + +## [0.3.0] - 2026-10-06 +### Changed +- Workshop collections show their members' total size in MB and the game versions all members support (cards, banners and details) when the server knows them; the catalog stores both per collection once the members have been read (expiring after a day), and hides them otherwise. +- Workshop cards are shorter and show a two-line title, a plain-text summary, game-version compatibility and a single primary action; Discover shows wide collection banners above compact popular mods, and Installed is a multi-column grid of compact tiles with an active checkbox beside each title and an Update button. +- Workshop Back walks the detail history (a dependency's Back returns to the mod you came from) and is labelled with the previous page. All navigation goes through `modrim://` links — rail views, view shortcuts, Settings (`modrim://settings`) and Workshop tabs and pages (e.g. `modrim://workshop/mod/`); opening a Workshop page from another one keeps Back history like a browser. + +- Plugin settings live in each plugin's own `plugins/.json` (`steam_downloader.json`, `workshop_catalog.json`), seeded once from the old `config.json` keys; plugins add their own Settings tabs, so the Settings dialog and the main window no longer refer to specific plugins. +- Workshop pages refresh in place when only item details change, instead of rebuilding every card. +- Collection members and a mod's required mods use the same rows as the Installed tab (active checkbox, thumbnail, author and size, Download/Update); collection members fill a multi-column grid above the description. +- Workshop detail pages: no boxes around the description and member lists; collections show the description first, with small "Jump to mods" / "Back to top" links when the description is long. The "Supports …" line is gone (versions join the tag line, e.g. "Mod · 1.6") and the incompatibility warning is just "Does not list your game version." + ### Fixed +- Picked collection API responses now expose the stored `featured_rank` in lists, details and PUT results, so metadata editors can preserve the collection's existing featured ordering. +- On Wayland, QML tooltips, menus and combo box lists open again: every QML surface's window gets its top-level window as transient parent. +- Closing Settings no longer logs a burst of `Cannot read property … of null` QML errors. +- Collection download buttons show `Preparing…`, `Queued` or `Downloading` while the collection's mods are being planned or downloaded, instead of offering the download again. +- Collection banners show Installed (or Complete download when partly installed) as soon as the list loads, instead of only after opening the collection. +- Collections that declare the game version themselves (e.g. a `1.6` tag) are no longer flagged "No common version" because one member lists older versions; members without listed versions no longer constrain a collection's versions. Worker migration `0007_collection_compatibility.sql` recomputes the stored summaries. +- Cancel or Save in Settings no longer crashes the app (the dialog's QML was unloaded while its click handler was still running). +- Workshop catalog refreshes remove deleted, private, banned and other non-public Steam items from cached details and search, while failed refreshes preserve cached items and picked collections. +- Workshop Installed lists refresh immediately when the installed set changes while retaining the five-minute cache for unchanged sets; Deactivate removes every active copy, and Activate only this pack keeps dependents when a redundant copy is removed. +- Workshop downloads queued during auto-update no longer disappear after an earlier cancellation, and unavailable dependencies show a warning without preventing public mod details from opening. +- Organizer rule-editor UI tests wait for the modal window to become active before clicking its controls, avoiding lost initial clicks on macOS. - QML menus, combo box dropdowns and tooltips open in their own window, so they are no longer clipped by the widget that hosts them (the header's launch-strategy menu and help menu). +- Workshop thumbnails and detail previews request display-sized Steam CDN images, avoid loading hidden previews and collages, and preserve existing URL queries and fragments when resizing. +- Workshop supported versions are deduplicated, sorted numerically, and shown once rather than repeated among category tags. +- Collection detail pages show member-image collages when no collection preview is available, including cached responses without thumbnail metadata. +- Closing the Workshop browser disconnects its core event subscriptions before Qt deletes its models, so queue cancellation during application shutdown no longer calls a deleted model. +- App header logo SVGs place decorative orbital arcs behind the PxModRim text, eliminating the accidental strikethrough appearance across letters at smaller sizes. +- Workshop Mods, Collections and Installed lists only keep the visible cards (and their images) alive, and hidden lists no longer keep their cards in memory, which cut memory use when switching tabs by tens of megabytes; the list scroll position is kept when returning from a detail page. +- Workshop Installed tab reuses the installed-mod list for 5 minutes instead of re-downloading and re-decoding about 2000 mods on every visit, and list cards no longer render their full descriptions (only the detail page does), so memory stays flat when switching tabs. +- Workshop cached Installed results reuse the displayed mod objects after unchanged refreshes and retain disk-loaded results in memory, avoiding a second full installed-mod list and repeated cache decoding. +- Workshop cards create button and badge tooltips only while hovered, reducing card memory without changing their appearance, action colours or accessible labels. - Qt Quick Controls default to the customizable Basic style on every platform; UI tests allow slower CI rendering and wait for organizer rule-window activation before interacting. ### Added +- Native QML Workshop browser: Discover, Mods, Collections and Installed views, catalog filters and detail pages, dependency-aware mod/collection downloads into Local mods, compatibility warnings and updates for downloader-managed copies. Downloads queue in the background (progress in the header, results in Downloads) so browsing stays usable, pages show remembered results immediately and refresh in the background, and the Installed tab loads 48 mods at a time. Catalog URL defaults to `https://api.modrim.pyxiion.dev` and can be changed or emptied to disable network requests; no catalog descriptions execute as HTML and downloads never activate mods. +- Favourite Workshop collections: a star on collection banners and pages adds the collection to the Workshop's Favourites tab (`modrim://workshop/favourites`, newest first, searchable). Favourites are stored locally in `plugins/workshop_favourites.json` with the last seen catalog details, so the tab works without a network round trip. - Settings and Help are pinned to the bottom of the left rail (the header no longer has a Settings button). The Help menu has Report issue, Log & system info, Open logs folder, Keyboard shortcuts, Check for updates and About, so they no longer need the Alt-only menu bar. - Settings > Steam Workshop: `Proxy` (empty uses `https_proxy`/`all_proxy` from the environment), `Connect timeout` and `Stall timeout` for Workshop downloads. Changing them logs in to Steam again before the next download. - Launch workflow: Play shows `Launching…` then `Running` and is disabled while RimWorld runs (tracked by process, for Steam launches too); a toast reports when the game closes or exits with a non-zero code. - Settings > Launch: game arguments (also passed through `steam://run` for the Steam strategy), a wrapper command for direct launch (`KEY=VALUE` env vars, `%command%` placeholder, e.g. `gamemoderun %command%`), and what the window does after launch (stay open, minimize while the game runs, close). - Pre-launch prompts for a mod list with errors, unsaved changes (saved before launch), and a game that is already running. Each has a "Don't ask again" checkbox and a Settings toggle. - The left rail's collapsed/expanded state is remembered between sessions. +- Native Workshop cards display approval percentage with thumb icons, formatted vote counts, score, and mod file size. +- Installed Workshop mod cards and detail views provide Activate/Deactivate toggles alongside installed badges, keeping mods safe from accidental activation during download. +- Workshop tag filtering uses a searchable dropdown populated from known mod and collection tags, replacing free-text exact matching. +- Navigating back from Workshop mod details preserves the current tab, search query, active filters, and scroll position. +- Workshop and Mod Info share the same sanitized description pipeline and selectable rich-text description renderer. +- Release builds come in three variants across Linux, macOS and Windows (artifact names end in the variant): `NoWorkshop` (plain mod sorter without the Steam downloader, Downloads tab or any Workshop browser, and without building PxSteamDL), `SteamWorkshop` (downloader with the Steam web browser) and `NativeWorkshop` (downloader with the native browser, no WebEngine). +- Fully downloaded Workshop collections show Activate all / Deactivate all and Activate only this pack (deactivates every other Workshop mod, keeps local mods), matching the Steam web integration. Workshop buttons are colour-coded: Download blue, Update orange, Activate green, Deactivate red. ### Changed - Updated PxSteamDL to 0.3.0: failed updates keep the previous item installed, concurrent downloads of one item are serialized by a lock, transient network failures are retried, and a failed item-details request fails only its batch instead of the whole download. Download progress uses the unpacked byte counters and cancellation uses `Result.cancelled`. diff --git a/cf-workers/.gitignore b/cf-workers/.gitignore new file mode 100644 index 0000000..10a7744 --- /dev/null +++ b/cf-workers/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.wrangler/ +.dev.vars* +dist/ diff --git a/cf-workers/README.md b/cf-workers/README.md index 8c04ed9..9b503a9 100644 --- a/cf-workers/README.md +++ b/cf-workers/README.md @@ -1,150 +1,203 @@ -# RimSort Steam Workshop CF Worker - -Worker that resolves RimWorld Steam Workshop mod dependency trees. Primary data source is Steam WebAPI ( -`IPublishedFileService/GetDetails/v1`), with ScrapingAnt HTML scraping as fallback. D1 caches results. Optional -Cloudflare Queue handles background refresh. - -## Exports - -| Export | Trigger | Purpose | -|-------------|------------------|----------------------------------------------------------------------------| -| `fetch` | HTTP request | `GET /` — health check; `GET /deps?id=` — dependency tree | -| `scheduled` | Cron | One `QueryFiles` page fetch + one `GetDetails` batch (200) scrape per tick | -| `queue` | Cloudflare Queue | Background re-scrape of incomplete trees | - -## Environment bindings - -| Binding | Required | Description | -|-----------------|---------------|---------------------------------------------------| -| `STEAM_API_KEY` | Yes | Steam WebAPI key (fallback: ScrapingAnt only) | -| `DB` | Yes | D1 database binding | -| `SCRAPI` | Fallback only | ScrapingAnt API key | -| `SCRAPE_QUEUE` | Optional | Cloudflare Queue binding for background rescrapes | -| `USE_QUEUE` | Optional | Set to `"true"` to enable queue path | -| `CONTROL_KEYS` | `/purge` only | JSON array of admin keys, e.g. `["abc123"]` | - -## D1 schema - -### `items` — mod cache - -```sql -CREATE TABLE items ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL DEFAULT '', - is_collection INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT '', - updated_at INTEGER NOT NULL DEFAULT 0, - package_id TEXT NOT NULL DEFAULT '', - deps TEXT NOT NULL DEFAULT '' -); +# PxModRim Workshop backend + +TypeScript Cloudflare Worker for the native Workshop browser and the existing dependency resolver. Public catalog data comes from Steam; PxModRim-picked collections are managed in D1. There is no demo catalog or production seed data. + +This is the backend only. Installed state, comparing installed timestamps against Workshop metadata, downloading files, and activation remain client responsibilities. The Worker neither subscribes a Steam account nor downloads mod archives. + +## Local development with Wrangler + +Requires Node.js 22+ and npm. Run these commands from `cf-workers/`: + +```sh +npm ci +npm run db:migrate +npm run dev ``` -`deps` is a comma-separated list of Steam workshop IDs. Collections (`is_collection=1`) have `deps=''` when they appear as sub-dependencies. Root collections are an exception — `ensureCollectionRootDeps` re-fetches the root's children from Steam API and writes them to `deps`. +`wrangler.local.jsonc` binds a local D1 database; its `local-only` database ID is deliberately not a production database ID. The migration script uses `--local`. The health check and picked-collection listing work without Steam credentials. -### `crawl_state` — cron pagination cursor +For Steam-backed requests, create a gitignored `.dev.vars`: -```sql -CREATE TABLE crawl_state ( - id INTEGER PRIMARY KEY DEFAULT 1, - cursor TEXT NOT NULL DEFAULT '*', - todo_ids TEXT NOT NULL DEFAULT '', - total_processed INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL DEFAULT 0 -); +```dotenv +STEAM_API_KEY="your-steam-web-api-key" +CONTROL_KEYS='["a-long-random-admin-token"]' ``` -Single-row table (id=1). `todo_ids` is comma-separated IDs from the current `QueryFiles` page awaiting scrape. Seeded on -first access if missing. +Do not distribute `CONTROL_KEYS` to application clients. They authorize picked-collection writes and the existing cache-purge endpoint. + +```sh +curl 'http://localhost:8787/' +curl 'http://localhost:8787/catalog/collections?source=picked' +curl 'http://localhost:8787/catalog/mods?version=1.6&sort=popular&limit=24' +``` + +Verification: + +```sh +npm run check # strict TypeScript checking +npm run build # Wrangler deployment dry-run; bundles dist/worker.js +npm test # builds first, then runs the bundled Worker in Miniflare/workerd with D1 +``` -## Status codes +Tests intercept Steam HTTP requests with explicit fixtures. They exercise metadata boundaries, collection kinds, pagination, authentication, curation persistence, dependency expansion, cache expiry, error handling, and the existing `/deps` response. They do not demonstrate live Steam credentials or a remote deployment. -| status | Meaning | -|----------------------------|--------------------------------| -| `OK` | Successfully scraped | -| `PENDING` | Not yet scraped | -| `PRIVATE` | Steam result=15 or hidden page | -| `DELETED` | Not in API response | -| `ERROR_RESULT_{code}` | Steam returned non-1 result | -| `INVALID_PAGE` | HTML scrape returned no title | -| `SCRAPE_ERROR_HTTP_{code}` | ScrapingAnt HTTP error | +## Deployment -## Data flow +Production Wrangler configuration remains user-managed. Point its `main` at `cf-workers/worker.ts` and its D1 `migrations_dir` at `cf-workers/migrations`, resolving both relative to that configuration file. Keep the existing real database ID, routes, cron schedule, and optional queue bindings. Apply the additive migrations before deploying: +```sh +npx wrangler d1 migrations apply DB --remote --config /path/to/production/wrangler.toml +npx wrangler deploy --config /path/to/production/wrangler.toml ``` -Request /deps?id=X - │ - ├─ prewarmCacheFromD1(DB, X) - │ BFS traversal via D1 batch SELECT (99/batch, 200 per step) - │ Populates GLOBAL_RAM_CACHE (LRUMap, 10k cap) - │ - ├─ buildTree(X) - │ Walks cache: fresh OK → itemsMap; expired/missing → missingIds - │ - ├─ scrapeInline(env, missingIds) - │ BFS batch scrape with two tiers: - │ 1. fetchSteamApiDetails — batched GetDetails (200/batch, 800 cap) - │ 2. scrapeViaScrapingAnt — HTML fallback per-ID - │ Results → saveToDB → GLOBAL_RAM_CACHE - │ - ├─ (optional queue enqueue if missing remain) - │ - └─ JSON response { rootId, totalItemsLoaded, isComplete, items } + +These commands change production; local development and `npm run build` do not execute them. Set secrets using `wrangler secret put NAME --config /path/to/production/wrangler.toml`. No production database ID or secret is checked into this directory. + +| Binding | Requirement | Purpose | +|---|---|---| +| `DB` | Required | Dependency cache, catalog metadata, and picked collections | +| `STEAM_API_KEY` | Uncached Steam requests and crawling | Steam WebAPI key | +| `CONTROL_KEYS` | Admin operations | JSON array of non-empty admin tokens | +| `SCRAPI` | Optional, legacy resolver only | ScrapingAnt HTML fallback key | +| `SCRAPE_QUEUE` | Optional | Background dependency refresh queue | +| `USE_QUEUE` | Optional | Set to `"true"` to enable the queue path | + +Exports remain `fetch`, `scheduled`, and `queue`. Catalog routes use Steam directly; they do not scrape Steam HTML or use ScrapingAnt. + +## Catalog API + +All Workshop IDs and file sizes are decimal **strings**, not JavaScript numbers. Mod IDs are bare Workshop IDs; collection IDs are namespaced as `steam:` or `picked:`. Timestamps are Unix seconds. Public results exclude unavailable, private, banned, non-RimWorld, and non-mod/non-collection Steam files. + +Catalog responses are JSON with `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`. Steam query pages (`query_cache`) and metadata (`catalog_items`) are stored in D1, and browsing never waits for a refresh: a query page older than five minutes and a mod or collection detail older than fifteen minutes are returned as stored while a background refresh runs (pages are kept for a week). Only data that was never stored waits for Steam. Curated listings are read from D1 on each request, so writes and deletes are not hidden by a public response cache. + +Successful, complete Steam detail refreshes evict cached files that are no longer public, including their local search entries. Subsequent detail reads return 404, and batch/resolve and collection member responses include the evicted IDs in `unavailable_ids`. Failed or incomplete upstream responses retain cached records; PxModRim-picked collections are never evicted by Steam refreshes. Migration `0005_catalog_eviction.sql` connects metadata deletion to the existing search-index/FTS deletion triggers. + +### Discovery and browsing + +| Method | Path | Response | +|---|---|---| +| GET | `/catalog/discover` | `{ mods: Page, collections: Page }`; four mods and up to three mixed-source collections | +| GET | `/catalog/mods` | `Page` | +| GET | `/catalog/collections` | `Page`; `source=all` (default), `steam`, or `picked` | + +A page is `{ items, total, next_cursor }`; `next_cursor: null` ends pagination. Steam totals reflect Steam's query count and can include entries subsequently excluded by public metadata validation. Picked totals count matching stored collections. + +Browsing parameters: + +- `q`: text, at most 200 characters. Steam matches titles/descriptions, **not creator nicknames**. Picked collections also search the curator field; `%` and `_` are literal characters, not SQL wildcards. +- `tag`: exact tag, at most 100 characters. +- `version`: version tag such as `1.6`. +- `sort`: `popular` (default), `updated`, `newest`, `trending`, or `relevance`. +- `limit`: 1–50, default 24. +- `cursor`: use the previous `next_cursor`; omit for the first page. + +Steam sort modes use its corresponding query rankings; trending uses seven days. Picks use `featured_rank` for popular/trending/relevance, creation time for newest, and modification time for updated. Mixed collection pages reserve room for both sources and continue the remaining source when the other is exhausted. Reuse a collection cursor with the same source, search, filters, sort, and limit; mismatched queries return 400. Text matching for picks follows SQLite's built-in `lower()` behavior, not full Unicode case folding. + +Discovery does not accept a continuation cursor. Start the browsing endpoints with their own page size when opening a tab; if consuming discovery's returned page cursors directly, retain their original limits (four mods / three collections). + +### Details and installed-mod metadata + +| Method | Path | Response | +|---|---|---| +| GET | `/catalog/mods/:id` | A mod, or 404 | +| POST | `/catalog/mods/batch` | `{ items: Mod[], unavailable_ids: string[] }` | +| GET | `/catalog/collections/steam/:id` | `{ collection, members, unavailable_ids, is_complete }` | +| GET | `/catalog/collections/picked/:slug` | Same detail envelope | + +Batch request: `{ "ids": ["2009463077", "818773962"] }`, at most 100 IDs. Metadata older than fifteen minutes is refetched from Steam before answering, so the client can compare each mod's `updated_at` against its installed copy. `/catalog/resolve` behaves the same. + +Mods expose title, Steam author/profile, description, previews, Workshop URL, tags, supported versions, publication/update times, file size, subscription count, votes, dependency IDs, package ID when provided by Steam, and incompatibility status. Missing optional data remains `null`; unrated mods do not receive an invented approval percentage. + +Collections expose source, author, description, preview, tags, supported versions, ordered direct member IDs, member count, and timestamps. Supported versions retain the collection's own version declarations (including numeric version tags) and add the intersection of declared member versions; members without version declarations do not restrict that intersection. `no_common_version` is true only when declared member versions conflict and the collection declares no supported version itself. A Steam member may itself be a collection. Detail responses hydrate available direct members only; download resolution expands the complete nested graph. A member-count mismatch or unavailable member makes `is_complete` false. + +Collections without a primary preview include up to four `member_previews` in both listing and detail responses. Detail thumbnails reuse the already hydrated direct members rather than fetching them again. + +Steam descriptions have `description_format: "bbcode"`; picked descriptions use `"text"`. Treat both as untrusted content in clients, not executable HTML. Author names come from `ISteamUser/GetPlayerSummaries`; missing names are `null`, with the Steam ID retained. Do not infer local installed status from any catalog field. + +### Download resolution + +```http +POST /catalog/resolve +Content-Type: application/json + +{"ids":[],"collection_ids":["steam:123456789","picked:starter-pack"]} +``` + +Examples illustrate the request format; replace IDs/slugs with actual public items and existing picks. + +- `ids`: optional array of up to 50 Workshop IDs. +- `collection_ids`: optional array of up to ten namespaced collection IDs. +- At least one root is required. An empty explicit mod selection is valid when collections are present. +- Expands transitive required mods, nested Steam collections, and picked collections. +- Deduplicates IDs and terminates cycles. `mod_ids` is dependency-first where the graph is acyclic; a cycle has no valid topological order. +- Returns `{ roots, items, mod_ids, unavailable_ids, incomplete_collection_ids, is_complete }`. +- `items` uses mod IDs and namespaced collection IDs as keys; `mod_ids` contains only downloadable mods, never collection IDs. +- Missing/private files or incomplete collection membership set `is_complete: false`. A Steam collection ID referring to a mod returns 400. +- Graphs exceeding 1,000 Steam items return 413 rather than silently truncating the download plan. + +The client downloads `mod_ids` and decides how to present an incomplete plan. Resolution does not activate mods or modify subscriptions. + +### Managing PxModRim picks + +`PUT /catalog/collections/picked/:slug` creates or replaces a collection. `DELETE` removes it. Both require `Authorization: Bearer ` matching `CONTROL_KEYS`. Slugs are lowercase letters/digits separated by single dashes, at most 80 characters. + +```json +{ + "title": "Starter pack", + "author": "PxModRim", + "description": "A curated set of quality-of-life mods.", + "preview_url": null, + "tags": ["Quality of Life"], + "supported_versions": ["1.6"], + "member_ids": ["2009463077", "818773962"], + "featured_rank": 10 +} ``` -### Key behaviors +Required: title (1–200 characters), author (1–120), description (up to 20,000), and 1–500 member IDs. Optional: HTTPS preview URL, up to 30 tags (100 characters each), up to 20 numeric version tags, and integer rank 0–1,000,000 (lower ranks first). Duplicate members are removed while retaining their first position. Every member must resolve to an available public RimWorld **mod**, not a collection. Unknown fields are rejected. + +Picked collection JSON includes the stored numeric `featured_rank` in browsing/discovery lists, detail responses, and successful PUT responses; Steam collections omit it. Metadata editors should load and resend that value when replacing an existing pick. Omitting `featured_rank` from a PUT defaults it to `0`, including when replacing a collection. This response field needs no new D1 migration: the rank is already stored in `picked_collections`. -- **Deps from Steam API**: `children` from `GetDetails` is parsed for ALL mods (both individual and collections). The API returns required items as `children` for individual mods, and collection items as `children` for collections. -- **Collection deps stripped**: When saving a mod via `scrapeInline`, collection children are stripped unless the mod is the root (`mod.id === rootId`). This prevents sub-collections from expanding, while the root collection's children are preserved. -- **Root collection fallback**: `ensureCollectionRootDeps` handles pre-cached roots — if a root collection was cached without children (from a previous scrape), it re-fetches them and updates D1 + RAM cache. -- **ScrapingAnt fallback**: Only used when Steam API call fails entirely (null response). Individual mods get - `RequiredItems` HTML parsing for deps; collections get `deps = []`. -- **BFS expansion**: Only from `deps` in cached/scraped results. Since collections have no deps and individual mods have - no deps from Steam API, BFS only expands via ScrapingAnt-fallback individual mods' `RequiredItems`. +Successful PUT returns the stored collection with HTTP 200 and preserves its original `created_at`; DELETE returns 204, or 404 for an absent slug. There is no public write or client-embedded admin token. -## Constants +### Errors and request limits -| Constant | Value | Description | -|------------------------|-------|-----------------------------------------| -| `CACHE_TTL_OK_MOD` | 72h | Fresh OK mod entry lifetime | -| `CACHE_TTL_OK_COLLECTION` | 1h | Fresh OK collection entry lifetime | -| `CACHE_TTL_ERROR` | 1h | Error entry lifetime | -| `QUEUE_DEDUP_TTL_MS` | 60s | Dedup window for re-enqueuing same root | -| `MAX_INLINE_SCRAPE` | 800 | Max mods scraped per `/deps` request | -| `STEAM_API_BATCH_SIZE` | 200 | Mods per GetDetails call | -| `QUERY_MODS_PAGE_SIZE` | 3000 | Mods per QueryFiles call (crawl) | -| `CRAWL_BATCH_SIZE` | 200 | Mods scraped per cron tick | -| `LRU maxSize` | 10000 | GLOBAL_RAM_CACHE capacity | +Errors use `{ "error": { "message": "..." } }`. Invalid inputs return 400, unauthorized writes 401, missing items/routes 404, unsupported methods 405, oversized bodies/graphs 413, and unsupported content types 415. Steam rate limiting, rejected/missing credentials return 503; malformed/unavailable upstream responses return 502. Unexpected server errors return a sanitized 500. -## Crawl system (`scheduled`) +JSON requests require `application/json` and are bounded to 64 KiB, including streamed bodies. Numeric JSON IDs, noncanonical/zero/out-of-range uint64 IDs, malformed cursors, and unknown payload fields are rejected. Secrets are not returned in upstream errors. The backend does not turn unavailable Steam data into a successful empty catalog. -Per tick: +## D1 migrations -1. Read `crawl_state` from D1 -2. If `todo_ids` empty → `queryMods(cursor)` → `findStaleIds` (missing + expired) → store as `todo_ids` -3. Slice 200 from `todo_ids` → `scrapeInline` → increment total -4. Save state. If page consumed + `cursor=""` → reset to `"*"` +- `0001_dependencies.sql`: existing `items` and `crawl_state` tables, created only when absent. Existing dependency data is preserved. +- `0002_catalog.sql`: `catalog_items` metadata cache and `picked_collections` with JSON-array constraints and browsing indexes. No sample rows are inserted. +- `0003_search.sql`: `catalog_index` (mods seen by the catalog) with an FTS5 table, and `index_state` for the crawler. The scheduled handler walks the Workshop newest-updated first; `/catalog/index` reports `ready` once one full pass has finished. After that, mod searches and the popular/updated/newest listings (with tag and version filters) are answered from the index; trending, collections and cursors that do not start with `ix:` still go to Steam. Every mod the catalog fetches for any reason is indexed too. + Full passes also confirm stored mods omitted by Steam's listing through fresh `GetDetails` requests, at most 100 per tick. Public files remain indexed; confirmed unavailable files are evicted from metadata and search. The verification phase walks ids in order, isolates files whose refresh keeps failing so later ids are still checked, and restarts the pass instead of completing while any omitted file is unverified. +- `0004_query_cache.sql`: `query_cache`, the ordered IDs, total and cursor of each Steam query page. Pages and metadata both live in D1, so they survive restarts and deploys and are identical at every edge. +- `0006_collection_summaries.sql`: `collection_summaries`, facts derived from a collection's members and written when its details are read, once every member is a known mod: `total_size` (decimal bytes, null if any member size is unknown) and `supported_versions` (the intersection of declared member versions). Listings read them without touching members; rows expire after 24 hours and triggers drop them when the collection's members change or it is deleted. Collections without a fresh row report `total_size: null` and their own versions. +- `0007_collection_compatibility.sql`: distinguishes conflicting member versions from missing declarations via a stored `no_common_version` flag, and clears summaries computed under the earlier rules. Apply it before deploying the updated Worker. -Cron schedule is configured in `wrangler.toml` (not in repo — user-managed). +The catalog cache is separate from `items`: the legacy resolver intentionally strips nested collection children in some paths; catalog resolution retains them. -## Queue system +## Existing dependency resolver -Enabled when `USE_QUEUE="true"` and `SCRAPE_QUEUE` binding exists. Handler sends a single `{ rootId }` message per -incomplete tree. Queue consumer runs `scrapeInline` + `buildTree`; if still incomplete, re-enqueues with 30s -`deliveryDelay`. 60s dedup via `RECENTLY_QUEUED` Map. +`GET /deps?id=` retains `{ rootId, totalItemsLoaded, isComplete, items }`. Each item retains `{ id, title, is_collection, is_available, status, deps, package_id }`. `GET /` still returns `ok`. -## Endpoints +The resolver prewarms its 10,000-entry RAM cache from D1, traverses dependencies, and scrapes missing/expired entries inline. Steam `GetDetails` children supply both mod requirements and collection members. HTML scraping through ScrapingAnt is only a fallback when Steam detail fetching fails entirely. Non-root collection children remain stripped in this legacy path; root collection children are preserved/refetched as before. -### `GET /deps?id=` +| Setting | Value | +|---|---| +| Fresh mod cache | 72 hours | +| Fresh collection / error cache | 1 hour | +| Inline scrape cap | 800 items | +| Legacy Steam detail batch / cron scrape batch | 200 items | +| Cron query page | 3,000 items | +| Queue deduplication window | 60 seconds | -- Response: `{ rootId, totalItemsLoaded, isComplete, items: { [id]: {...} } }` -- Each item: `{ id, title, is_collection, is_available, status, deps, package_id }` +Stored statuses remain `OK`, `PENDING`, `PRIVATE`, `DELETED`, `ERROR_RESULT_`, `INVALID_PAGE`, and `SCRAPE_ERROR_HTTP_`. -### `GET /purge?id=&key=` +The scheduled handler fetches a Workshop query page when needed, processes one batch, and persists `crawl_state`. The optional queue sends `{ rootId }`; its consumer refreshes the tree and re-enqueues incomplete work with Cloudflare's `delaySeconds: 30`. Cron/queue provisioning remains in production Wrangler configuration. -- Purges the cached response for a specific mod from the edge cache. -- Requires `key` matching a value in `CONTROL_KEYS` env var. -- Response: `Purged ` or `401 Unauthorized`. +`GET /purge?id=&key=` retains the existing authenticated edge-response purge behavior. It does not delete catalog metadata or picks; catalog metadata expires separately. -### `GET /` +## Steam contracts -- Response: `ok` +[IPublishedFileService documentation](https://partner.steamgames.com/doc/webapi/IPublishedFileService) defines `QueryFiles` parameters passed via `input_json`. Matching file type is 0 for items and 1 for collections, while returned `EWorkshopFileType` is 0 for mods and **2** for collections; these are different enums. Rich metadata/children fields follow the [Steam published-file protobuf definitions](https://github.com/SteamDatabase/Protobufs/blob/master/steam/steammessages_publishedfile.steamclient.proto). diff --git a/cf-workers/catalog.ts b/cf-workers/catalog.ts new file mode 100644 index 0000000..5aaa683 --- /dev/null +++ b/cf-workers/catalog.ts @@ -0,0 +1,367 @@ +import { deletePick, getPick, listPicks, parsePickInput, putPick, validatePickSlug } from './picks'; +import { indexStatus, searchMods } from './search'; +import { attachMemberPreviews, getSteamItems, querySteam, validateSteamId } from './steam'; +import type { SteamQuery, SteamSort } from './steam'; +import { HttpError } from './types'; +import type { CatalogCollection, CatalogItem, CatalogMod, CatalogPage, Env } from './types'; + +const MAX_BODY_BYTES = 64 * 1024; +const MAX_GRAPH_ITEMS = 1000; +const SORTS: SteamSort[] = ['popular', 'updated', 'newest', 'trending', 'relevance']; + +interface CollectionCursor { + key: string; + steam: string | null; + picked: number | null; + steam_total: number; +} + +function json(value: unknown, status = 200): Response { + return Response.json(value, { + status, + headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }, + }); +} + +function integer(value: string | null, fallback: number, min: number, max: number): number { + if (value === null) return fallback; + if (!/^\d+$/.test(value)) throw new HttpError(400, 'Invalid pagination number'); + const result = Number(value); + if (!Number.isSafeInteger(result) || result < min || result > max) throw new HttpError(400, 'Pagination number is out of range'); + return result; +} + +function queryOptions(url: URL): SteamQuery { + const query = (url.searchParams.get('q') ?? '').trim(); + const tag = url.searchParams.get('tag'); + const version = url.searchParams.get('version'); + const sort = url.searchParams.get('sort') ?? (query ? 'relevance' : 'popular'); + const cursor = url.searchParams.get('cursor') ?? '*'; + if (query.length > 200 || (tag !== null && (!tag.trim() || tag.length > 100)) + || (version !== null && !/^\d+\.\d+(?:\.\d+)?$/.test(version)) || cursor.length > 8192) { + throw new HttpError(400, 'Invalid search, tag, version or cursor'); + } + if (!SORTS.includes(sort as SteamSort)) throw new HttpError(400, 'Unsupported sort'); + if (sort === 'relevance' && !query) throw new HttpError(400, 'Relevance sorting requires a search query'); + return { query, tag, version, sort: sort as SteamSort, limit: integer(url.searchParams.get('limit'), 24, 1, 50), cursor }; +} + +async function readJson(request: Request): Promise> { + if (request.headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase() !== 'application/json') { + throw new HttpError(415, 'Use application/json'); + } + const reader = request.body?.getReader(); + if (!reader) throw new HttpError(400, 'A JSON body is required'); + const decoder = new TextDecoder(); + let size = 0; + let text = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_BODY_BYTES) { await reader.cancel(); throw new HttpError(413, 'JSON body exceeds 64 KiB'); } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + let value: unknown; + try { value = JSON.parse(text); } catch { throw new HttpError(400, 'Invalid JSON'); } + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new HttpError(400, 'JSON body must be an object'); + return value as Record; +} + +function onlyFields(body: Record, fields: string[]): void { + if (Object.keys(body).some(key => !fields.includes(key))) throw new HttpError(400, 'Unexpected request field'); +} + +function idsFrom(value: unknown, max: number): string[] { + if (!Array.isArray(value) || !value.length || value.length > max) throw new HttpError(400, `Provide between 1 and ${max} Workshop IDs`); + for (const id of value as unknown[]) { + if (typeof id !== 'string') throw new HttpError(400, 'Workshop IDs must be strings, not JSON numbers'); + validateSteamId(id); + } + return [...new Set(value as string[])]; +} + +function requireMethod(request: Request, methods: string[]): void { + if (!methods.includes(request.method)) throw new HttpError(405, `Allowed methods: ${methods.join(', ')}`); +} + +function requireAdmin(request: Request, env: Env): void { + const authorization = request.headers.get('Authorization'); + if (!authorization?.startsWith('Bearer ') || !authorization.slice(7)) throw new HttpError(401, 'A valid admin Bearer token is required'); + let keys: unknown; + try { keys = JSON.parse(env.CONTROL_KEYS ?? '[]'); } catch { throw new HttpError(503, 'Admin keys are not configured correctly'); } + if (!Array.isArray(keys) || !keys.every(key => typeof key === 'string' && key.length)) throw new HttpError(503, 'Admin keys are not configured correctly'); + if (!keys.includes(authorization.slice(7))) throw new HttpError(401, 'A valid admin Bearer token is required'); +} + +function encodeCursor(cursor: CollectionCursor): string { + return btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(cursor)))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function decodeCursor(value: string, key: string): CollectionCursor { + let cursor: unknown; + try { + if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error('Invalid base64'); + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const bytes = Uint8Array.from(atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')), char => char.charCodeAt(0)); + cursor = JSON.parse(new TextDecoder().decode(bytes)); + } catch { throw new HttpError(400, 'Invalid collection cursor'); } + if (!cursor || typeof cursor !== 'object' || !('key' in cursor) || cursor.key !== key + || !('steam' in cursor) || (cursor.steam !== null && (typeof cursor.steam !== 'string' || !cursor.steam || cursor.steam.length > 4096)) + || !('picked' in cursor) || (cursor.picked !== null && (typeof cursor.picked !== 'number' || !Number.isSafeInteger(cursor.picked) || cursor.picked < 0)) + || !('steam_total' in cursor) || typeof cursor.steam_total !== 'number' || !Number.isSafeInteger(cursor.steam_total) || cursor.steam_total < 0) { + throw new HttpError(400, 'Collection cursor does not match this query'); + } + return cursor as CollectionCursor; +} + +async function modPage(env: Env, ctx: ExecutionContext, query: SteamQuery): Promise> { + return await searchMods(env.DB, query) ?? await querySteam(env, ctx, 'mod', query); +} + +async function collectionPage(env: Env, ctx: ExecutionContext, url: URL, query: SteamQuery): Promise> { + const source = url.searchParams.get('source') ?? 'all'; + if (!['all', 'steam', 'picked'].includes(source)) throw new HttpError(400, 'Source must be all, steam or picked'); + const key = JSON.stringify([source, query.query, query.tag, query.version, query.sort, query.limit]); + const cursor = query.cursor === '*' ? { key, steam: source === 'picked' ? null : '*', picked: source === 'steam' ? null : 0, steam_total: 0 } : decodeCursor(query.cursor, key); + if ((source === 'steam' && cursor.picked !== null) || (source === 'picked' && cursor.steam !== null)) { + throw new HttpError(400, 'Collection cursor source does not match this query'); + } + const pickLimit = cursor.steam === null ? query.limit : Math.max(1, Math.floor(query.limit / 2)); + const picked = source === 'steam' ? { items: [], total: 0, next_offset: null } : await listPicks(env.DB, { + query: query.query, tag: query.tag, version: query.version, + sort: query.sort === 'updated' || query.sort === 'newest' ? query.sort : 'featured', + limit: pickLimit, offset: cursor.picked ?? 0, + }); + const pickedItems = cursor.picked === null ? [] : picked.items; + const steamLimit = query.limit - pickedItems.length; + let steam: CatalogPage = { items: [], total: cursor.steam_total, next_cursor: cursor.steam }; + if (cursor.steam !== null && steamLimit > 0) { + steam = await querySteam(env, ctx, 'collection', { ...query, limit: steamLimit, cursor: cursor.steam }); + } + const next: CollectionCursor = { + key, steam: steam.next_cursor, picked: cursor.picked === null ? null : picked.next_offset, steam_total: steam.total, + }; + return { + items: await attachCollectionSummaries(env.DB, await attachMemberPreviews(env, [...pickedItems, ...steam.items])), + total: (source === 'steam' ? 0 : picked.total) + steam.total, + next_cursor: next.steam === null && next.picked === null ? null : encodeCursor(next), + }; +} + +const SUMMARY_TTL_MS = 24 * 60 * 60 * 1000; +const MAX_SIZE = 2n ** 63n - 1n; + +interface SummaryRow { id: string; total_size: string | null; supported_versions: string; no_common_version: number } + +// Totals and versions are denormalised from the members; they expire so a member update cannot leave them wrong for long. +async function attachCollectionSummaries(db: D1Database, collections: CatalogCollection[]): Promise { + const summaries = new Map(); + const cutoff = Date.now() - SUMMARY_TTL_MS; + for (let offset = 0; offset < collections.length; offset += 90) { + const chunk = collections.slice(offset, offset + 90); + const rows = await db.prepare( + `SELECT id, total_size, supported_versions, no_common_version FROM collection_summaries WHERE computed_at > ? AND id IN (${chunk.map(() => '?').join(',')})`, + ).bind(cutoff, ...chunk.map(collection => collection.id)).all(); + for (const row of rows.results) summaries.set(row.id, row); + } + return collections.map(collection => { + const declared = [...new Set([...collection.supported_versions, ...collection.tags.filter(tag => /^\d+\.\d+(?:\.\d+)?$/.test(tag))])]; + const summary = summaries.get(collection.id); + if (!summary) return { ...collection, supported_versions: declared, total_size: null, no_common_version: false }; + const versions = [...new Set([...declared, ...JSON.parse(summary.supported_versions) as string[]])]; + return { ...collection, total_size: summary.total_size, supported_versions: versions, no_common_version: declared.length === 0 && Boolean(summary.no_common_version) }; + }); +} + +// Stored only once every member is a known mod. Undeclared member versions cannot disprove compatibility. +async function recordCollectionSummary(db: D1Database, collection: CatalogCollection, members: CatalogItem[], complete: boolean): Promise { + const mods = members.filter((member): member is CatalogMod => member.kind === 'mod'); + if (!complete || mods.length !== members.length || mods.length !== collection.member_count) { + await db.prepare('DELETE FROM collection_summaries WHERE id = ?').bind(collection.id).run(); + return; + } + let total: bigint | null = 0n; + for (const mod of mods) { + total = total === null || mod.file_size === null || !/^\d+$/.test(mod.file_size) ? null : total + BigInt(mod.file_size); + } + if (total !== null && total > MAX_SIZE) total = null; + const declaredVersions = mods.map(mod => mod.supported_versions).filter(versions => versions.length > 0); + const versions = declaredVersions.length + ? declaredVersions.reduce((common, next) => common.filter(version => next.includes(version))) + : []; + const noCommonVersion = declaredVersions.length > 0 && versions.length === 0; + await db.prepare( + `INSERT INTO collection_summaries (id, total_size, supported_versions, no_common_version, computed_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET total_size = excluded.total_size, supported_versions = excluded.supported_versions, no_common_version = excluded.no_common_version, computed_at = excluded.computed_at`, + ).bind(collection.id, total === null ? null : total.toString(), JSON.stringify(versions), Number(noCommonVersion), Date.now()).run(); +} + +async function collectionDetails(env: Env, ctx: ExecutionContext, collection: CatalogCollection): Promise { + const { items, unavailable_ids } = await getSteamItems(env, collection.member_ids, ctx); + const members: CatalogItem[] = []; + for (const id of collection.member_ids) { + const item = items.get(id); + if (item) members.push(item); + } + const isComplete = unavailable_ids.length === 0 && collection.member_count === collection.member_ids.length; + await recordCollectionSummary(env.DB, collection, members, isComplete); + const [summarised] = await attachCollectionSummaries(env.DB, [collection]); + const [withPreviews] = await attachMemberPreviews(env, [summarised!], items); + return json({ collection: withPreviews!, members, unavailable_ids, is_complete: isComplete }); +} + +async function resolve(env: Env, body: Record): Promise { + onlyFields(body, ['ids', 'collection_ids']); + const roots = body.ids === undefined || (Array.isArray(body.ids) && body.ids.length === 0) + ? [] : idsFrom(body.ids, 50); + const collectionIds = body.collection_ids === undefined ? [] : body.collection_ids; + if (!Array.isArray(collectionIds) || collectionIds.length > 10) throw new HttpError(400, 'Provide at most 10 collection IDs'); + if (!roots.length && !collectionIds.length) throw new HttpError(400, 'Provide at least one root mod or collection'); + const items: Record = {}; + const pending = new Set(roots); + const pickedRoots: CatalogCollection[] = []; + for (const value of collectionIds as unknown[]) { + if (typeof value !== 'string') throw new HttpError(400, 'Collection IDs must be strings'); + if (value.startsWith('steam:')) { const id = value.slice(6); validateSteamId(id); pending.add(id); } + else if (value.startsWith('picked:')) { + const slug = value.slice(7); validatePickSlug(slug); + const collection = await getPick(env.DB, slug); + if (!collection) throw new HttpError(404, 'Picked collection not found'); + items[collection.id] = collection; pickedRoots.push(collection); + collection.member_ids.forEach(id => pending.add(id)); + } else throw new HttpError(400, 'Collection IDs must start with steam: or picked:'); + } + const visited = new Set(); + const unavailable = new Set(); + const incompleteCollections = new Set(); + while (pending.size) { + const batch = [...pending].slice(0, 100); + for (const id of batch) { pending.delete(id); visited.add(id); } + if (visited.size > MAX_GRAPH_ITEMS) throw new HttpError(413, `Dependency graph exceeds ${MAX_GRAPH_ITEMS} Workshop items`); + const result = await getSteamItems(env, batch); + result.unavailable_ids.forEach(id => unavailable.add(id)); + for (const item of result.items.values()) { + items[item.id] = item; + if (item.kind === 'collection' && item.member_count !== item.member_ids.length) incompleteCollections.add(item.id); + const children = item.kind === 'mod' ? item.dependencies : item.member_ids; + for (const child of children) if (!visited.has(child)) pending.add(child); + } + if (visited.size + pending.size > MAX_GRAPH_ITEMS) throw new HttpError(413, `Dependency graph exceeds ${MAX_GRAPH_ITEMS} Workshop items`); + } + for (const value of collectionIds as string[]) { + if (value.startsWith('steam:') && items[value] === undefined && !unavailable.has(value.slice(6))) { + throw new HttpError(400, 'A collection ID refers to an individual mod'); + } + } + const ordered: string[] = []; + const orderedSeen = new Set(); + function visit(id: string): void { + const item = items[id] ?? items[`steam:${id}`]; + if (!item || orderedSeen.has(item.id)) return; + orderedSeen.add(item.id); + for (const child of item.kind === 'mod' ? item.dependencies : item.member_ids) visit(child); + if (item.kind === 'mod') ordered.push(item.id); + } + roots.forEach(visit); + for (const value of collectionIds as string[]) if (value.startsWith('steam:')) visit(value); + pickedRoots.forEach(collection => collection.member_ids.forEach(visit)); + return json({ roots: [...roots, ...new Set(collectionIds as string[])], items, mod_ids: ordered, unavailable_ids: [...unavailable], incomplete_collection_ids: [...incompleteCollections], is_complete: unavailable.size === 0 && incompleteCollections.size === 0 }); +} + +async function route(request: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(request.url); + if (url.pathname === '/catalog/discover') { + requireMethod(request, ['GET']); + const query = queryOptions(url); + if (query.cursor !== '*') throw new HttpError(400, 'Use the browsing endpoints for pagination'); + const [mods, collections] = await Promise.all([ + modPage(env, ctx, { ...query, limit: 4 }), + collectionPage(env, ctx, url, { ...query, limit: 3 }), + ]); + return json({ mods, collections }); + } + if (url.pathname === '/catalog/mods') { + requireMethod(request, ['GET']); + const query = queryOptions(url); + return json(await modPage(env, ctx, query)); + } + if (url.pathname === '/catalog/index') { + requireMethod(request, ['GET']); + return json(await indexStatus(env.DB)); + } + if (url.pathname === '/catalog/collections') { + requireMethod(request, ['GET']); + return json(await collectionPage(env, ctx, url, queryOptions(url))); + } + if (url.pathname === '/catalog/mods/batch') { + requireMethod(request, ['POST']); + const body = await readJson(request); onlyFields(body, ['ids']); + const ids = idsFrom(body.ids, 100); + const result = await getSteamItems(env, ids); + const items: CatalogMod[] = []; + const unavailable = new Set(result.unavailable_ids); + for (const id of ids) { + const item = result.items.get(id); + if (item?.kind === 'mod') items.push(item); else unavailable.add(id); + } + return json({ items, unavailable_ids: [...unavailable] }); + } + if (url.pathname === '/catalog/resolve') { + requireMethod(request, ['POST']); + return await resolve(env, await readJson(request)); + } + const modMatch = /^\/catalog\/mods\/([^/]+)$/.exec(url.pathname); + if (modMatch) { + requireMethod(request, ['GET']); + const id = modMatch[1]!; validateSteamId(id); + const result = await getSteamItems(env, [id], ctx); + const item = result.items.get(id); + if (item?.kind !== 'mod') throw new HttpError(404, 'Public RimWorld mod not found'); + return json(item); + } + const collectionMatch = /^\/catalog\/collections\/(steam|picked)\/([^/]+)$/.exec(url.pathname); + if (collectionMatch) { + const source = collectionMatch[1]!; + const id = collectionMatch[2]!; + if (source === 'steam') { + requireMethod(request, ['GET']); validateSteamId(id); + const result = await getSteamItems(env, [id], ctx); + const item = result.items.get(id); + if (item?.kind !== 'collection') throw new HttpError(404, 'Public RimWorld collection not found'); + return await collectionDetails(env, ctx, item); + } + validatePickSlug(id); + requireMethod(request, ['GET', 'PUT', 'DELETE']); + if (request.method === 'PUT') { + requireAdmin(request, env); + const input = parsePickInput(await readJson(request)); + const members = await getSteamItems(env, input.member_ids); + if (members.unavailable_ids.length || [...members.items.values()].some(item => item.kind !== 'mod')) { + throw new HttpError(400, 'Picked collections may only contain available RimWorld mods'); + } + return json(await putPick(env.DB, id, input)); + } + if (request.method === 'DELETE') { + requireAdmin(request, env); + if (!await deletePick(env.DB, id)) throw new HttpError(404, 'Picked collection not found'); + return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } }); + } + const collection = await getPick(env.DB, id); + if (!collection) throw new HttpError(404, 'Picked collection not found'); + return await collectionDetails(env, ctx, collection); + } + throw new HttpError(404, 'Catalog endpoint not found'); +} + +export async function handleCatalogRequest(request: Request, env: Env, ctx: ExecutionContext): Promise { + try { + return await route(request, env, ctx); + } catch (error) { + if (error instanceof HttpError) return json({ error: { message: error.message } }, error.status); + console.error('[CATALOG] Internal request failure', error instanceof Error ? `${error.name}: ${error.message}` : String(error)); + return json({ error: { message: 'Internal catalog error' } }, 500); + } +} diff --git a/cf-workers/indexer.ts b/cf-workers/indexer.ts new file mode 100644 index 0000000..fa7f0e2 --- /dev/null +++ b/cf-workers/indexer.ts @@ -0,0 +1,134 @@ +import { queryFiles, refreshSteamItems } from './steam'; +import { HttpError, type Env } from './types'; + +const PAGES_PER_TICK = 4; +const PAGE_SIZE = 100; +const FULL_REFRESH_MS = 7 * 24 * 60 * 60 * 1000; +const OVERLAP_MS = 60 * 60 * 1000; +const VERIFY_PREFIX = 'verify:'; + +interface IndexState { + cursor: string; + cycle_started_at: number; + since: number; + last_started_at: number; + full_at: number; + indexed: number; +} + +async function loadState(db: D1Database): Promise { + await db.prepare('INSERT OR IGNORE INTO index_state (id) VALUES (1)').run(); + const row = await db.prepare('SELECT cursor, cycle_started_at, since, last_started_at, full_at, indexed FROM index_state WHERE id = 1').first(); + return row!; +} + +async function saveState(db: D1Database, state: IndexState): Promise { + await db.prepare( + 'UPDATE index_state SET cursor = ?, cycle_started_at = ?, since = ?, last_started_at = ?, full_at = ?, indexed = ?, updated_at = ? WHERE id = 1', + ).bind(state.cursor, state.cycle_started_at, state.since, state.last_started_at, state.full_at, state.indexed, Date.now()).run(); +} + +const ISOLATION_BUDGET = 16; + +function isPerFileFailure(error: unknown): boolean { + return error instanceof HttpError && /incomplete file details|invalid file metadata/.test(error.message); +} + +/** + * Refreshes `ids`. A response that is malformed for some files is bisected so one bad + * file cannot block its neighbours; outages and rate limits propagate instead, and + * bisection stops after `budget.left` requests. + */ +async function refreshIsolating(env: Env, ids: string[], budget: { left: number }): Promise { + if (budget.left <= 0) return false; + budget.left--; + try { + await refreshSteamItems(env, ids); + return true; + } catch (error) { + if (!isPerFileFailure(error)) throw error; + if (ids.length === 1) { + console.error(`[INDEX] Verification of ${ids[0]} failed`, (error as Error).message); + return false; + } + } + const middle = ids.length >> 1; + const left = await refreshIsolating(env, ids.slice(0, middle), budget); + const right = await refreshIsolating(env, ids.slice(middle), budget); + return left && right; +} + +function parseVerifyCursor(cursor: string): { failed: boolean; after: string } { + const [, failed, after] = cursor.split(':'); + return { failed: failed === '1', after: after ?? '' }; +} + +/** + * Confirms stored mods that Steam's listing omitted, walking ids in keyset order. + * Ids that keep failing are skipped for the rest of the pass so later ids still get + * checked, but the pass then restarts instead of completing: the index is never + * marked verified while an omitted file is unchecked. + */ +async function verifyUnseenMods(env: Env, state: IndexState): Promise { + const { failed, after } = parseVerifyCursor(state.cursor); + const rows = await env.DB.prepare( + `SELECT i.id FROM catalog_index AS i JOIN catalog_items AS c ON c.id = i.id + WHERE c.updated_at < ? AND i.id > ? ORDER BY i.id LIMIT ?`, + ).bind(state.cycle_started_at, after, PAGE_SIZE).all<{ id: string }>(); + const ids = rows.results.map(row => row.id); + const ok = ids.length === 0 || await refreshIsolating(env, ids, { left: ISOLATION_BUDGET }); + const hasFailure = failed || !ok; + if (ids.length === PAGE_SIZE) { + state.cursor = `${VERIFY_PREFIX}${hasFailure ? 1 : 0}:${ids[ids.length - 1]}`; + return false; + } + if (!hasFailure) return true; + console.warn('[INDEX] Some omitted mods could not be verified; retrying the pass'); + state.cursor = `${VERIFY_PREFIX}0:`; + return false; +} + +async function finishCycle(db: D1Database, state: IndexState, now: number): Promise { + if (state.since === 0) state.full_at = now; + state.last_started_at = state.cycle_started_at; + state.cycle_started_at = 0; + state.cursor = '*'; + console.log(`[INDEX] Cycle complete, ${state.indexed} mods seen in total`); + await saveState(db, state); +} + +/** + * Walks the Workshop newest-updated first. The first cycle covers every mod and + * marks the index ready; later cycles stop once they reach mods that were already + * seen, with a full pass weekly to refresh subscription counts. + */ +export async function indexTick(env: Env): Promise { + if (!env.STEAM_API_KEY) return; + const state = await loadState(env.DB); + const now = Date.now(); + if (state.cycle_started_at === 0) { + state.cycle_started_at = now; + state.cursor = '*'; + state.since = state.full_at > 0 && now - state.full_at < FULL_REFRESH_MS ? Math.max(0, state.last_started_at - OVERLAP_MS) : 0; + } + + for (let page = 0; page < PAGES_PER_TICK && !state.cursor.startsWith(VERIFY_PREFIX); page++) { + const result = await queryFiles(env, 'mod', { query: '', tag: null, version: null, sort: 'updated', limit: PAGE_SIZE, cursor: state.cursor }); + state.indexed += result.items.length; + const reachedSeen = state.since > 0 && result.times_updated.every(time => time * 1000 < state.since); + if (result.next_cursor && !reachedSeen) { + state.cursor = result.next_cursor; + await saveState(env.DB, state); + continue; + } + if (state.since !== 0) { + await finishCycle(env.DB, state, now); + return; + } + state.cursor = `${VERIFY_PREFIX}0:`; + await saveState(env.DB, state); + } + if (!state.cursor.startsWith(VERIFY_PREFIX)) return; + if (await verifyUnseenMods(env, state)) await finishCycle(env.DB, state, now); + else await saveState(env.DB, state); +} diff --git a/cf-workers/migrations/0001_dependencies.sql b/cf-workers/migrations/0001_dependencies.sql new file mode 100644 index 0000000..3c666ac --- /dev/null +++ b/cf-workers/migrations/0001_dependencies.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS items ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + is_collection INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL DEFAULT 0, + package_id TEXT NOT NULL DEFAULT '', + deps TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS crawl_state ( + id INTEGER PRIMARY KEY DEFAULT 1, + cursor TEXT NOT NULL DEFAULT '*', + todo_ids TEXT NOT NULL DEFAULT '', + total_processed INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 +); diff --git a/cf-workers/migrations/0002_catalog.sql b/cf-workers/migrations/0002_catalog.sql new file mode 100644 index 0000000..a9c8f40 --- /dev/null +++ b/cf-workers/migrations/0002_catalog.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS catalog_items ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL CHECK(json_valid(data)), + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS picked_collections ( + slug TEXT PRIMARY KEY, + title TEXT NOT NULL, + author TEXT NOT NULL, + description TEXT NOT NULL, + preview_url TEXT, + tags TEXT NOT NULL CHECK(json_valid(tags) AND json_type(tags) = 'array'), + supported_versions TEXT NOT NULL CHECK(json_valid(supported_versions) AND json_type(supported_versions) = 'array'), + member_ids TEXT NOT NULL CHECK(json_valid(member_ids) AND json_type(member_ids) = 'array'), + featured_rank INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS picked_collections_featured ON picked_collections(featured_rank, slug); +CREATE INDEX IF NOT EXISTS picked_collections_updated ON picked_collections(updated_at DESC, slug); +CREATE INDEX IF NOT EXISTS picked_collections_name ON picked_collections(title COLLATE NOCASE, slug); diff --git a/cf-workers/migrations/0003_search.sql b/cf-workers/migrations/0003_search.sql new file mode 100644 index 0000000..75cb976 --- /dev/null +++ b/cf-workers/migrations/0003_search.sql @@ -0,0 +1,53 @@ +CREATE TABLE IF NOT EXISTS catalog_index ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + author TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL CHECK(json_valid(tags) AND json_type(tags) = 'array'), + tag_text TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + data TEXT NOT NULL CHECK(json_valid(data)), + subscriptions INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + indexed_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS catalog_index_popular ON catalog_index(subscriptions DESC, id); +CREATE INDEX IF NOT EXISTS catalog_index_updated ON catalog_index(updated_at DESC, id); +CREATE INDEX IF NOT EXISTS catalog_index_created ON catalog_index(created_at DESC, id); + +CREATE VIRTUAL TABLE IF NOT EXISTS catalog_fts USING fts5( + title, author, tag_text, description, + content = 'catalog_index', content_rowid = 'rowid', + tokenize = 'unicode61 remove_diacritics 2' +); + +CREATE TRIGGER IF NOT EXISTS catalog_index_ai AFTER INSERT ON catalog_index BEGIN + INSERT INTO catalog_fts(rowid, title, author, tag_text, description) + VALUES (new.rowid, new.title, new.author, new.tag_text, new.description); +END; + +CREATE TRIGGER IF NOT EXISTS catalog_index_ad AFTER DELETE ON catalog_index BEGIN + INSERT INTO catalog_fts(catalog_fts, rowid, title, author, tag_text, description) + VALUES ('delete', old.rowid, old.title, old.author, old.tag_text, old.description); +END; + +CREATE TRIGGER IF NOT EXISTS catalog_index_au AFTER UPDATE ON catalog_index BEGIN + INSERT INTO catalog_fts(catalog_fts, rowid, title, author, tag_text, description) + VALUES ('delete', old.rowid, old.title, old.author, old.tag_text, old.description); + INSERT INTO catalog_fts(rowid, title, author, tag_text, description) + VALUES (new.rowid, new.title, new.author, new.tag_text, new.description); +END; + +CREATE TABLE IF NOT EXISTS index_state ( + id INTEGER PRIMARY KEY CHECK(id = 1), + cursor TEXT NOT NULL DEFAULT '*', + cycle_started_at INTEGER NOT NULL DEFAULT 0, + since INTEGER NOT NULL DEFAULT 0, + last_started_at INTEGER NOT NULL DEFAULT 0, + full_at INTEGER NOT NULL DEFAULT 0, + indexed INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 +); + +INSERT OR IGNORE INTO index_state (id) VALUES (1); diff --git a/cf-workers/migrations/0004_query_cache.sql b/cf-workers/migrations/0004_query_cache.sql new file mode 100644 index 0000000..6c7de7f --- /dev/null +++ b/cf-workers/migrations/0004_query_cache.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS query_cache ( + key TEXT PRIMARY KEY, + ids TEXT NOT NULL CHECK(json_valid(ids) AND json_type(ids) = 'array'), + total INTEGER NOT NULL, + next_cursor TEXT, + fetched_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS query_cache_fetched ON query_cache(fetched_at); diff --git a/cf-workers/migrations/0005_catalog_eviction.sql b/cf-workers/migrations/0005_catalog_eviction.sql new file mode 100644 index 0000000..45f60c9 --- /dev/null +++ b/cf-workers/migrations/0005_catalog_eviction.sql @@ -0,0 +1,3 @@ +CREATE TRIGGER IF NOT EXISTS catalog_items_ad AFTER DELETE ON catalog_items BEGIN + DELETE FROM catalog_index WHERE id = old.id; +END; diff --git a/cf-workers/migrations/0006_collection_summaries.sql b/cf-workers/migrations/0006_collection_summaries.sql new file mode 100644 index 0000000..eb1c36d --- /dev/null +++ b/cf-workers/migrations/0006_collection_summaries.sql @@ -0,0 +1,35 @@ +-- Remove objects of the earlier, renamed 0006_collection_sizes migration, whose triggers would otherwise +-- keep firing on catalog_items after its table is gone. +DROP TRIGGER IF EXISTS collection_sizes_pick_au; +DROP TRIGGER IF EXISTS collection_sizes_pick_ad; +DROP TRIGGER IF EXISTS collection_sizes_item_au; +DROP TRIGGER IF EXISTS collection_sizes_item_ad; +DROP TABLE IF EXISTS collection_sizes; + +-- Denormalised facts derived from a collection's members, written when its details are read. +-- total_size is decimal bytes (TEXT keeps full 64-bit precision) and NULL when any member size is unknown. +CREATE TABLE IF NOT EXISTS collection_summaries ( + id TEXT PRIMARY KEY, + total_size TEXT, + supported_versions TEXT NOT NULL CHECK(json_valid(supported_versions) AND json_type(supported_versions) = 'array'), + computed_at INTEGER NOT NULL +); + +CREATE TRIGGER IF NOT EXISTS collection_summaries_pick_au AFTER UPDATE OF member_ids ON picked_collections +WHEN old.member_ids IS NOT new.member_ids BEGIN + DELETE FROM collection_summaries WHERE id = 'picked:' || old.slug; +END; + +CREATE TRIGGER IF NOT EXISTS collection_summaries_pick_ad AFTER DELETE ON picked_collections BEGIN + DELETE FROM collection_summaries WHERE id = 'picked:' || old.slug; +END; + +CREATE TRIGGER IF NOT EXISTS collection_summaries_item_au AFTER UPDATE OF data ON catalog_items +WHEN json_extract(new.data, '$.kind') = 'collection' + AND json_extract(old.data, '$.member_ids') IS NOT json_extract(new.data, '$.member_ids') BEGIN + DELETE FROM collection_summaries WHERE id = 'steam:' || old.id; +END; + +CREATE TRIGGER IF NOT EXISTS collection_summaries_item_ad AFTER DELETE ON catalog_items BEGIN + DELETE FROM collection_summaries WHERE id = 'steam:' || old.id; +END; diff --git a/cf-workers/migrations/0007_collection_compatibility.sql b/cf-workers/migrations/0007_collection_compatibility.sql new file mode 100644 index 0000000..09688e3 --- /dev/null +++ b/cf-workers/migrations/0007_collection_compatibility.sql @@ -0,0 +1,4 @@ +ALTER TABLE collection_summaries ADD COLUMN no_common_version INTEGER NOT NULL DEFAULT 0 CHECK(no_common_version IN (0, 1)); + +-- Earlier empty intersections conflated conflicting declarations with unknown versions. +DELETE FROM collection_summaries; diff --git a/cf-workers/package-lock.json b/cf-workers/package-lock.json new file mode 100644 index 0000000..902848f --- /dev/null +++ b/cf-workers/package-lock.json @@ -0,0 +1,2500 @@ +{ + "name": "pxmodrim-workshop-backend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pxmodrim-workshop-backend", + "devDependencies": { + "@cloudflare/workers-types": "5.20261005.1", + "@types/node": "22.20.5", + "miniflare": "5.20261001.0-alpha", + "tsx": "4.23.15", + "typescript": "7.0.2", + "wrangler": "4.147.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20261001.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261001.1.tgz", + "integrity": "sha512-4cgSgDf28JSw/P5Dj5GCS59hzVqS5XnmGAWNkvYHLI6ODU9idGaMMNEuhJXDkEG/lsABmlVlnCgsdh2VbKWepw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20261001.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261001.1.tgz", + "integrity": "sha512-8ulAWruEVouNmEIsQsy9WSSCS9zkLu93W2MTwp5esiFyoPp05BNSVFIFsYSPi1pkFmBBd7fsnwMpbLkaJLaVPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20261001.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261001.1.tgz", + "integrity": "sha512-kZbTZJGrhsMOdqZ2BIybjaBRLZjYsRLWj7mcNw/6Y3hodOhp5MrNzcz4iWGwNVWwyIV+7Pl+/LX5VcgnRqdHOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20261001.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261001.1.tgz", + "integrity": "sha512-oOk3Zj6k/8oP0FJgZBWDn7+BqbsqIMEMaV95pulHPVbwVu4wYoLfQq2hp0vkbCNsCFLqZb7cqixXdrwD54ZIow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20261001.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261001.1.tgz", + "integrity": "sha512-uRxm5W4VyBkoSaoP1BfOuH0873tE+vxsknF4sab6/5YIRvIAufChq3QDp+zlAn67PuGPGcn7W8QraA0t4ucmOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20261005.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20261005.1.tgz", + "integrity": "sha512-QQwZY342US72QGHso/H1CSzn9FWe6IoLZ8TsYyoMnvRHHOnucGuKZRDCyOn43HAkkRQqfRHFw8NP1xe1A7Oj8A==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "5.20261001.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261001.0-alpha.tgz", + "integrity": "sha512-GaimS5mSIOMyvd16ga+e1/QkI8cmp3z35QPHFex0DktqNQf2RVZQwMPQXdyoUreUiFP/0Gpe2MoQInTyMAD5xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.4", + "undici": "7.29.1", + "workerd": "1.20261001.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20261001.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261001.1.tgz", + "integrity": "sha512-d/SIYHFO0PT/wiFZg8in4NpRIxYuFwslX1HdylOtWkBIIUmSpkGFhK820cV84XACFylwJ48xuRoWW/8DWDPsPQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20261001.1", + "@cloudflare/workerd-darwin-arm64": "1.20261001.1", + "@cloudflare/workerd-linux-64": "1.20261001.1", + "@cloudflare/workerd-linux-arm64": "1.20261001.1", + "@cloudflare/workerd-windows-64": "1.20261001.1" + } + }, + "node_modules/wrangler": { + "version": "4.147.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.147.0.tgz", + "integrity": "sha512-pQYRoiq8PTAxphaG69z8+GC1DkSGd19EDZehQ8zxjo/Ko3mRB6Qs1mTrd8ZuKAarLklIjTqr1lUdCK9r4q2hUg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20261001.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20261001.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20261001.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/cf-workers/package.json b/cf-workers/package.json new file mode 100644 index 0000000..0da1b7d --- /dev/null +++ b/cf-workers/package.json @@ -0,0 +1,28 @@ +{ + "name": "pxmodrim-workshop-backend", + "private": true, + "type": "module", + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "check": "tsc --noEmit", + "dev": "wrangler dev --config wrangler.local.jsonc --local", + "build": "wrangler deploy --dry-run --config wrangler.local.jsonc --outdir dist", + "db:migrate": "wrangler d1 migrations apply DB --local --config wrangler.local.jsonc", + "pretest": "npm run build", + "test": "node --import tsx --test tests/*.test.ts" + }, + "devDependencies": { + "@cloudflare/workers-types": "5.20261005.1", + "@types/node": "22.20.5", + "miniflare": "5.20261001.0-alpha", + "tsx": "4.23.15", + "typescript": "7.0.2", + "wrangler": "4.147.0" + }, + "allowScripts": { + "workerd": true, + "esbuild": true + } +} diff --git a/cf-workers/picks.ts b/cf-workers/picks.ts new file mode 100644 index 0000000..84b9351 --- /dev/null +++ b/cf-workers/picks.ts @@ -0,0 +1,235 @@ +import { HttpError } from './types'; +import type { CatalogCollection, PickedCollectionInput, PickListOptions } from './types'; + +interface PickRow { + slug: string; + title: string; + author: string; + description: string; + preview_url: string | null; + tags: string; + supported_versions: string; + member_ids: string; + featured_rank: number; + created_at: number; + updated_at: number; +} + +export function validatePickSlug(slug: string): void { + if (typeof slug !== 'string' || slug.length > 80 || slug !== slug.trim() || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) { + throw new HttpError(400, 'Invalid picked collection slug'); + } +} + +export function parsePickInput(value: unknown): PickedCollectionInput { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new HttpError(400, 'Picked collection input must be an object'); + } + const input = value as Record; + const fields = new Set([ + 'title', 'author', 'description', 'member_ids', 'preview_url', 'tags', + 'supported_versions', 'featured_rank', + ]); + for (const field of Object.keys(input)) { + if (!fields.has(field)) throw new HttpError(400, `Unexpected field: ${field}`); + } + for (const field of ['title', 'author', 'description', 'member_ids']) { + if (!Object.prototype.hasOwnProperty.call(input, field)) { + throw new HttpError(400, `Missing required field: ${field}`); + } + } + if (typeof input.title !== 'string' || input.title.length < 1 || input.title.length > 200) { + throw new HttpError(400, 'title must contain 1 to 200 characters'); + } + if (typeof input.author !== 'string' || input.author.length < 1 || input.author.length > 120) { + throw new HttpError(400, 'author must contain 1 to 120 characters'); + } + if (typeof input.description !== 'string' || input.description.length > 20000) { + throw new HttpError(400, 'description must be a string of at most 20000 characters'); + } + if (!Array.isArray(input.member_ids) || input.member_ids.length < 1 || input.member_ids.length > 500) { + throw new HttpError(400, 'member_ids must contain 1 to 500 Steam IDs'); + } + const memberIds: string[] = []; + const seen = new Set(); + for (const id of input.member_ids) { + if (typeof id !== 'string' || id.length > 20 || id !== id.trim() || + !/^[1-9]\d*$/.test(id) || BigInt(id) > 18446744073709551615n) { + throw new HttpError(400, 'member_ids must contain positive uint64 Steam IDs as decimal strings'); + } + if (!seen.has(id)) { + seen.add(id); + memberIds.push(id); + } + } + + let previewUrl: string | null = null; + if (Object.prototype.hasOwnProperty.call(input, 'preview_url')) { + if (input.preview_url !== null) { + if (typeof input.preview_url !== 'string' || input.preview_url.length > 2048) { + throw new HttpError(400, 'preview_url must be null or an HTTPS URL of at most 2048 characters'); + } + let url: URL; + try { + url = new URL(input.preview_url); + } catch { + throw new HttpError(400, 'preview_url must be a valid HTTPS URL'); + } + if (url.protocol !== 'https:') throw new HttpError(400, 'preview_url must use HTTPS'); + previewUrl = input.preview_url; + } + } + const tags: string[] = []; + if (Object.prototype.hasOwnProperty.call(input, 'tags')) { + if (!Array.isArray(input.tags) || input.tags.length > 30) { + throw new HttpError(400, 'tags must contain at most 30 strings of at most 100 characters'); + } + for (const tag of input.tags) { + if (typeof tag !== 'string' || tag.length > 100) { + throw new HttpError(400, 'tags must contain at most 30 strings of at most 100 characters'); + } + tags.push(tag); + } + } + const versions: string[] = []; + if (Object.prototype.hasOwnProperty.call(input, 'supported_versions')) { + if (!Array.isArray(input.supported_versions) || input.supported_versions.length > 20) { + throw new HttpError(400, 'supported_versions must contain at most 20 versions in major.minor[.patch] form'); + } + for (const version of input.supported_versions) { + if (typeof version !== 'string' || version !== version.trim() || + !/^\d+\.\d+(?:\.\d+)?$/.test(version)) { + throw new HttpError(400, 'supported_versions must contain at most 20 versions in major.minor[.patch] form'); + } + versions.push(version); + } + } + let featuredRank = 0; + if (Object.prototype.hasOwnProperty.call(input, 'featured_rank')) { + if (typeof input.featured_rank !== 'number' || !Number.isInteger(input.featured_rank) || + input.featured_rank < 0 || input.featured_rank > 1000000) { + throw new HttpError(400, 'featured_rank must be an integer from 0 to 1000000'); + } + featuredRank = input.featured_rank; + } + return { + title: input.title, + author: input.author, + description: input.description, + member_ids: memberIds, + preview_url: previewUrl, + tags, + supported_versions: versions, + featured_rank: featuredRank, + }; +} + +function toCollection(row: PickRow): CatalogCollection { + const memberIds = JSON.parse(row.member_ids) as string[]; + return { + id: `picked:${row.slug}`, + kind: 'collection', + source: 'picked', + steam_id: null, + title: row.title, + author: { id: null, name: row.author, profile_url: null }, + description: row.description, + description_format: 'text', + preview_url: row.preview_url, + previews: [], + workshop_url: null, + tags: JSON.parse(row.tags) as string[], + supported_versions: JSON.parse(row.supported_versions) as string[], + featured_rank: row.featured_rank, + created_at: row.created_at, + updated_at: row.updated_at, + member_ids: memberIds, + member_count: memberIds.length, + member_previews: [], + total_size: null, + no_common_version: false, + }; +} + +export async function listPicks( + db: D1Database, + options: PickListOptions, +): Promise<{ items: CatalogCollection[]; total: number; next_offset: number | null }> { + if (!Number.isSafeInteger(options.offset + options.limit)) { + throw new HttpError(400, 'Invalid picked collection list options'); + } + const predicates: string[] = []; + const bindings: string[] = []; + if (options.query !== '') { + predicates.push('(instr(lower(title), lower(?)) > 0 OR instr(lower(author), lower(?)) > 0 OR instr(lower(description), lower(?)) > 0)'); + bindings.push(options.query, options.query, options.query); + } + if (options.tag !== null) { + predicates.push('EXISTS (SELECT 1 FROM json_each(p.tags) WHERE value = ?)'); + bindings.push(options.tag); + } + if (options.version !== null) { + predicates.push('EXISTS (SELECT 1 FROM json_each(p.supported_versions) WHERE value = ?)'); + bindings.push(options.version); + } + const where = predicates.length ? ` WHERE ${predicates.join(' AND ')}` : ''; + const orders = { + featured: 'featured_rank ASC, slug ASC', + updated: 'updated_at DESC, slug ASC', + newest: 'created_at DESC, slug ASC', + name: 'title COLLATE NOCASE ASC, slug ASC', + }; + const [count, page] = await db.batch([ + db.prepare(`SELECT COUNT(*) AS total FROM picked_collections AS p${where}`).bind(...bindings), + db.prepare(`SELECT p.* FROM picked_collections AS p${where} ORDER BY ${orders[options.sort]} LIMIT ? OFFSET ?`) + .bind(...bindings, options.limit, options.offset), + ]); + const total = (count.results[0] as { total: number }).total; + const items = page.results.map(row => toCollection(row as PickRow)); + const nextOffset = options.offset + items.length; + return { items, total, next_offset: nextOffset < total ? nextOffset : null }; +} + +export async function getPick(db: D1Database, slug: string): Promise { + validatePickSlug(slug); + const row = await db.prepare('SELECT * FROM picked_collections WHERE slug = ?').bind(slug).first(); + return row ? toCollection(row) : null; +} + +export async function putPick( + db: D1Database, + slug: string, + input: PickedCollectionInput, +): Promise { + validatePickSlug(slug); + const now = Math.floor(Date.now() / 1000); + const row = await db.prepare(` + INSERT INTO picked_collections ( + slug, title, author, description, preview_url, tags, supported_versions, + member_ids, featured_rank, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(slug) DO UPDATE SET + title = excluded.title, + author = excluded.author, + description = excluded.description, + preview_url = excluded.preview_url, + tags = excluded.tags, + supported_versions = excluded.supported_versions, + member_ids = excluded.member_ids, + featured_rank = excluded.featured_rank, + updated_at = excluded.updated_at + RETURNING * + `).bind( + slug, input.title, input.author, input.description, input.preview_url, + JSON.stringify(input.tags), JSON.stringify(input.supported_versions), + JSON.stringify(input.member_ids), input.featured_rank, now, now, + ).first(); + return toCollection(row!); +} + +export async function deletePick(db: D1Database, slug: string): Promise { + validatePickSlug(slug); + const result = await db.prepare('DELETE FROM picked_collections WHERE slug = ?').bind(slug).run(); + return result.meta.changes > 0; +} diff --git a/cf-workers/search.ts b/cf-workers/search.ts new file mode 100644 index 0000000..b1ae4bd --- /dev/null +++ b/cf-workers/search.ts @@ -0,0 +1,103 @@ +import type { SteamQuery } from './steam'; +import type { CatalogMod, CatalogPage } from './types'; + +const LIST_DESCRIPTION_CHARS = 300; +const FTS_DESCRIPTION_CHARS = 1000; +const MAX_TOKENS = 8; +const MAX_TOKEN_CHARS = 40; +const BATCH_SIZE = 100; +const CURSOR = /^ix:(\d{1,9})$/; + +export interface IndexStatus { + ready: boolean; + indexed: number; + full_at: number | null; + cycle_in_progress: boolean; +} + +export async function indexMods(db: D1Database, mods: CatalogMod[]): Promise { + const now = Date.now(); + const statement = db.prepare( + `INSERT INTO catalog_index (id, title, author, tags, tag_text, description, data, subscriptions, created_at, updated_at, indexed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, author = excluded.author, tags = excluded.tags, tag_text = excluded.tag_text, + description = excluded.description, data = excluded.data, subscriptions = excluded.subscriptions, + created_at = excluded.created_at, updated_at = excluded.updated_at, indexed_at = excluded.indexed_at + WHERE catalog_index.data IS NOT excluded.data`, + ); + for (let offset = 0; offset < mods.length; offset += BATCH_SIZE) { + await db.batch(mods.slice(offset, offset + BATCH_SIZE).map(mod => { + const listed: CatalogMod = { ...mod, description: mod.description.slice(0, LIST_DESCRIPTION_CHARS), previews: [] }; + return statement.bind( + mod.id, mod.title, mod.author.name ?? '', JSON.stringify(mod.tags), mod.tags.join(' '), + mod.description.replace(/\[[^\]]*\]/g, ' ').slice(0, FTS_DESCRIPTION_CHARS), + JSON.stringify(listed), mod.subscriptions ?? 0, mod.created_at ?? 0, mod.updated_at ?? 0, now, + ); + })); + } +} + +export async function indexStatus(db: D1Database): Promise { + try { + const state = await db.prepare('SELECT full_at, cycle_started_at FROM index_state WHERE id = 1') + .first<{ full_at: number; cycle_started_at: number }>(); + const count = await db.prepare('SELECT count(*) AS n FROM catalog_index').first<{ n: number }>(); + return { + ready: (state?.full_at ?? 0) > 0, + indexed: count?.n ?? 0, + full_at: state?.full_at ? state.full_at : null, + cycle_in_progress: (state?.cycle_started_at ?? 0) > 0, + }; + } catch { + return { ready: false, indexed: 0, full_at: null, cycle_in_progress: false }; + } +} + +function matchExpression(text: string): string | null { + const tokens = (text.match(/[\p{L}\p{N}]+/gu) ?? []).slice(0, MAX_TOKENS).map(token => token.slice(0, MAX_TOKEN_CHARS)); + if (!tokens.length) return null; + return tokens.map((token, index) => `"${token}"${index === tokens.length - 1 ? '*' : ''}`).join(' '); +} + +const POPULARITY_BOOST = `(CASE WHEN catalog_index.subscriptions >= 100000 THEN 3.0 + WHEN catalog_index.subscriptions >= 10000 THEN 2.0 + WHEN catalog_index.subscriptions >= 1000 THEN 1.0 ELSE 0.0 END)`; + +const ORDER: Record = { + popular: 'catalog_index.subscriptions DESC, catalog_index.id', + updated: 'catalog_index.updated_at DESC, catalog_index.id', + newest: 'catalog_index.created_at DESC, catalog_index.id', + relevance: `(bm25(catalog_fts, 8.0, 2.0, 2.0, 1.0) - ${POPULARITY_BOOST}), catalog_index.id`, +}; + +/** Returns null when the index cannot answer this query and Steam should. */ +export async function searchMods(db: D1Database, query: SteamQuery): Promise | null> { + const order = ORDER[query.sort]; + const match = query.query ? matchExpression(query.query) : null; + const resumed = CURSOR.exec(query.cursor); + if (!order || (query.query && !match) || (query.sort === 'relevance' && !match) || (query.cursor !== '*' && !resumed)) return null; + if (!(await indexStatus(db)).ready) return null; + const offset = resumed ? Number(resumed[1]) : 0; + + const filters: string[] = []; + const params: (string | number)[] = match ? [match] : []; + for (const tag of new Set([query.tag, query.version].filter((value): value is string => value !== null))) { + filters.push('AND EXISTS (SELECT 1 FROM json_each(catalog_index.tags) WHERE json_each.value = ?)'); + params.push(tag); + } + const from = match + ? `FROM catalog_fts JOIN catalog_index ON catalog_index.rowid = catalog_fts.rowid WHERE catalog_fts MATCH ? ${filters.join(' ')}` + : `FROM catalog_index WHERE 1 = 1 ${filters.join(' ')}`; + try { + const total = await db.prepare(`SELECT count(*) AS n ${from}`).bind(...params).first<{ n: number }>(); + const rows = await db.prepare(`SELECT catalog_index.data AS data ${from} ORDER BY ${order} LIMIT ? OFFSET ?`) + .bind(...params, query.limit, offset).all<{ data: string }>(); + const items = rows.results.map(row => JSON.parse(row.data) as CatalogMod); + const count = total?.n ?? 0; + return { items, total: count, next_cursor: offset + items.length < count && items.length ? `ix:${offset + items.length}` : null }; + } catch (error) { + console.error('Search index query failed', error instanceof Error ? error.message : error); + return null; + } +} diff --git a/cf-workers/steam.ts b/cf-workers/steam.ts new file mode 100644 index 0000000..23f138d --- /dev/null +++ b/cf-workers/steam.ts @@ -0,0 +1,411 @@ +import { indexMods } from './search'; +import { HttpError } from './types'; +import type { Author, CatalogCollection, CatalogItem, CatalogMod, CatalogPage, Env, Preview, SteamFile } from './types'; + +const APP_ID = 294100; +const DETAIL_TTL_MS = 15 * 60 * 1000; +const QUERY_TTL_SECONDS = 5 * 60; +const API_BATCH_SIZE = 100; +const MAX_STEAM_ID = 18446744073709551615n; + +export type SteamSort = 'popular' | 'updated' | 'newest' | 'trending' | 'relevance'; +export interface SteamQuery { + query: string; + tag: string | null; + version: string | null; + sort: SteamSort; + limit: number; + cursor: string; +} + +export function validateSteamId(id: string): void { + if (!/^[1-9]\d{0,19}$/.test(id) || BigInt(id) > MAX_STEAM_ID) { + throw new HttpError(400, 'Workshop IDs must be positive uint64 decimal strings'); + } +} + +function steamUrl(env: Env, service: string, method: string, version = 1): URL { + if (!env.STEAM_API_KEY) throw new HttpError(503, 'STEAM_API_KEY is required for uncached Steam catalog data'); + const url = new URL(`https://api.steampowered.com/${service}/${method}/v${version}/`); + url.searchParams.set('key', env.STEAM_API_KEY); + return url; +} + +const STEAM_ATTEMPTS = 3; +const STEAM_ATTEMPT_TIMEOUT_MS = 4_000; + +async function steamJson(url: URL): Promise { + for (let attempt = 1; ; attempt++) { + const started = Date.now(); + let stage = 'headers'; + let status: number | null = null; + try { + const response = await fetch(url, { signal: AbortSignal.timeout(STEAM_ATTEMPT_TIMEOUT_MS) }); + status = response.status; + if (response.status === 429) throw new HttpError(503, 'Steam API rate limit reached'); + if (response.status === 401 || response.status === 403) throw new HttpError(503, 'Steam API credentials were rejected'); + if (!response.ok) { + stage = 'error-body'; + console.error(`Steam ${url.pathname} HTTP ${status} body: ${(await response.text()).slice(0, 300)}`); + throw new HttpError(502, 'Steam API request failed'); + } + stage = 'body'; + return await response.json(); + } catch (error) { + const retryable = !(error instanceof HttpError) || error.status === 502; + if (!(error instanceof HttpError)) { + const reason = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + console.error(`Steam ${url.pathname} attempt ${attempt}/${STEAM_ATTEMPTS} failed at ${stage} after ${Date.now() - started}ms (status ${status ?? 'none'}): ${reason}`); + } + if (!retryable) throw error; + if (attempt === STEAM_ATTEMPTS) { + if (error instanceof HttpError) throw error; + throw new HttpError(502, 'Steam API is unavailable or returned invalid JSON'); + } + } + } +} + +function responseObject(value: unknown): Record { + if (!value || typeof value !== 'object' || !('response' in value) + || !value.response || typeof value.response !== 'object') { + throw new HttpError(502, 'Steam API returned an invalid response'); + } + return value.response as Record; +} + +function invalidMetadata(): never { + throw new HttpError(502, 'Steam API returned invalid file metadata'); +} + +function fileDetails(response: Record): SteamFile[] { + if (response.total === 0 && response.publishedfiledetails === undefined) return []; + if (!Array.isArray(response.publishedfiledetails)) { + throw new HttpError(502, 'Steam API returned invalid file details'); + } + for (const value of response.publishedfiledetails as unknown[]) { + if (!value || typeof value !== 'object' || !('publishedfileid' in value) + || typeof value.publishedfileid !== 'string' || !('result' in value) || typeof value.result !== 'number') { + throw new HttpError(502, 'Steam API returned invalid file details'); + } + try { validateSteamId(value.publishedfileid); } catch { throw new HttpError(502, 'Steam API returned an invalid Workshop ID'); } + const file = value as Record; + for (const key of ['creator', 'title', 'file_description', 'description', 'short_description', 'preview_url']) { + if (file[key] !== undefined && typeof file[key] !== 'string') invalidMetadata(); + } + for (const key of ['consumer_appid', 'creator_appid', 'file_type', 'time_created', 'time_updated', 'visibility', 'subscriptions', 'num_children']) { + if (file[key] !== undefined && (typeof file[key] !== 'number' || !Number.isSafeInteger(file[key]) || file[key] < 0)) invalidMetadata(); + } + for (const key of ['banned', 'incompatible']) if (file[key] !== undefined && typeof file[key] !== 'boolean') invalidMetadata(); + if (file.file_size !== undefined && !(typeof file.file_size === 'string' && /^\d+$/.test(file.file_size)) + && !(typeof file.file_size === 'number' && Number.isSafeInteger(file.file_size) && file.file_size >= 0)) invalidMetadata(); + for (const [key, required] of [['tags', ['tag']], ['kvtags', ['key', 'value']], ['children', ['publishedfileid']], ['previews', []]] as const) { + const list = file[key]; + if (list === undefined) continue; + if (!Array.isArray(list)) invalidMetadata(); + for (const entry of list as unknown[]) { + if (!entry || typeof entry !== 'object') invalidMetadata(); + const record = entry as Record; + for (const field of required) if (typeof record[field] !== 'string') invalidMetadata(); + for (const field of ['url', 'youtubevideoid']) if (record[field] !== undefined && typeof record[field] !== 'string') invalidMetadata(); + for (const field of ['sortorder', 'preview_type']) if (record[field] !== undefined && (typeof record[field] !== 'number' || !Number.isSafeInteger(record[field]) || record[field] < 0)) invalidMetadata(); + } + } + if (file.vote_data !== undefined) { + if (!file.vote_data || typeof file.vote_data !== 'object') invalidMetadata(); + const votes = file.vote_data as Record; + for (const key of ['votes_up', 'votes_down']) if (votes[key] !== undefined && (typeof votes[key] !== 'number' || !Number.isSafeInteger(votes[key]) || votes[key] < 0)) invalidMetadata(); + } + } + return response.publishedfiledetails as SteamFile[]; +} + +async function authorsFor(env: Env, details: SteamFile[]): Promise> { + const ids = [...new Set(details.map(file => file.creator).filter((id): id is string => typeof id === 'string' && /^[1-9]\d{0,19}$/.test(id)))]; + const authors = new Map(); + for (let offset = 0; offset < ids.length; offset += API_BATCH_SIZE) { + const url = steamUrl(env, 'ISteamUser', 'GetPlayerSummaries', 2); + url.searchParams.set('steamids', ids.slice(offset, offset + API_BATCH_SIZE).join(',')); + const response = responseObject(await steamJson(url)); + if (!Array.isArray(response.players)) throw new HttpError(502, 'Steam API returned invalid creator profiles'); + for (const player of response.players as unknown[]) { + if (!player || typeof player !== 'object' || !('steamid' in player) || typeof player.steamid !== 'string') { + throw new HttpError(502, 'Steam API returned invalid creator profiles'); + } + authors.set(player.steamid, { + id: player.steamid, + name: 'personaname' in player && typeof player.personaname === 'string' ? player.personaname : null, + profile_url: `https://steamcommunity.com/profiles/${player.steamid}/`, + }); + } + } + return authors; +} + +function publicFile(file: SteamFile): boolean { + return file.result === 1 && file.consumer_appid === APP_ID && !file.banned + && (file.visibility === undefined || file.visibility === 0) && (file.file_type === 0 || file.file_type === 2); +} + +function normalized(file: SteamFile, authors: Map): CatalogItem { + const tags = [...new Set((file.tags ?? []).map(tag => tag.tag))]; + const supportedVersions = tags.filter(tag => /^\d+\.\d+(?:\.\d+)?$/.test(tag)); + const children = [...(file.children ?? [])].sort((a,b) => (a.sortorder ?? 0) - (b.sortorder ?? 0)); + const childIds = [...new Set(children.map(child => child.publishedfileid))]; + childIds.forEach(id => { + try { validateSteamId(id); } catch { throw new HttpError(502, 'Steam API returned an invalid child Workshop ID'); } + }); + const previews: Preview[] = []; + for (const preview of [...(file.previews ?? [])].sort((a,b) => (a.sortorder ?? 0) - (b.sortorder ?? 0))) { + if (preview.preview_type === 0 && preview.url) previews.push({ type: 'image', url: preview.url }); + if (preview.preview_type === 1 && preview.youtubevideoid) previews.push({ type: 'video', url: `https://www.youtube.com/watch?v=${encodeURIComponent(preview.youtubevideoid)}` }); + } + const creator = typeof file.creator === 'string' ? file.creator : null; + const common = { + source: 'steam' as const, + title: file.title ?? '', + author: creator ? authors.get(creator) ?? { id: creator, name: null, profile_url: `https://steamcommunity.com/profiles/${creator}/` } : { id: null, name: null, profile_url: null }, + description: file.file_description ?? file.description ?? file.short_description ?? '', + description_format: 'bbcode' as const, + preview_url: file.preview_url || null, + previews, + workshop_url: `https://steamcommunity.com/sharedfiles/filedetails/?id=${file.publishedfileid}`, + tags, + supported_versions: supportedVersions, + created_at: file.time_created ?? null, + updated_at: file.time_updated ?? null, + }; + if (file.file_type === 2) { + return { + ...common, + id: `steam:${file.publishedfileid}`, + kind: 'collection', + steam_id: file.publishedfileid, + member_ids: childIds, + member_count: file.num_children ?? childIds.length, + member_previews: [], + total_size: null, + no_common_version: false, + }; + } + const votes = file.vote_data; + const up = votes?.votes_up ?? 0; + const down = votes?.votes_down ?? 0; + return { + ...common, + id: file.publishedfileid, + kind: 'mod', + file_size: file.file_size === undefined ? null : String(file.file_size), + subscriptions: file.subscriptions ?? null, + votes: votes ? { up, down, positive_percent: up + down > 0 ? Math.round(up / (up + down) * 100) : null } : null, + dependencies: childIds, + package_id: file.kvtags?.find(tag => tag.key === 'packageId')?.value ?? null, + incompatible: file.incompatible ?? false, + }; +} + +async function saveItems(db: D1Database, items: CatalogItem[]): Promise { + const now = Date.now(); + for (let offset = 0; offset < items.length; offset += API_BATCH_SIZE) { + await db.batch(items.slice(offset, offset + API_BATCH_SIZE).map(item => db.prepare( + 'INSERT INTO catalog_items (id, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at', + ).bind(item.kind === 'collection' ? item.steam_id : item.id, JSON.stringify(item), now))); + } + await indexMods(db, items.filter((item): item is CatalogMod => item.kind === 'mod')).catch(error => + console.error('Search index update failed', error instanceof Error ? error.message : error)); +} + +export async function refreshSteamItems(env: Env, ids: string[]): Promise> { + const fetched = new Map(); + const unavailable: string[] = []; + for (let offset = 0; offset < ids.length; offset += API_BATCH_SIZE) { + const chunk = ids.slice(offset, offset + API_BATCH_SIZE); + const url = steamUrl(env, 'IPublishedFileService', 'GetDetails'); + url.searchParams.set('input_json', JSON.stringify({ + publishedfileids: chunk, + includechildren: true, + includetags: true, + includekvtags: true, + includevotes: true, + includeadditionalpreviews: true, + short_description: false, + })); + const response = responseObject(await steamJson(url)); + const details = fileDetails(response); + const returned = new Set(details.map(file => file.publishedfileid)); + if ((response.result !== undefined && response.result !== 1) || details.length !== chunk.length + || returned.size !== chunk.length || chunk.some(id => !returned.has(id))) { + throw new HttpError(502, 'Steam API returned incomplete file details'); + } + for (const file of details) { + if (file.result === 1 && (file.consumer_appid === undefined || file.file_type === undefined)) invalidMetadata(); + } + const files = details.filter(publicFile); + const authors = await authorsFor(env, files); + const items = files.map(file => normalized(file, authors)); + unavailable.push(...details.filter(file => !publicFile(file)).map(file => file.publishedfileid)); + for (const item of items) fetched.set(item.kind === 'collection' ? item.steam_id! : item.id, item); + } + // Do not evict anything if a later Steam batch or creator lookup fails. + await saveItems(env.DB, [...fetched.values()]); + for (let offset = 0; offset < unavailable.length; offset += API_BATCH_SIZE) { + await env.DB.batch(unavailable.slice(offset, offset + API_BATCH_SIZE).map(id => env.DB.prepare( + "DELETE FROM catalog_items WHERE id = ? AND json_extract(data, '$.source') = 'steam'", + ).bind(id))); + } + return fetched; +} + +/** + * With `ctx`, expired rows are returned as they are and refreshed in the background, + * so only IDs that were never stored wait for Steam. Without it expired rows are refetched first. + */ +export async function getSteamItems(env: Env, requestedIds: string[], ctx?: ExecutionContext): Promise<{ items: Map; unavailable_ids: string[] }> { + const ids = [...new Set(requestedIds)]; + const items = new Map(); + const stale: string[] = []; + const cutoff = Date.now() - DETAIL_TTL_MS; + for (let offset = 0; offset < ids.length; offset += 90) { + const chunk = ids.slice(offset, offset + 90); + const rows = await env.DB.prepare(`SELECT id, data, updated_at FROM catalog_items WHERE id IN (${chunk.map(() => '?').join(',')})`) + .bind(...chunk).all<{ id: string; data: string; updated_at: number }>(); + for (const row of rows.results) { + if (row.updated_at > cutoff) items.set(row.id, JSON.parse(row.data) as CatalogItem); + else if (ctx) { items.set(row.id, JSON.parse(row.data) as CatalogItem); stale.push(row.id); } + } + } + if (ctx && stale.length) { + ctx.waitUntil(refreshSteamItems(env, stale).then(() => undefined).catch(error => console.error('Background refresh failed', error instanceof Error ? error.message : error))); + } + const fetched = await refreshSteamItems(env, ids.filter(id => !items.has(id))); + for (const [id, item] of fetched) items.set(id, item); + return { items, unavailable_ids: ids.filter(id => !items.has(id)) }; +} + +const COLLAGE_SIZE = 4; +const COLLAGE_CANDIDATES = 8; + +// Steam gives collections no image; its site shows a collage of member previews. +export async function attachMemberPreviews(env: Env, collections: CatalogCollection[], members?: Map): Promise { + const candidates = collections.map(collection => collection.preview_url ? [] : collection.member_ids.slice(0, COLLAGE_CANDIDATES)); + const ids = [...new Set(candidates.flat())]; + if (!ids.length) return collections; + if (!members) { + try { + members = (await getSteamItems(env, ids)).items; + } catch (error) { + console.error('Collection thumbnails unavailable:', error instanceof Error ? error.message : error); + return collections; + } + } + return collections.map((collection, index) => { + const previews: string[] = []; + for (const id of candidates[index]!) { + const member = members.get(id); + if (member?.kind === 'mod' && member.preview_url) previews.push(member.preview_url); + if (previews.length === COLLAGE_SIZE) break; + } + return previews.length ? { ...collection, member_previews: previews } : collection; + }); +} + +export interface FilesPage { + items: T[]; + total: number; + next_cursor: string | null; + /** Steam update times (seconds) of every file returned, including non-public ones. */ + times_updated: number[]; +} + +export async function queryFiles(env: Env, kind: K, query: SteamQuery): Promise> { + const queryTypes: Record = { popular: 9, updated: 21, newest: 1, trending: 3, relevance: 12 }; + const requiredTags = [...new Set([query.tag, query.version].filter((tag): tag is string => tag !== null))]; + const url = steamUrl(env, 'IPublishedFileService', 'QueryFiles'); + url.searchParams.set('input_json', JSON.stringify({ + query_type: queryTypes[query.sort], + page: 1, + cursor: query.cursor, + numperpage: query.limit, + creator_appid: APP_ID, + appid: APP_ID, + filetype: kind === 'mod' ? 0 : 1, + search_text: query.query, + requiredtags: requiredTags, + match_all_tags: true, + days: 7, + ids_only: false, + return_vote_data: true, + return_tags: true, + return_kv_tags: true, + return_children: true, + return_previews: true, + return_short_description: false, + cache_max_age_seconds: QUERY_TTL_SECONDS, + })); + const response = responseObject(await steamJson(url)); + if (typeof response.total !== 'number' || !Number.isSafeInteger(response.total) || response.total < 0 + || (response.next_cursor !== undefined && typeof response.next_cursor !== 'string')) { + throw new HttpError(502, 'Steam API returned invalid pagination'); + } + const details = fileDetails(response); + const files = details.filter(file => publicFile(file) && file.file_type === (kind === 'mod' ? 0 : 2)); + const authors = await authorsFor(env, files); + const items = files.map(file => normalized(file, authors)); + await saveItems(env.DB, items); + const cursor = typeof response.next_cursor === 'string' ? response.next_cursor : ''; + return { + items, + total: response.total, + next_cursor: details.length && cursor && cursor !== query.cursor ? cursor : null, + times_updated: details.map(file => file.time_updated ?? 0), + } as FilesPage; +} + +const QUERY_KEEP_MS = 7 * 24 * 60 * 60 * 1000; + +async function storedItems(db: D1Database, ids: string[]): Promise> { + const stored = new Map(); + for (let offset = 0; offset < ids.length; offset += 90) { + const chunk = ids.slice(offset, offset + 90); + const rows = await db.prepare(`SELECT id, data FROM catalog_items WHERE id IN (${chunk.map(() => '?').join(',')})`) + .bind(...chunk).all<{ id: string; data: string }>(); + for (const row of rows.results) stored.set(row.id, JSON.parse(row.data) as CatalogItem); + } + return stored; +} + +/** + * Query pages are remembered in D1 as ordered IDs. A remembered page is returned at once, + * however old, and refreshed in the background once it is older than five minutes. + */ +export async function querySteam(env: Env, ctx: ExecutionContext, kind: K, query: SteamQuery): Promise> { + type Page = CatalogPage; + const key = JSON.stringify([kind, query]); + + const refresh = async (): Promise => { + const { items, total, next_cursor } = await queryFiles(env, kind, query); + const ids = items.map(item => item.kind === 'collection' ? item.steam_id! : item.id); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO query_cache (key, ids, total, next_cursor, fetched_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET ids = excluded.ids, total = excluded.total, next_cursor = excluded.next_cursor, fetched_at = excluded.fetched_at`, + ).bind(key, JSON.stringify(ids), total, next_cursor, Date.now()), + env.DB.prepare('DELETE FROM query_cache WHERE fetched_at < ?').bind(Date.now() - QUERY_KEEP_MS), + ]); + return { items, total, next_cursor } as Page; + }; + + const row = await env.DB.prepare('SELECT ids, total, next_cursor, fetched_at FROM query_cache WHERE key = ? AND fetched_at > ?') + .bind(key, Date.now() - QUERY_KEEP_MS).first<{ ids: string; total: number; next_cursor: string | null; fetched_at: number }>(); + if (row) { + const ids = JSON.parse(row.ids) as string[]; + const stored = await storedItems(env.DB, ids); + if (ids.every(id => stored.has(id))) { + if (Date.now() - row.fetched_at > QUERY_TTL_SECONDS * 1000) { + ctx.waitUntil(refresh().catch(error => console.error('Background refresh failed', error instanceof Error ? error.message : error))); + } + return { items: ids.map(id => stored.get(id)!), total: row.total, next_cursor: row.next_cursor } as Page; + } + } + return await refresh(); +} diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts new file mode 100644 index 0000000..4644839 --- /dev/null +++ b/cf-workers/tests/catalog.test.ts @@ -0,0 +1,788 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve as resolvePath } from 'node:path'; +import { after, before, test } from 'node:test'; +import { Miniflare } from 'miniflare'; +import type { CatalogCollection, CatalogItem, CatalogMod, CatalogPage, SteamFile } from '../types'; + +const creator = '76561198000000001'; +function mod(id: string, title: string, children: string[] = [], versions = ['1.6']): SteamFile { + return { + publishedfileid: id, result: 1, consumer_appid: 294100, creator_appid: 294100, + creator, title, file_type: 0, file_description: '[b]A real description format[/b]', + file_size: '4096', time_created: 1700000000, time_updated: 1720000000, + preview_url: 'https://images.example.test/preview.png', + tags: [...versions, 'Quality of life'].map(tag => ({ tag })), + children: children.map((publishedfileid, sortorder) => ({ publishedfileid, sortorder })), + vote_data: { votes_up: 90, votes_down: 10 }, + }; +} +const files: Record = { + '10': mod('10', 'Allow Tool', ['12']), + '11': { ...mod('11', 'Harmony'), kvtags: [{ key: 'packageId', value: 'brrainz.harmony' }] }, + '12': mod('12', 'HugsLib', ['11']), + '13': mod('13', 'RimHUD', ['11']), + '14': mod('14', 'Camera+', ['11'], ['1.5']), + '15': { ...mod('15', 'Another game'), consumer_appid: 730 }, + '16': { publishedfileid: '16', result: 15 }, + '17': { ...mod('17', 'Artwork'), file_type: 3 }, + '20': mod('20', 'Cycle A', ['21']), + '21': mod('21', 'Cycle B', ['20']), + '100': { ...mod('100', 'Steam Collection'), file_type: 2, children: [{ publishedfileid: '101', sortorder: 1 }, { publishedfileid: '10', sortorder: 0 }], num_children: 2 }, + '101': { ...mod('101', 'Nested Collection'), file_type: 2, children: [{ publishedfileid: '13', sortorder: 0 }], num_children: 1 }, + '102': { ...mod('102', 'no-thumb collection'), file_type: 2, preview_url: '', children: [{ publishedfileid: '13', sortorder: 0 }, { publishedfileid: '16', sortorder: 1 }, { publishedfileid: '101', sortorder: 2 }, { publishedfileid: '11', sortorder: 3 }], num_children: 4 }, + '18446744073709551615': { ...mod('18446744073709551615', 'Maximum ID'), file_size: '18446744073709551615', vote_data: { votes_up: 0, votes_down: 0 } }, +}; + +let flakyCalls = 0; +let queryCalls = 0; +const detailFailures = new Map(); + +async function upstream(request: Request): Promise { + const url = new URL(request.url); + if (url.hostname !== 'api.steampowered.com') return new Response('Unexpected upstream', { status: 502 }); + if (url.pathname.includes('GetPlayerSummaries')) { + const players = [{ steamid: creator, personaname: 'Fixture Author' }]; + return Response.json({ response: { players: url.pathname.endsWith('/v1/') ? { player: players } : players } }); + } + const input = JSON.parse(url.searchParams.get('input_json') ?? '{}') as { + publishedfileids?: string[]; filetype?: number; search_text?: string; cursor?: string; numperpage?: number; requiredtags?: string[]; + }; + if (url.pathname.includes('GetDetails')) { + const ids = input.publishedfileids ?? []; + if (ids.some(id => detailFailures.get(id) === 'http')) return new Response('Steam unavailable', { status: 503 }); + if (ids.some(id => detailFailures.get(id) === 'metadata')) { + return Response.json({ response: { publishedfiledetails: ids.map(publishedfileid => ({ publishedfileid, result: 1 })) } }); + } + if (ids.includes('999')) { + return Response.json({ response: { publishedfiledetails: [{ ...mod('999', 'Malformed upstream'), tags: 'not-an-array' }] } }); + } + return Response.json({ response: { publishedfiledetails: ids.filter(id => detailFailures.get(id) !== 'partial') + .map(id => files[id] ?? { publishedfileid: id, result: 9 }) } }); + } + if (url.pathname.includes('QueryFiles')) { + queryCalls++; + if (input.search_text === 'upstream-rate-limit') return new Response('Rate limited', { status: 429 }); + if (input.search_text === 'upstream-flaky' && flakyCalls++ === 0) return new Response('Hiccup', { status: 500 }); + const candidateIds = input.filetype === 1 ? (input.search_text === 'no-thumb' ? ['102'] : ['100', '101']) : ['10', '11', '12', '13', '14']; + const matching = candidateIds.map(id => files[id]!).filter(file => + (file.title ?? '').toLowerCase().includes((input.search_text ?? '').toLowerCase()) + && (input.requiredtags ?? []).every(tag => file.tags?.some(value => value.tag === tag))); + const offset = input.cursor === '*' ? 0 : Number(input.cursor); + const details = matching.slice(offset, offset + (input.numperpage ?? 24)); + const next = offset + details.length; + return Response.json({ response: { total: matching.length, publishedfiledetails: details, next_cursor: next < matching.length ? String(next) : '' } }); + } + return new Response('Unexpected Steam method', { status: 502 }); +} + +let worker: Miniflare; +let db: D1Database; + +function statements(sql: string): string[] { + const result: string[] = []; + let current = ''; + for (const line of sql.split('\n')) { + current += `${line}\n`; + const inTrigger = /CREATE TRIGGER/i.test(current) && !/^END;$/m.test(current); + if (line.trimEnd().endsWith(';') && !inTrigger) { result.push(current.trim()); current = ''; } + } + return result.filter(statement => statement && statement !== ';'); +} + +async function migrate(database: D1Database): Promise { + for (const file of ['0001_dependencies.sql', '0002_catalog.sql', '0003_search.sql', '0004_query_cache.sql', '0005_catalog_eviction.sql', '0006_collection_summaries.sql', '0007_collection_compatibility.sql']) { + const sql = await readFile(resolvePath('migrations', file), 'utf8'); + await database.batch(statements(sql).map(statement => database.prepare(statement))); + } +} + +before(async () => { + worker = new Miniflare({ + workers: [{ + config: { + name: 'catalog-tests', compatibilityDate: '2026-10-05', + manifest: { mainModule: 'worker.js', modules: { 'worker.js': { type: 'esm', contents: await readFile(resolvePath('dist/worker.js'), 'utf8') } } }, + env: { + DB: { type: 'd1', id: 'catalog-tests' }, + STEAM_API_KEY: { type: 'text', value: 'fixture-only-key' }, + CONTROL_KEYS: { type: 'text', value: '["test-admin"]' }, + }, + }, + dev: { outboundService: { type: 'fetcher', handler: upstream } }, + }], + }); + db = await worker.getD1Database('DB'); + await migrate(db); +}); +after(async () => { await worker?.dispose(); }); + +async function request(path: string, method = 'GET', body?: unknown, token?: string) { + return await worker.dispatchFetch(`https://catalog.test${path}`, { + method, + headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +async function createPick(slug: string, memberIds = ['10', '13'], title = 'Starter colony', extra: Record = {}) { + const response = await request(`/catalog/collections/picked/${slug}`, 'PUT', { + title, author: 'PxModRim', description: 'A focused collection.', member_ids: memberIds, + tags: ['Quality of life'], supported_versions: ['1.6'], ...extra, + }, 'test-admin'); + assert.equal(response.status, 200, await response.text()); +} + +test('mod details expose real tag declarations, creators, votes and required IDs', async () => { + const response = await request('/catalog/mods/10'); + assert.equal(response.status, 200); + const detail = await response.json() as CatalogMod; + assert.equal(detail.author.name, 'Fixture Author'); + assert.equal(detail.description_format, 'bbcode'); + assert.equal(detail.description, '[b]A real description format[/b]'); + assert.deepEqual(detail.supported_versions, ['1.6']); + assert.deepEqual(detail.dependencies, ['12']); + assert.deepEqual(detail.votes, { up: 90, down: 10, positive_percent: 90 }); +}); + +test('uint64 IDs and sizes retain their exact string value; unrated is not zero percent', async () => { + const response = await request('/catalog/mods/18446744073709551615'); + assert.equal(response.status, 200); + const detail = await response.json() as CatalogMod; + assert.equal(detail.id, '18446744073709551615'); + assert.equal(detail.file_size, '18446744073709551615'); + assert.equal(detail.votes?.positive_percent, null); +}); + +test('private, foreign-game and non-mod files are not advertised as downloadable mods', async () => { + for (const id of ['15', '16', '17', '100']) assert.equal((await request(`/catalog/mods/${id}`)).status, 404); + const batch = await request('/catalog/mods/batch', 'POST', { ids: ['10', '15', '16', '17', '100'] }); + const value = await batch.json() as { items: CatalogMod[]; unavailable_ids: string[] }; + assert.deepEqual(value.items.map(item => item.id), ['10']); + assert.deepEqual(value.unavailable_ids, ['15', '16', '17', '100']); +}); + +test('Steam browsing filters version tags and paginates without repeating the first page', async () => { + const first = await request('/catalog/mods?version=1.6&limit=2'); + const value = await first.json() as CatalogPage; + assert.deepEqual(value.items.map(item => item.id), ['10', '11']); + assert.equal(value.total, 4); + const second = await request(`/catalog/mods?version=1.6&limit=2&cursor=${encodeURIComponent(value.next_cursor!)}`); + const next = await second.json() as CatalogPage; + assert.deepEqual(next.items.map(item => item.id), ['12', '13']); + assert.equal(next.next_cursor, null); +}); + +test('Steam collection details retain ordered members, including nested collections', async () => { + const response = await request('/catalog/collections/steam/100'); + const value = await response.json() as { collection: CatalogCollection; members: CatalogItem[] }; + assert.equal(response.status, 200); + assert.equal(value.collection.id, 'steam:100'); + assert.equal(Object.hasOwn(value.collection, 'featured_rank'), false); + assert.deepEqual(value.collection.member_ids, ['10', '101']); + assert.deepEqual(value.members.map(item => item.id), ['10', 'steam:101']); + assert.equal(value.members[1]?.kind, 'collection'); +}); + +test('picked collection mutations require authentication and validate members before persistence', async () => { + const body = { title: 'Protected', author: 'PxModRim', description: '', member_ids: ['10'] }; + assert.equal((await request('/catalog/collections/picked/protected', 'PUT', body)).status, 401); + assert.equal((await request('/catalog/collections/picked/protected', 'PUT', body, 'wrong')).status, 401); + assert.equal((await request('/catalog/collections/picked/protected')).status, 404); + for (const invalid of [['15'], ['100'], [10], ['18446744073709551616']]) { + assert.equal((await request('/catalog/collections/picked/protected', 'PUT', { ...body, member_ids: invalid }, 'test-admin')).status, 400); + } + await createPick('protected', ['10', '10', '13']); + const value = await (await request('/catalog/collections/picked/protected')).json() as { collection: CatalogCollection }; + assert.deepEqual(value.collection.member_ids, ['10', '13']); + assert.equal(value.collection.source, 'picked'); + assert.equal(value.collection.workshop_url, null); + assert.equal(value.collection.featured_rank, 0); + assert.equal((await request('/catalog/collections/picked/protected', 'DELETE')).status, 401); + assert.equal((await request('/catalog/collections/picked/protected', 'DELETE', undefined, 'test-admin')).status, 204); + assert.equal((await request('/catalog/collections/picked/protected')).status, 404); +}); + +test('picked search treats wildcard text literally, combines tag/version filters, and preserves creation time', async () => { + await createPick('literal', ['10'], '100% colony', { featured_rank: 1 }); + const before = await db.prepare('SELECT created_at FROM picked_collections WHERE slug = ?').bind('literal').first<{ created_at: number }>(); + await createPick('literal', ['13'], '100% colony updated', { featured_rank: 1 }); + const after = await db.prepare('SELECT created_at FROM picked_collections WHERE slug = ?').bind('literal').first<{ created_at: number }>(); + assert.equal(after?.created_at, before?.created_at); + const response = await request('/catalog/collections?source=picked&q=%25&tag=Quality%20of%20life&version=1.6'); + const value = await response.json() as CatalogPage; + assert.deepEqual(value.items.map(item => item.id), ['picked:literal']); + assert.deepEqual(value.items[0]?.member_ids, ['13']); + assert.equal((await (await request('/catalog/collections?source=picked&q=%25&version=1.5')).json() as CatalogPage).total, 0); +}); + +test('picked ranks round-trip through PUT, detail and ordered lists when editing metadata', async () => { + const slugs = ['rank-first', 'rank-second']; + const body = { + title: 'Ranked collection', author: 'Curator', description: 'Original metadata.', + tags: ['rank-roundtrip'], supported_versions: ['1.6'], member_ids: ['10'], featured_rank: 42, + }; + try { + const createdResponse = await request('/catalog/collections/picked/rank-first', 'PUT', body, 'test-admin'); + assert.equal(createdResponse.status, 200); + const created = await createdResponse.json() as CatalogCollection; + assert.equal(created.featured_rank, 42); + await createPick('rank-second', ['13'], 'Other ranked collection', { tags: ['rank-roundtrip'], featured_rank: 7 }); + for (const sort of ['popular', 'trending', 'relevance']) { + const response = await request(`/catalog/collections?source=picked&tag=rank-roundtrip&q=ranked&sort=${sort}`); + assert.equal(response.status, 200); + const page = await response.json() as CatalogPage; + assert.deepEqual(page.items.map(item => [item.id, item.featured_rank]), [['picked:rank-second', 7], ['picked:rank-first', 42]]); + } + + const detailResponse = await request('/catalog/collections/picked/rank-first'); + assert.equal(detailResponse.status, 200); + const detail = await detailResponse.json() as { collection: CatalogCollection }; + assert.equal(detail.collection.featured_rank, 42); + const updatedResponse = await request('/catalog/collections/picked/rank-first', 'PUT', { + ...body, title: 'Edited collection', description: 'Edited metadata.', featured_rank: detail.collection.featured_rank, + }, 'test-admin'); + assert.equal(updatedResponse.status, 200); + const updated = await updatedResponse.json() as CatalogCollection; + assert.equal(updated.featured_rank, 42); + assert.equal(updated.created_at, created.created_at); + assert.equal(updated.title, 'Edited collection'); + assert.equal(updated.description, 'Edited metadata.'); + + const reloaded = await (await request('/catalog/collections/picked/rank-first')).json() as { collection: CatalogCollection }; + assert.equal(reloaded.collection.featured_rank, 42); + assert.equal(reloaded.collection.description, 'Edited metadata.'); + const page = await (await request('/catalog/collections?source=picked&tag=rank-roundtrip')).json() as CatalogPage; + assert.deepEqual(page.items.map(item => [item.id, item.featured_rank]), [['picked:rank-second', 7], ['picked:rank-first', 42]]); + } finally { + for (const slug of slugs) await request(`/catalog/collections/picked/${slug}`, 'DELETE', undefined, 'test-admin'); + } +}); + +test('mixed collection pagination retains both sources without duplicates or query-mismatched cursors', async () => { + await createPick('starter', ['10'], 'Starter colony', { featured_rank: 10 }); + await createPick('tools', ['13'], 'Useful tools', { featured_rank: 20 }); + const first = await request('/catalog/collections?limit=3&version=1.6'); + const firstPage = await first.json() as CatalogPage; + const all = [...firstPage.items]; + let cursor = firstPage.next_cursor; + while (cursor) { + const page = await (await request(`/catalog/collections?limit=3&version=1.6&cursor=${encodeURIComponent(cursor)}`)).json() as CatalogPage; + all.push(...page.items); cursor = page.next_cursor; + } + assert.deepEqual(all.map(item => item.id).sort(), ['picked:literal', 'picked:starter', 'picked:tools', 'steam:100', 'steam:101'].sort()); + assert.deepEqual(all.filter(item => item.source === 'picked').map(item => [item.id, item.featured_rank]), [ + ['picked:literal', 1], ['picked:starter', 10], ['picked:tools', 20], + ]); + assert.ok(all.filter(item => item.source === 'steam').every(item => !Object.hasOwn(item, 'featured_rank'))); + assert.equal(firstPage.total, 5); + assert.equal((await request(`/catalog/collections?limit=3&version=1.5&cursor=${encodeURIComponent(firstPage.next_cursor!)}`)).status, 400); +}); + +test('Unicode queries survive opaque picked collection pagination', async () => { + await createPick('unicode-a', ['10'], 'Колония A'); + await createPick('unicode-b', ['13'], 'Колония B'); + const path = '/catalog/collections?source=picked&q=%D0%9A%D0%BE%D0%BB%D0%BE%D0%BD%D0%B8%D1%8F&limit=1'; + const first = await (await request(path)).json() as CatalogPage; + const next = await (await request(`${path}&cursor=${encodeURIComponent(first.next_cursor!)}`)).json() as CatalogPage; + assert.deepEqual([first.items[0]?.id, next.items[0]?.id], ['picked:unicode-a', 'picked:unicode-b']); + assert.equal(next.next_cursor, null); +}); + +test('download resolution expands nested collections, includes transitive dependencies once, and orders libraries first', async () => { + const response = await request('/catalog/resolve', 'POST', { ids: ['13'], collection_ids: ['steam:100', 'picked:starter'] }); + const value = await response.json() as { mod_ids: string[]; items: Record; is_complete: boolean }; + assert.equal(response.status, 200); + assert.deepEqual(value.mod_ids, ['11', '13', '12', '10']); + assert.equal(value.items['steam:101']?.kind, 'collection'); + assert.equal(value.is_complete, true); +}); + +test('a collection-only download accepts an empty explicit mod selection but not an entirely empty request', async () => { + const response = await request('/catalog/resolve', 'POST', { ids: [], collection_ids: ['steam:100'] }); + assert.equal(response.status, 200); + const value = await response.json() as { mod_ids: string[]; is_complete: boolean }; + assert.deepEqual(value.mod_ids, ['11', '12', '10', '13']); + assert.equal(value.is_complete, true); + assert.equal((await request('/catalog/resolve', 'POST', { ids: [], collection_ids: [] })).status, 400); +}); + +test('download resolution terminates cycles and reports missing items rather than claiming completeness', async () => { + const cycle = await (await request('/catalog/resolve', 'POST', { ids: ['20'] })).json() as { mod_ids: string[]; is_complete: boolean }; + assert.deepEqual(cycle.mod_ids, ['21', '20']); + assert.equal(cycle.is_complete, true); + const missing = await (await request('/catalog/resolve', 'POST', { ids: ['10', '16'] })).json() as { mod_ids: string[]; unavailable_ids: string[]; is_complete: boolean }; + assert.deepEqual(missing.mod_ids, ['11', '12', '10']); + assert.deepEqual(missing.unavailable_ids, ['16']); + assert.equal(missing.is_complete, false); +}); + +test('expired metadata is refreshed instead of hiding a newer Workshop update', async () => { + const stale = { ...await (await request('/catalog/mods/13')).json() as CatalogMod, updated_at: 1 }; + await db.prepare('UPDATE catalog_items SET data = ?, updated_at = 0 WHERE id = ?').bind(JSON.stringify(stale), '13').run(); + const fresh = await (await request('/catalog/mods/batch', 'POST', { ids: ['13'] })).json() as { items: CatalogMod[] }; + assert.equal(fresh.items[0]?.updated_at, 1720000000); +}); + +test('invalid input and malformed/rate-limited upstreams have explicit non-success responses without leaking secrets', async () => { + for (const path of ['/catalog/mods?limit=0', '/catalog/mods?sort=name', '/catalog/mods?version=banana', '/catalog/mods/0']) { + assert.equal((await request(path)).status, 400); + } + assert.equal((await request('/catalog/mods/batch', 'POST', { ids: [10] })).status, 400); + assert.equal((await request('/catalog/resolve', 'POST', { collection_ids: ['steam:10'] })).status, 400); + assert.equal((await request('/catalog/mods/999')).status, 502); + const limited = await request('/catalog/mods?q=upstream-rate-limit'); + assert.equal(limited.status, 503); + assert.equal((await limited.text()).includes('fixture-only-key'), false); +}); + +test('a transient Steam failure is retried instead of failing the browse request', async () => { + const response = await request('/catalog/mods?q=upstream-flaky'); + assert.equal(response.status, 200); + assert.equal(flakyCalls, 2); +}); + +test('the idempotent dependency migration and existing /deps response preserve populated dependency data', async () => { + const now = Date.now(); + await db.batch([ + db.prepare('INSERT INTO items (id,title,is_collection,status,updated_at,package_id,deps) VALUES (?,?,?,?,?,?,?)').bind('777', 'Existing mod', 0, 'OK', now, 'existing.mod', '778'), + db.prepare('INSERT INTO items (id,title,is_collection,status,updated_at,package_id,deps) VALUES (?,?,?,?,?,?,?)').bind('778', 'Existing dependency', 0, 'OK', now, 'existing.dependency', ''), + ]); + const sql = await readFile(resolvePath('migrations', '0001_dependencies.sql'), 'utf8'); + await db.batch(statements(sql).map(statement => db.prepare(statement))); + const response = await request('/deps?id=777'); + const value = await response.json() as { rootId: string; totalItemsLoaded: number; isComplete: boolean; items: Record }; + assert.equal(response.status, 200); + assert.equal(value.rootId, '777'); + assert.equal(value.totalItemsLoaded, 2); + assert.equal(value.isComplete, true); + assert.deepEqual(value.items['777']?.deps, ['778']); + assert.equal(value.items['777']?.package_id, 'existing.mod'); +}); + +test('an unconfigured Steam key does not turn a missing catalog into a successful empty page', async () => { + const noKey = new Miniflare({ workers: [{ + config: { + name: 'no-key', compatibilityDate: '2026-10-05', + manifest: { mainModule: 'worker.js', modules: { 'worker.js': { type: 'esm', contents: await readFile(resolvePath('dist/worker.js'), 'utf8') } } }, + env: { DB: { type: 'd1', id: 'no-key' } }, + }, + }] }); + try { + const database = await noKey.getD1Database('DB'); + await migrate(database); + const response = await noKey.dispatchFetch('https://no-key.test/catalog/mods'); + assert.equal(response.status, 503); + const picks = await noKey.dispatchFetch('https://no-key.test/catalog/collections?source=picked'); + assert.equal(picks.status, 200); + assert.deepEqual((await picks.json() as CatalogPage).items, []); + } finally { await noKey.dispose(); } +}); + +test('newest picks use creation time while recently updated picks use modification time', async () => { + const slugs = ['ordering-first', 'ordering-second']; + try { + for (const slug of slugs) { + assert.equal((await request(`/catalog/collections/picked/${slug}`, 'PUT', { + title: slug, author: 'Curator', description: '', tags: ['ordering'], member_ids: ['10'], + }, 'test-admin')).status, 200); + } + await db.prepare("UPDATE picked_collections SET created_at = CASE slug WHEN 'ordering-first' THEN 100 ELSE 200 END, updated_at = CASE slug WHEN 'ordering-first' THEN 300 ELSE 200 END WHERE slug IN ('ordering-first', 'ordering-second')").run(); + const newest = await (await request('/catalog/collections?source=picked&tag=ordering&sort=newest')).json() as CatalogPage; + assert.deepEqual(newest.items.map(item => item.id), ['picked:ordering-second', 'picked:ordering-first']); + const updated = await (await request('/catalog/collections?source=picked&tag=ordering&sort=updated')).json() as CatalogPage; + assert.deepEqual(updated.items.map(item => item.id), ['picked:ordering-first', 'picked:ordering-second']); + } finally { + for (const slug of slugs) await request(`/catalog/collections/picked/${slug}`, 'DELETE', undefined, 'test-admin'); + } +}); + +test('collections without a Steam image get a collage from their member mods, skipping unavailable and nested members', async () => { + const response = await request('/catalog/collections?source=steam&q=no-thumb'); + const page = await response.json() as CatalogPage; + assert.equal(response.status, 200); + assert.equal(page.items[0]?.preview_url, null); + assert.deepEqual(page.items[0]?.member_previews, Array(2).fill('https://images.example.test/preview.png')); +}); + +test('Steam and picked collection details include member collages without their own preview', async () => { + await createPick('thumbnail-detail', ['13', '11']); + for (const path of ['/catalog/collections/steam/102', '/catalog/collections/picked/thumbnail-detail']) { + const response = await request(path); + const detail = await response.json() as { collection: CatalogCollection; members: CatalogItem[] }; + assert.equal(response.status, 200); + assert.equal(detail.collection.preview_url, null); + assert.deepEqual(detail.collection.member_previews, Array(2).fill('https://images.example.test/preview.png')); + } +}); + +test('text search is answered by the local index only after a full crawl cycle, and filters by tag and version', async () => { + assert.equal((await (await request('/catalog/index')).json() as { ready: boolean }).ready, false); + const before = await request('/catalog/mods?q=upstream-rate-limit'); + assert.equal(before.status, 503); + + await (await worker.getWorker()).scheduled({ cron: '* * * * *' }); + const status = await (await request('/catalog/index')).json() as { ready: boolean; indexed: number; cycle_in_progress: boolean }; + assert.equal(status.ready, true); + assert.equal(status.cycle_in_progress, false); + assert.ok(status.indexed >= 5); + + const local = await request('/catalog/mods?q=upstream-rate-limit'); + assert.equal(local.status, 200); + assert.deepEqual((await local.json() as CatalogPage).items, []); + + const prefix = await (await request('/catalog/mods?q=harm')).json() as CatalogPage; + assert.deepEqual(prefix.items.map(item => item.id), ['11']); + assert.equal(prefix.items[0]?.previews.length, 0); + + const old = await (await request('/catalog/mods?q=camera&version=1.5')).json() as CatalogPage; + assert.deepEqual(old.items.map(item => item.id), ['14']); + const none = await (await request('/catalog/mods?q=camera&version=1.6')).json() as CatalogPage; + assert.deepEqual(none.items, []); + + const first = await (await request('/catalog/mods?q=h&limit=1&sort=updated')).json() as CatalogPage; + assert.equal(first.items.length, 1); + assert.ok(first.total >= 2 && first.next_cursor?.startsWith('ix:')); + const second = await (await request(`/catalog/mods?q=h&limit=1&sort=updated&cursor=${first.next_cursor}`)).json() as CatalogPage; + assert.notEqual(second.items[0]?.id, first.items[0]?.id); + + const hostile = await request('/catalog/mods?q=%22%20OR%20*%20NEAR('); + assert.equal(hostile.status, 200); +}); + +test('expired detail reads answer from storage immediately and refresh in the background; update checks still wait', async () => { + const stale = { ...await (await request('/catalog/mods/13')).json() as CatalogMod, title: 'Old title' }; + const expire = () => db.prepare('UPDATE catalog_items SET data = ?, updated_at = 0 WHERE id = ?').bind(JSON.stringify(stale), '13').run(); + + await expire(); + const served = await (await request('/catalog/mods/13')).json() as CatalogMod; + assert.equal(served.title, 'Old title'); + let refreshed = ''; + for (let attempt = 0; attempt < 50 && refreshed !== 'RimHUD'; attempt++) { + await new Promise(done => setTimeout(done, 20)); + refreshed = (await db.prepare('SELECT json_extract(data, "$.title") AS title FROM catalog_items WHERE id = ?').bind('13').first<{ title: string }>())?.title ?? ''; + } + assert.equal(refreshed, 'RimHUD'); + + await expire(); + const checked = await (await request('/catalog/mods/batch', 'POST', { ids: ['13'] })).json() as { items: CatalogMod[] }; + assert.equal(checked.items[0]?.title, 'RimHUD'); +}); + +test('query pages are remembered in D1: repeats do not call Steam, and expired pages are served first then refreshed', async () => { + const path = '/catalog/collections?source=steam&q=no-thumb&limit=7'; + const before = queryCalls; + const first = await (await request(path)).json() as CatalogPage; + assert.equal(queryCalls, before + 1); + await request(path); + assert.equal(queryCalls, before + 1); + + await db.prepare('UPDATE query_cache SET fetched_at = 1').run(); + const served = await (await request(path)).json() as CatalogPage; + assert.deepEqual(served.items.map(item => item.id), first.items.map(item => item.id)); + let refreshedAt = 1; + for (let attempt = 0; attempt < 50 && refreshedAt === 1; attempt++) { + await new Promise(done => setTimeout(done, 20)); + refreshedAt = (await db.prepare('SELECT max(fetched_at) AS at FROM query_cache').first<{ at: number }>())?.at ?? 1; + } + assert.ok(refreshedAt > 1 && queryCalls === before + 2); +}); + +test('once the index is ready, mod browsing without text comes from it, except trending which needs Steam', async () => { + const calls = queryCalls; + const newest = await (await request('/catalog/mods?sort=newest&limit=2')).json() as CatalogPage; + assert.equal(newest.items.length, 2); + assert.ok(newest.total >= 5); + const popular = await (await request('/catalog/mods?version=1.5&limit=5')).json() as CatalogPage; + assert.deepEqual(popular.items.map(item => item.id), ['14']); + assert.equal(queryCalls, calls); + await request('/catalog/mods?sort=trending&limit=3'); + assert.equal(queryCalls, calls + 1); +}); + +test('a cached mod removed by Steam disappears after background refresh, including from local search and batch', async () => { + const id = '800'; + files[id] = mod(id, 'Evictionfixture'); + assert.equal((await request(`/catalog/mods/${id}`)).status, 200); + const search = () => request('/catalog/mods?q=Evictionfixture'); + assert.deepEqual((await (await search()).json() as CatalogPage).items.map(item => item.id), [id]); + delete files[id]; + await db.prepare('UPDATE catalog_items SET updated_at = 0 WHERE id = ?').bind(id).run(); + assert.equal((await request(`/catalog/mods/${id}`)).status, 200); + let cached: { id: string } | null = null; + for (let attempt = 0; attempt < 50; attempt++) { + cached = await db.prepare('SELECT id FROM catalog_items WHERE id = ?').bind(id).first<{ id: string }>(); + if (!cached) break; + await new Promise(done => setTimeout(done, 20)); + } + assert.equal(cached, null); + assert.equal((await request(`/catalog/mods/${id}`)).status, 404); + assert.deepEqual((await (await search()).json() as CatalogPage).items, []); + const batch = await (await request('/catalog/mods/batch', 'POST', { ids: [id] })).json() as { items: CatalogMod[]; unavailable_ids: string[] }; + assert.deepEqual(batch, { items: [], unavailable_ids: [id] }); +}); + +test('batch and resolve evict cached files that become non-public rather than leaving them searchable', async () => { + const changes: Partial[] = [ + { result: 9 }, { visibility: 1 }, { visibility: 2 }, { visibility: 3 }, + { banned: true }, { consumer_appid: 730 }, { file_type: 3 }, + ]; + for (const [index, change] of changes.entries()) { + const id = String(810 + index); + files[id] = mod(id, 'Withdrawnfixture'); + assert.equal((await request(`/catalog/mods/${id}`)).status, 200); + files[id] = { ...files[id]!, ...change }; + await db.prepare('UPDATE catalog_items SET updated_at = 0 WHERE id = ?').bind(id).run(); + const path = index % 2 ? '/catalog/resolve' : '/catalog/mods/batch'; + const response = await request(path, 'POST', { ids: [id] }); + assert.equal(response.status, 200); + const value = await response.json() as { unavailable_ids: string[] }; + assert.deepEqual(value.unavailable_ids, [id]); + assert.equal((await request(`/catalog/mods/${id}`)).status, 404); + } + const search = await (await request('/catalog/mods?q=Withdrawnfixture')).json() as CatalogPage; + assert.deepEqual(search.items, []); +}); + +test('evicted collection members are unavailable while Steam collection eviction never removes picked collections', async () => { + files['820'] = mod('820', 'Collectionmemberfixture'); + files['821'] = { ...mod('821', 'Withdrawn collection', ['820']), file_type: 2, num_children: 1, preview_url: '' }; + await createPick('evictionfixture', ['820']); + assert.equal((await request('/catalog/collections/steam/821')).status, 200); + delete files['820']; + await db.prepare('UPDATE catalog_items SET updated_at = 0 WHERE id = ?').bind('820').run(); + const batch = await (await request('/catalog/mods/batch', 'POST', { ids: ['820'] })).json() as { unavailable_ids: string[] }; + assert.deepEqual(batch.unavailable_ids, ['820']); + for (const path of ['/catalog/collections/steam/821', '/catalog/collections/picked/evictionfixture']) { + const detail = await (await request(path)).json() as { members: CatalogItem[]; unavailable_ids: string[]; is_complete: boolean; collection: CatalogCollection }; + assert.deepEqual(detail.members, []); + assert.deepEqual(detail.unavailable_ids, ['820']); + assert.equal(detail.is_complete, false); + assert.deepEqual(detail.collection.member_previews, []); + } + delete files['821']; + await db.prepare('UPDATE catalog_items SET updated_at = 0 WHERE id = ?').bind('821').run(); + const resolved = await (await request('/catalog/resolve', 'POST', { collection_ids: ['steam:821', 'picked:evictionfixture'] })).json() as { items: Record; unavailable_ids: string[] }; + assert.deepEqual(resolved.unavailable_ids, ['821', '820']); + assert.ok(resolved.items['picked:evictionfixture']); + assert.equal((await request('/catalog/collections/steam/821')).status, 404); + assert.equal((await request('/catalog/collections/picked/evictionfixture')).status, 200); +}); + +test('failed and partial Steam refreshes retain cached metadata and search entries', async () => { + for (const [index, failure] of (['http', 'partial', 'metadata'] as const).entries()) { + const id = String(830 + index); + const companion = String(840 + index); + files[id] = mod(id, 'Retainedfixture'); + files[companion] = mod(companion, 'Retainedfixture companion'); + assert.equal((await request(`/catalog/mods/${id}`)).status, 200); + assert.equal((await request(`/catalog/mods/${companion}`)).status, 200); + delete files[companion]; + detailFailures.set(id, failure); + await db.prepare('UPDATE catalog_items SET updated_at = 0 WHERE id IN (?, ?)').bind(id, companion).run(); + try { + assert.equal((await request('/catalog/mods/batch', 'POST', { ids: [id, companion] })).status, 502); + const rows = await db.prepare('SELECT id FROM catalog_items WHERE id IN (?, ?) ORDER BY id').bind(id, companion).all<{ id: string }>(); + assert.deepEqual(rows.results.map(row => row.id), [id, companion]); + const search = await (await request('/catalog/mods?q=Retainedfixture')).json() as CatalogPage; + assert.ok(search.items.some(item => item.id === id)); + assert.ok(search.items.some(item => item.id === companion)); + assert.equal((await request(`/catalog/mods/${id}`)).status, 200); + } finally { + detailFailures.delete(id); + } + } +}); + +test('full index cycles confirm omitted files, evict unavailable ones and retain public ones', async () => { + files['850'] = mod('850', 'UnseenDeletedFixture'); + files['851'] = mod('851', 'UnseenPublicFixture'); + await request('/catalog/mods/850'); + await request('/catalog/mods/851'); + delete files['850']; + await db.prepare('UPDATE catalog_items SET updated_at = 0 WHERE id IN (?, ?)').bind('850', '851').run(); + await db.prepare('UPDATE index_state SET full_at = 1, cycle_started_at = 0, cursor = ?').bind('*').run(); + + await (await worker.getWorker()).scheduled({ cron: '* * * * *' }); + + assert.equal(await db.prepare('SELECT id FROM catalog_items WHERE id = ?').bind('850').first(), null); + const removed = await (await request('/catalog/mods?q=UnseenDeletedFixture')).json() as CatalogPage; + assert.deepEqual(removed.items, []); + const retained = await (await request('/catalog/mods?q=UnseenPublicFixture')).json() as CatalogPage; + assert.deepEqual(retained.items.map(item => item.id), ['851']); +}); + +test('index verification resumes after failed Steam refreshes without evicting records', async () => { + files['860'] = mod('860', 'UnseenFailedFixture'); + await request('/catalog/mods/860'); + await db.prepare('UPDATE catalog_items SET updated_at = 0 WHERE id = ?').bind('860').run(); + await db.prepare('UPDATE index_state SET full_at = 1, cycle_started_at = 0, cursor = ?').bind('*').run(); + detailFailures.set('860', 'http'); + try { + await (await worker.getWorker()).scheduled({ cron: '* * * * *' }); + const state = await db.prepare('SELECT cursor, full_at, cycle_started_at FROM index_state WHERE id = 1') + .first<{ cursor: string; full_at: number; cycle_started_at: number }>(); + assert.equal(state?.cursor, 'verify:0:'); + assert.equal(state?.full_at, 1); + assert.ok((state?.cycle_started_at ?? 0) > 0); + assert.ok(await db.prepare('SELECT id FROM catalog_items WHERE id = ?').bind('860').first()); + } finally { + detailFailures.delete('860'); + } + await (await worker.getWorker()).scheduled({ cron: '* * * * *' }); + const state = await db.prepare('SELECT full_at, cycle_started_at FROM index_state WHERE id = 1') + .first<{ full_at: number; cycle_started_at: number }>(); + assert.ok((state?.full_at ?? 0) > 1); + assert.equal(state?.cycle_started_at, 0); +}); + +test('a persistently failing omitted file does not starve later ones or complete the pass', async () => { + files['870'] = mod('870', 'UnseenPoisonFixture'); + files['871'] = mod('871', 'UnseenBehindPoisonFixture'); + await request('/catalog/mods/870'); + await request('/catalog/mods/871'); + delete files['871']; + await db.prepare('UPDATE catalog_items SET updated_at = 0 WHERE id IN (?, ?)').bind('870', '871').run(); + await db.prepare('UPDATE index_state SET full_at = 1, cycle_started_at = 0, cursor = ?').bind('*').run(); + detailFailures.set('870', 'metadata'); + try { + for (let tick = 0; tick < 3; tick++) await (await worker.getWorker()).scheduled({ cron: '* * * * *' }); + assert.equal(await db.prepare('SELECT id FROM catalog_items WHERE id = ?').bind('871').first(), null); + assert.ok(await db.prepare('SELECT id FROM catalog_items WHERE id = ?').bind('870').first()); + const state = await db.prepare('SELECT full_at FROM index_state WHERE id = 1').first<{ full_at: number }>(); + assert.equal(state?.full_at, 1); + } finally { + detailFailures.delete('870'); + } +}); + +test('index verification checks at most 100 omitted mods per tick and persists its phase', async () => { + const seed = await (await request('/catalog/mods/10')).json() as CatalogMod; + const statements = Array.from({ length: 101 }, (_, index) => { + const id = String(9000 + index); + return db.prepare('INSERT INTO catalog_items (id, data, updated_at) VALUES (?, ?, 0)') + .bind(id, JSON.stringify({ ...seed, id })); + }); + await db.batch(statements); + const indexRows = Array.from({ length: 101 }, (_, index) => db.prepare( + `INSERT INTO catalog_index (id, title, data, tags, indexed_at) VALUES (?, ?, ?, '[]', 0)`, + ).bind(String(9000 + index), 'UnseenBatchFixture', JSON.stringify({ ...seed, id: String(9000 + index) }))); + await db.batch(indexRows); + await db.prepare('UPDATE index_state SET full_at = 1, cycle_started_at = 0, cursor = ?').bind('*').run(); + await db.prepare('UPDATE catalog_items SET updated_at = ? WHERE id NOT BETWEEN ? AND ?') + .bind(Date.now() + 60_000, '9000', '9100').run(); + + await (await worker.getWorker()).scheduled({ cron: '* * * * *' }); + const remaining = await db.prepare('SELECT count(*) AS n FROM catalog_index WHERE id BETWEEN ? AND ?') + .bind('9000', '9100').first<{ n: number }>(); + assert.equal(remaining?.n, 1); + const phase = await db.prepare('SELECT cursor FROM index_state WHERE id = 1').first<{ cursor: string }>(); + assert.ok(phase?.cursor.startsWith('verify:0:9')); + + await (await worker.getWorker()).scheduled({ cron: '* * * * *' }); + const complete = await db.prepare('SELECT cycle_started_at FROM index_state WHERE id = 1').first<{ cycle_started_at: number }>(); + assert.equal(complete?.cycle_started_at, 0); + const count = await db.prepare('SELECT count(*) AS n FROM catalog_index WHERE id BETWEEN ? AND ?') + .bind('9000', '9100').first<{ n: number }>(); + assert.equal(count?.n, 0); +}); + +test('collection sizes and common versions are stored once members are known, and cleared when membership changes', async () => { + await createPick('sized', ['10', '13']); + const listed = async () => (await (await request('/catalog/collections?source=picked&q=Starter')).json() as CatalogPage) + .items.find(item => item.id === 'picked:sized'); + try { + assert.equal((await listed())?.total_size, null); + + const detail = await (await request('/catalog/collections/picked/sized')).json() as { collection: CatalogCollection }; + assert.equal(detail.collection.total_size, '8192'); + assert.deepEqual(detail.collection.supported_versions, ['1.6']); + assert.equal(detail.collection.no_common_version, false); + assert.equal((await listed())?.total_size, '8192'); + + await db.prepare('UPDATE collection_summaries SET computed_at = 1 WHERE id = ?').bind('picked:sized').run(); + assert.equal((await listed())?.total_size, null); + await request('/catalog/collections/picked/sized'); + assert.equal((await listed())?.total_size, '8192'); + + await createPick('sized', ['10', '13', '11']); + assert.equal((await listed())?.total_size, null); + } finally { + await request('/catalog/collections/picked/sized', 'DELETE', undefined, 'test-admin'); + } +}); + +test('collections whose total exceeds a safe integer have no stored size', async () => { + await createPick('huge', ['10', '18446744073709551615']); + try { + const detail = await (await request('/catalog/collections/picked/huge')).json() as { collection: CatalogCollection }; + assert.equal(detail.collection.total_size, null); + } finally { + await request('/catalog/collections/picked/huge', 'DELETE', undefined, 'test-admin'); + } +}); + +test('undeclared collections with conflicting member versions have no common version', async () => { + await createPick('versions', ['10', '14'], 'Conflicting versions', { supported_versions: [] }); + try { + const detail = await (await request('/catalog/collections/picked/versions')).json() as { collection: CatalogCollection }; + assert.deepEqual(detail.collection.supported_versions, []); + assert.equal(detail.collection.no_common_version, true); + assert.equal(detail.collection.total_size, '8192'); + } finally { + await request('/catalog/collections/picked/versions', 'DELETE', undefined, 'test-admin'); + } +}); + +test('members with unknown versions do not erase known compatible versions', async () => { + files['880'] = mod('880', 'Unversioned member', [], []); + await createPick('unknown-member', ['10', '880'], 'Unknown member versions', { supported_versions: [] }); + try { + const detail = await (await request('/catalog/collections/picked/unknown-member')).json() as { collection: CatalogCollection }; + assert.deepEqual(detail.collection.supported_versions, ['1.6']); + assert.equal(detail.collection.no_common_version, false); + } finally { + await request('/catalog/collections/picked/unknown-member', 'DELETE', undefined, 'test-admin'); + } +}); + +test('collections with only unknown member versions are unstated rather than conflicting', async () => { + files['881'] = mod('881', 'Another unversioned member', [], []); + await createPick('unknown-versions', ['881'], 'Unknown versions', { supported_versions: [] }); + try { + const detail = await (await request('/catalog/collections/picked/unknown-versions')).json() as { collection: CatalogCollection }; + assert.deepEqual(detail.collection.supported_versions, []); + assert.equal(detail.collection.no_common_version, false); + } finally { + await request('/catalog/collections/picked/unknown-versions', 'DELETE', undefined, 'test-admin'); + } +}); + +test('collection declarations remain compatible despite conflicting member versions in details and listings', async () => { + for (const declaration of [{ supported_versions: ['1.6'], tags: [] }, { supported_versions: [], tags: ['Mod', '1.6'] }]) { + await createPick('declared-version', ['10', '14'], 'Declared version fixture', declaration); + try { + const detail = await (await request('/catalog/collections/picked/declared-version')).json() as { collection: CatalogCollection }; + assert.deepEqual(detail.collection.supported_versions, ['1.6']); + assert.equal(detail.collection.no_common_version, false); + const listing = await (await request('/catalog/collections?source=picked&q=Declared')).json() as CatalogPage; + assert.deepEqual(listing.items[0]?.supported_versions, ['1.6']); + assert.equal(listing.items[0]?.no_common_version, false); + } finally { + await request('/catalog/collections/picked/declared-version', 'DELETE', undefined, 'test-admin'); + } + } +}); + +test('migration 0006 removes the objects of the earlier collection_sizes migration', async () => { + await db.batch([ + db.prepare('CREATE TABLE IF NOT EXISTS collection_sizes (id TEXT PRIMARY KEY, total_size INTEGER NOT NULL)'), + db.prepare("CREATE TRIGGER IF NOT EXISTS collection_sizes_item_ad AFTER DELETE ON catalog_items BEGIN DELETE FROM collection_sizes WHERE id = 'steam:' || old.id; END"), + db.prepare('DROP TABLE collection_sizes'), + ]); + await db.prepare("INSERT INTO catalog_items (id, data, updated_at) VALUES ('old-trigger', '{}', 0)").run(); + await assert.rejects(db.prepare("DELETE FROM catalog_items WHERE id = 'old-trigger'").run()); + const sql = await readFile(resolvePath('migrations', '0006_collection_summaries.sql'), 'utf8'); + await db.batch(statements(sql).map(statement => db.prepare(statement))); + await db.prepare("DELETE FROM catalog_items WHERE id = 'old-trigger'").run(); +}); diff --git a/cf-workers/tsconfig.json b/cf-workers/tsconfig.json new file mode 100644 index 0000000..5bd1b72 --- /dev/null +++ b/cf-workers/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types", "node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["*.ts", "tests/**/*.ts"] +} diff --git a/cf-workers/types.ts b/cf-workers/types.ts new file mode 100644 index 0000000..a0af2c2 --- /dev/null +++ b/cf-workers/types.ts @@ -0,0 +1,134 @@ +export interface DependencyMessage { + rootId: string; +} + +export interface Env { + DB: D1Database; + STEAM_API_KEY?: string; + SCRAPI?: string; + SCRAPE_QUEUE?: Queue; + USE_QUEUE?: string; + CONTROL_KEYS?: string; +} + +export class HttpError extends Error { + constructor(public readonly status: number, message: string) { + super(message); + } +} + +export interface Author { + id: string | null; + name: string | null; + profile_url: string | null; +} + +export interface Preview { + type: 'image' | 'video'; + url: string; +} + +export interface SteamFile { + publishedfileid: string; + result: number; + consumer_appid?: number; + creator_appid?: number; + creator?: string; + title?: string; + file_description?: string; + description?: string; + short_description?: string; + file_type?: number; + file_size?: string | number; + preview_url?: string; + time_created?: number; + time_updated?: number; + visibility?: number; + banned?: boolean; + incompatible?: boolean; + subscriptions?: number; + num_children?: number; + tags?: Array<{ tag: string; display_name?: string }>; + kvtags?: Array<{ key: string; value: string }>; + children?: Array<{ publishedfileid: string; sortorder?: number; file_type?: number }>; + previews?: Array<{ preview_type?: number; url?: string; youtubevideoid?: string; sortorder?: number }>; + vote_data?: { votes_up?: number; votes_down?: number; score?: number }; +} + +export interface CatalogMod { + id: string; + kind: 'mod'; + source: 'steam'; + title: string; + author: Author; + description: string; + description_format: 'bbcode'; + preview_url: string | null; + previews: Preview[]; + workshop_url: string; + tags: string[]; + supported_versions: string[]; + created_at: number | null; + updated_at: number | null; + file_size: string | null; + subscriptions: number | null; + votes: { up: number; down: number; positive_percent: number | null } | null; + dependencies: string[]; + package_id: string | null; + incompatible: boolean; +} + +export interface CatalogCollection { + id: string; + kind: 'collection'; + source: 'steam' | 'picked'; + steam_id: string | null; + title: string; + author: Author; + description: string; + description_format: 'bbcode' | 'text'; + preview_url: string | null; + previews: Preview[]; + workshop_url: string | null; + tags: string[]; + supported_versions: string[]; + /** Stored ordering rank for picked collections; omitted for Steam collections. */ + featured_rank?: number; + created_at: number | null; + updated_at: number | null; + member_ids: string[]; + member_count: number; + member_previews: string[]; + /** Total download size in bytes; null until every member is known. */ + total_size: string | null; + /** True when declared member versions conflict and the collection declares no supported version itself. */ + no_common_version: boolean; +} + +export type CatalogItem = CatalogMod | CatalogCollection; + +export interface CatalogPage { + items: T[]; + total: number; + next_cursor: string | null; +} + +export interface PickedCollectionInput { + title: string; + author: string; + description: string; + preview_url: string | null; + tags: string[]; + supported_versions: string[]; + member_ids: string[]; + featured_rank: number; +} + +export interface PickListOptions { + query: string; + tag: string | null; + version: string | null; + sort: 'featured' | 'updated' | 'newest' | 'name'; + limit: number; + offset: number; +} diff --git a/cf-workers/worker.js b/cf-workers/worker.ts similarity index 78% rename from cf-workers/worker.js rename to cf-workers/worker.ts index 2fbd9fd..71dd59e 100644 --- a/cf-workers/worker.js +++ b/cf-workers/worker.ts @@ -1,13 +1,78 @@ +import { handleCatalogRequest } from './catalog'; +import { indexTick } from './indexer'; +import type { DependencyMessage, Env, SteamFile } from './types'; + +interface DependencyItem { + id: string; + title: string; + is_collection?: boolean; + is_available: boolean; + status: string; + deps: string[]; + package_id?: string; + error_details?: string; +} + +interface ApiDependencyItem extends DependencyItem { + is_collection: boolean; + package_id: string; +} + +interface DependencyCacheEntry { + data: DependencyItem; + timestamp: number; + status: string; +} + +interface DependencyRow { + id: string; + title: string; + is_collection: number; + status: string; + updated_at: number; + package_id: string; + deps: string; +} + +interface SteamDetailsEnvelope { + response?: { publishedfiledetails?: SteamFile[] }; +} + +interface SteamQueryEnvelope { + response?: { + publishedfiledetails?: Array>; + next_cursor?: string; + }; +} + +interface CrawlState { + cursor: string; + todo_ids: string[]; + total_processed: number; +} + +interface CrawlStateRow { + cursor: string; + todo_ids: string; + total_processed: number; +} + +type ScrapeResult = + | { isRateLimited: true } + | { isRateLimited?: false; data: DependencyItem; nextTasks: string[] }; + // ============================================================================ // GLOBAL RUNTIME CACHE & CONFIG // ============================================================================ class LRUMap { + private readonly _max: number; + private readonly _map: Map; constructor(maxSize = 10000) { this._max = maxSize; - this._map = new Map(); + this._map = new Map(); } - get(key) { + get(key: string): DependencyCacheEntry | undefined { const val = this._map.get(key); if (val !== undefined) { this._map.delete(key); @@ -15,14 +80,17 @@ class LRUMap { } return val; } - set(key, value) { + set(key: string, value: DependencyCacheEntry): void { if (this._map.has(key)) this._map.delete(key); - else if (this._map.size >= this._max) this._map.delete(this._map.keys().next().value); + else if (this._map.size >= this._max) { + const oldest = this._map.keys().next(); + if (!oldest.done) this._map.delete(oldest.value); + } this._map.set(key, value); } - has(key) { return this._map.has(key); } - delete(key) { return this._map.delete(key); } - get size() { return this._map.size; } + has(key: string): boolean { return this._map.has(key); } + delete(key: string): boolean { return this._map.delete(key); } + get size(): number { return this._map.size; } } const GLOBAL_RAM_CACHE = new LRUMap(10000); @@ -36,9 +104,9 @@ const STEAM_API_BATCH_SIZE = 200; const QUERY_MODS_PAGE_SIZE = 3000; const CRAWL_BATCH_SIZE = 200; -const RECENTLY_QUEUED = new Map(); +const RECENTLY_QUEUED = new Map(); -function sweepRecentlyQueued() { +function sweepRecentlyQueued(): void { const cutoff = Date.now() - QUEUE_DEDUP_TTL_MS; for (const [id, ts] of RECENTLY_QUEUED) { if (ts < cutoff) RECENTLY_QUEUED.delete(id); @@ -49,14 +117,14 @@ function sweepRecentlyQueued() { // 1. PARSERS // ============================================================================ -function parseRequiredDeps(htmlBlock) { - const deps = []; - const seenIds = new Set(); +function parseRequiredDeps(htmlBlock: string): string[] { + const deps: string[] = []; + const seenIds = new Set(); const reqContainerMatch = htmlBlock.match(/]*id=["']RequiredItems["'][^>]*>([\s\S]*?)<\/div>\s*<\/div>/i); if (!reqContainerMatch) return deps; const regex = /href=["'][^"']*filedetails\/\?id=(\d+)[^"']*/gi; - let match; + let match: RegExpExecArray | null; while ((match = regex.exec(reqContainerMatch[1])) !== null) { if (!seenIds.has(match[1])) { @@ -71,7 +139,11 @@ function parseRequiredDeps(htmlBlock) { // 2. NETWORK & D1 DATABASE // ============================================================================ -async function fetchSteamPageHtml(apiKey, modId) { +async function fetchSteamPageHtml( + apiKey: string | undefined, + modId: string +): Promise<{ html: string | null; httpStatus: number; error: string | null }> { + if (!apiKey) return { html: null, httpStatus: 0, error: 'SCRAPI not configured' }; const targetUrl = `https://steamcommunity.com/sharedfiles/filedetails/?id=${modId}`; const apiUrl = `https://api.scrapingant.com/v2/general?url=${encodeURIComponent(targetUrl)}&browser=false`; @@ -95,12 +167,22 @@ async function fetchSteamPageHtml(apiKey, modId) { const html = await response.text(); return { html, httpStatus: 200, error: null }; } catch (err) { - console.error(`[NETWORK EXCEPTION] ID ${modId}:`, err.message || err); - return { html: null, httpStatus: 0, error: err.message }; + const message = err instanceof Error ? err.message : String(err); + console.error(`[NETWORK EXCEPTION] ID ${modId}:`, message); + return { html: null, httpStatus: 0, error: message }; } } -async function saveToDB(db, modId, title, isCollection, status, deps, timestamp, packageId = '') { +async function saveToDB( + db: D1Database, + modId: string, + title: string, + isCollection: boolean, + status: string, + deps: string[], + timestamp: number, + packageId = '' +): Promise { try { const depsStr = Array.isArray(deps) ? deps.join(',') : ''; await db.prepare( @@ -115,13 +197,13 @@ async function saveToDB(db, modId, title, isCollection, status, deps, timestamp, deps = excluded.deps` ).bind(modId, title, isCollection ? 1 : 0, status, timestamp, packageId, depsStr).run(); } catch (err) { - console.error(`[D1 SAVE ERROR] ID ${modId}:`, err.message || err); + console.error(`[D1 SAVE ERROR] ID ${modId}:`, err instanceof Error ? err.message : err); } } -async function prewarmCacheFromD1(db, rootId) { +async function prewarmCacheFromD1(db: D1Database, rootId: string): Promise { const toLoad = new Set([rootId]); - const loaded = new Set(); + const loaded = new Set(); let loadedCount = 0; while (toLoad.size > 0) { @@ -137,7 +219,7 @@ async function prewarmCacheFromD1(db, rootId) { const { results } = await db.prepare( `SELECT id, title, is_collection, status, updated_at, package_id, deps FROM items WHERE id IN (${placeholders})` - ).bind(...chunk).all(); + ).bind(...chunk).all(); if (!results) continue; @@ -174,20 +256,20 @@ async function prewarmCacheFromD1(db, rootId) { } } -function buildTree(targetId) { - const itemsMap = {}; +function buildTree(targetId: string): { itemsMap: Record; missingIds: Set } { + const itemsMap: Record = {}; const queue = [targetId]; - const visited = new Set(); - const missingIds = new Set(); + const visited = new Set(); + const missingIds = new Set(); const now = Date.now(); while (queue.length > 0) { - const currentId = queue.shift(); + const currentId = queue.shift()!; if (visited.has(currentId)) continue; visited.add(currentId); - if (GLOBAL_RAM_CACHE.has(currentId)) { - const cached = GLOBAL_RAM_CACHE.get(currentId); + const cached = GLOBAL_RAM_CACHE.get(currentId); + if (cached) { const ttl = cached.status === 'OK' ? (cached.data.is_collection ? CACHE_TTL_OK_COLLECTION : CACHE_TTL_OK_MOD) : CACHE_TTL_ERROR; @@ -213,7 +295,8 @@ function buildTree(targetId) { return { itemsMap, missingIds }; } -async function fetchSteamApiDetails(apiKey, modIds) { +async function fetchSteamApiDetails(apiKey: string | undefined, modIds: string[]): Promise { + if (!apiKey) return null; const jsonPayload = { publishedfileids: modIds, includechildren: true, @@ -237,7 +320,7 @@ async function fetchSteamApiDetails(apiKey, modIds) { return null; } - const data = await response.json(); + const data = await response.json(); const details = data?.response?.publishedfiledetails; if (!Array.isArray(details)) { console.error(`[STEAM API FAIL] Unexpected response shape`); @@ -246,17 +329,17 @@ async function fetchSteamApiDetails(apiKey, modIds) { return details; } catch (err) { - console.error(`[STEAM API EXCEPTION] Batch of ${modIds.length}:`, err.message || err); + console.error(`[STEAM API EXCEPTION] Batch of ${modIds.length}:`, err instanceof Error ? err.message : err); return null; } } -function processApiResults(details) { - const processed = []; +function processApiResults(details: SteamFile[]): { processed: ApiDependencyItem[] } { + const processed: ApiDependencyItem[] = []; for (const detail of details) { const id = detail.publishedfileid; - let status, title, isCollection, deps, packageId; + let status: string, title: string, isCollection: boolean, deps: string[], packageId: string; if (detail.result !== 1) { status = detail.result === 15 ? 'PRIVATE' : `ERROR_RESULT_${detail.result}`; @@ -267,7 +350,7 @@ function processApiResults(details) { } else { status = 'OK'; title = detail.title || 'Untitled'; - isCollection = (detail.file_type && detail.file_type !== 0); + isCollection = detail.file_type === 2; deps = (detail.children || []).map(c => c.publishedfileid).filter(Boolean); const kvTag = (detail.kvtags || []).find(t => t.key === 'packageId'); packageId = kvTag ? kvTag.value : ''; @@ -285,7 +368,7 @@ function processApiResults(details) { // 4. CRAWL — STALE MOD DISCOVERY VIA QueryFiles // ============================================================================ -async function queryMods(apiKey, cursor) { +async function queryMods(apiKey: string, cursor: string): Promise<{ ids: string[]; next_cursor: string }> { const params = new URLSearchParams({ key: apiKey, query_type: '1', @@ -313,7 +396,7 @@ async function queryMods(apiKey, cursor) { return { ids: [], next_cursor: '' }; } - const data = await response.json(); + const data = await response.json(); const details = data?.response?.publishedfiledetails; const nextCursor = data?.response?.next_cursor || ''; @@ -322,8 +405,8 @@ async function queryMods(apiKey, cursor) { return { ids, next_cursor: nextCursor }; } -async function findStaleIds(db, ids) { - const stale = []; +async function findStaleIds(db: D1Database, ids: string[]): Promise { + const stale: string[] = []; const now = Date.now(); for (let i = 0; i < ids.length; i += 99) { @@ -331,7 +414,7 @@ async function findStaleIds(db, ids) { const placeholders = batch.map(() => '?').join(','); const { results } = await db.prepare( `SELECT id, is_collection, updated_at FROM items WHERE id IN (${placeholders})` - ).bind(...batch).all(); + ).bind(...batch).all>(); const found = new Map((results || []).map(r => [r.id, r])); @@ -351,10 +434,10 @@ async function findStaleIds(db, ids) { return stale; } -async function getCrawlState(db) { +async function getCrawlState(db: D1Database): Promise { const { results } = await db.prepare( 'SELECT cursor, todo_ids, total_processed FROM crawl_state WHERE id = 1' - ).all(); + ).all(); if (!results || results.length === 0) { await db.prepare( @@ -370,7 +453,7 @@ async function getCrawlState(db) { }; } -async function saveCrawlState(db, state) { +async function saveCrawlState(db: D1Database, state: CrawlState): Promise { await db.prepare( 'UPDATE crawl_state SET cursor = ?, todo_ids = ?, total_processed = ?, updated_at = ? WHERE id = 1' ).bind( @@ -381,7 +464,7 @@ async function saveCrawlState(db, state) { ).run(); } -async function crawlTick(env) { +async function crawlTick(env: Env): Promise { if (!env.STEAM_API_KEY) { console.warn('[CRAWL] No STEAM_API_KEY, skipping tick'); return; @@ -435,7 +518,7 @@ async function crawlTick(env) { await saveCrawlState(env.DB, state); } -async function ensureCollectionRootDeps(env, rootId) { +async function ensureCollectionRootDeps(env: Env, rootId: string): Promise { const cached = GLOBAL_RAM_CACHE.get(rootId); if (!cached || !cached.data.is_collection) return false; @@ -466,7 +549,7 @@ async function ensureCollectionRootDeps(env, rootId) { return true; } -async function scrapeViaScrapingAnt(env, modId) { +async function scrapeViaScrapingAnt(env: Env, modId: string): Promise { const now = Date.now(); console.log(`[SCRAPINGANT FALLBACK] Scraping Steam page for ID: ${modId}...`); @@ -478,7 +561,7 @@ async function scrapeViaScrapingAnt(env, modId) { } let resultStatus = 'OK'; - let resultData = null; + let resultData: DependencyItem; if (!html) { resultStatus = `SCRAPE_ERROR_HTTP_${httpStatus}`; @@ -530,16 +613,16 @@ async function scrapeViaScrapingAnt(env, modId) { return { data: resultData, nextTasks: resultData.deps || [] }; } -async function scrapeInline(env, missingIds, rootId = null) { +async function scrapeInline(env: Env, missingIds: Set, rootId: string | null = null): Promise> { const toScrape = [...missingIds]; const inQueue = new Set(missingIds); - const scraped = new Set(); + const scraped = new Set(); let scrapedCount = 0; while (toScrape.length > 0 && scrapedCount < MAX_INLINE_SCRAPE) { - const batch = []; + const batch: string[] = []; while (batch.length < STEAM_API_BATCH_SIZE && toScrape.length > 0 && scrapedCount + batch.length < MAX_INLINE_SCRAPE) { - const id = toScrape.shift(); + const id = toScrape.shift()!; if (!scraped.has(id)) batch.push(id); } if (batch.length === 0) break; @@ -618,7 +701,7 @@ async function scrapeInline(env, missingIds, rootId = null) { } } - const stillMissing = new Set(); + const stillMissing = new Set(); for (const id of missingIds) { if (!scraped.has(id)) stillMissing.add(id); } @@ -630,10 +713,14 @@ async function scrapeInline(env, missingIds, rootId = null) { // ============================================================================ export default { - async fetch(request, env, ctx) { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { try { const url = new URL(request.url); + if (url.pathname === '/catalog' || url.pathname.startsWith('/catalog/')) { + return await handleCatalogRequest(request, env, ctx); + } + if (url.pathname === '/') { return new Response('ok', { status: 200, @@ -643,7 +730,7 @@ export default { if (url.pathname === '/purge') { const key = url.searchParams.get('key'); - let validKeys = []; + let validKeys: unknown = []; try { validKeys = env.CONTROL_KEYS ? JSON.parse(env.CONTROL_KEYS) : []; } catch {} if (!key || !Array.isArray(validKeys) || !validKeys.includes(key)) { return new Response('Unauthorized', { status: 401 }); @@ -684,14 +771,14 @@ export default { await prewarmCacheFromD1(env.DB, targetId); await ensureCollectionRootDeps(env, targetId); - const useQueue = env.SCRAPE_QUEUE && env.USE_QUEUE === 'true'; + const scrapeQueue = env.USE_QUEUE === 'true' ? env.SCRAPE_QUEUE : undefined; if (!env.STEAM_API_KEY) { console.warn('[CONFIG] STEAM_API_KEY not set — Steam API calls will fall back to ScrapingAnt'); } - let result = buildTree(targetId); - let itemsMap = result.itemsMap; + const result = buildTree(targetId); + const itemsMap = result.itemsMap; let missingIds = result.missingIds; if (missingIds.size > 0) { @@ -708,15 +795,15 @@ export default { missingIds = rebased.missingIds; } - if (useQueue && missingIds.size > 0) { + if (scrapeQueue && missingIds.size > 0) { try { if (!RECENTLY_QUEUED.has(targetId)) { - await env.SCRAPE_QUEUE.send({ body: { rootId: targetId } }); + await scrapeQueue.send({ rootId: targetId }); RECENTLY_QUEUED.set(targetId, Date.now()); console.log(`[QUEUE BG] Enqueued root ${targetId} (${missingIds.size} IDs remaining)`); } } catch (queueErr) { - console.warn(`[QUEUE FAIL] ${queueErr.message}`); + console.warn(`[QUEUE FAIL] ${queueErr instanceof Error ? queueErr.message : String(queueErr)}`); } } } @@ -748,8 +835,8 @@ export default { }); } catch (err) { - console.error(`[FATAL ERROR]`, err.stack || err); - return new Response(JSON.stringify({ error: 'Internal Server Error', message: err.message }), { + console.error(`[FATAL ERROR]`, err instanceof Error ? err.stack : err); + return new Response(JSON.stringify({ error: 'Internal Server Error', message: err instanceof Error ? err.message : String(err) }), { status: 500, headers: { 'Content-Type': 'application/json' } }); @@ -758,10 +845,10 @@ export default { return new Response('Not Found', { status: 404 }); } catch (err) { - console.error('[CRITICAL WORKER ERROR]', err.stack || err); + console.error('[CRITICAL WORKER ERROR]', err instanceof Error ? err.stack : err); return new Response(JSON.stringify({ error: 'Worker Execution Error', - message: err.message + message: err instanceof Error ? err.message : String(err) }), { status: 500, headers: { 'Content-Type': 'application/json' } @@ -769,11 +856,12 @@ export default { } }, - async scheduled(event, env, ctx) { + async scheduled(_event: ScheduledController, env: Env, ctx: ExecutionContext): Promise { ctx.waitUntil(crawlTick(env)); + ctx.waitUntil(indexTick(env).catch(error => console.error('[INDEX] Tick failed', error instanceof Error ? error.message : error))); }, - async queue(batch, env) { + async queue(batch: MessageBatch, env: Env): Promise { console.log(`[QUEUE CONSUMER] Received batch of ${batch.messages.length} items`); sweepRecentlyQueued(); @@ -803,16 +891,17 @@ export default { message.ack(); } else { console.log(`[QUEUE CONTINUE] Root ${rootId}: ${check.missingIds.size} still missing, re-scheduling`); + if (!env.SCRAPE_QUEUE) throw new Error('SCRAPE_QUEUE not configured'); message.ack(); await env.SCRAPE_QUEUE.send( - { body: { rootId } }, - { deliveryDelay: 30 } + { rootId }, + { delaySeconds: 30 } ); } } catch (err) { - console.error(`[QUEUE ERROR] Root ${rootId}:`, err.message || err); + console.error(`[QUEUE ERROR] Root ${rootId}:`, err instanceof Error ? err.message : err); message.retry(); } } } -}; +} satisfies ExportedHandler; diff --git a/cf-workers/wrangler.local.jsonc b/cf-workers/wrangler.local.jsonc new file mode 100644 index 0000000..f2ccc98 --- /dev/null +++ b/cf-workers/wrangler.local.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "pxmodrim-workshop-local", + "main": "worker.ts", + "compatibility_date": "2026-10-05", + "d1_databases": [ + { + "binding": "DB", + "database_name": "pxmodrim-workshop-local", + "database_id": "local-only", + "migrations_dir": "migrations" + } + ] +} diff --git a/docs/mockups/native-mod-catalog.html b/docs/mockups/native-mod-catalog.html new file mode 100644 index 0000000..b2e3c3d --- /dev/null +++ b/docs/mockups/native-mod-catalog.html @@ -0,0 +1,456 @@ + + + + + +PxModRim — Workshop discovery · layout study + + + + +
+
PPxModRim/ Workshop
Mockup · compare navigation
+
+ + +
+

Workshop

Mods & collections for RimWorld

Your game: RimWorld 1.6
+ +
+
+
+
+
Interactive mockup · illustrative catalogNo backend connection · simulated downloads · no mods enabled
+
+ + + + diff --git a/justfile b/justfile index acefd44..9f68cbf 100644 --- a/justfile +++ b/justfile @@ -70,8 +70,8 @@ build *args: build-js build-release *args: build-js uv run python packaging/build.py --release {{args}} -build-release-bundled: build-js - uv run python packaging/build.py --release --bundle-qt +build-release-bundled *args: build-js + uv run python packaging/build.py --release --bundle-qt {{args}} build-clean: rm -rf build/ dist/ *.build/ *.dist/ *.onefile-build/ diff --git a/packaging/build.py b/packaging/build.py index 8c08b75..4b573bf 100644 --- a/packaging/build.py +++ b/packaging/build.py @@ -1,5 +1,6 @@ from __future__ import annotations +import argparse import os import platform import shutil @@ -7,6 +8,7 @@ import sys import tomllib from pathlib import Path +from tempfile import TemporaryDirectory def get_version(project_root: Path) -> str: @@ -29,7 +31,50 @@ def debian_architecture(machine: str | None = None) -> str: ) from None -def get_standalone_args(release: bool = False) -> list[str]: +_WITHOUT_WEBVIEW_BROWSER = [ + ( + "--nofollow-import-to=pxmodrim.ui.plugins.steam_workshop," + "PySide6.QtWebEngine*,PySide6.QtWebChannel" + ), + "--noinclude-data-files=pxmodrim/ui/plugins/steam_workshop", + "--noinclude-data-files=PySide6/qml/QtWeb*/*", + "--noinclude-data-files=*qtwebengine*", + "--noinclude-dlls=PySide6/qml/QtWeb*/*", + "--noinclude-dlls=*WebEngine*", + "--noinclude-dlls=*WebChannel*", +] +_WITHOUT_NATIVE_BROWSER = [ + "--nofollow-import-to=pxmodrim.ui.plugins.workshop", + "--noinclude-data-files=pxmodrim/ui/plugins/workshop", +] +_WITHOUT_DOWNLOADER = [ + ( + "--nofollow-import-to=pxsteamdl,pxmodrim.core.downloads.steam," + "pxmodrim.ui.plugins.downloads" + ), + "--noinclude-data-files=pxmodrim/ui/plugins/downloads", +] + + +def _variant_exclusions(workshop: str) -> list[str]: + """Nuitka options that leave out what the Workshop variant does not ship.""" + match workshop: + case "SteamWorkshop": + return _WITHOUT_NATIVE_BROWSER + case "NativeWorkshop": + return _WITHOUT_WEBVIEW_BROWSER + case "NoWorkshop": + return [ + *_WITHOUT_WEBVIEW_BROWSER, + *_WITHOUT_NATIVE_BROWSER, + *_WITHOUT_DOWNLOADER, + ] + raise ValueError(f"Unknown Workshop variant: {workshop}") + + +def get_standalone_args( + release: bool = False, workshop: str = "NativeWorkshop" +) -> list[str]: project_root = Path(__file__).parent.parent args = [ @@ -47,6 +92,8 @@ def get_standalone_args(release: bool = False) -> list[str]: args.append("--include-qt-plugins=qml") + args.extend(_variant_exclusions(workshop)) + if release: args.extend( [ @@ -450,16 +497,14 @@ def create_macos_bundle(project_root: Path, release: bool = False) -> Path | Non return app_dir -def strip_unused_qml_modules(dist_dir: Path) -> None: - """Remove QML modules not imported anywhere in the app's .qml files. - - The app only uses QtQuick, QtQuick.Controls, QtQuick.Layouts, - QtWebChannel, and QtWebEngine. - """ +def strip_unused_qml_modules(dist_dir: Path, workshop: str = "NativeWorkshop") -> None: + """Remove QML modules unused by the selected Workshop browser.""" qml_root = dist_dir / "PySide6" / "qml" if not qml_root.is_dir(): return - keep = {"Qt", "QtQml", "QtQuick", "QtWebChannel", "QtWebEngine"} + keep = {"Qt", "QtQml", "QtQuick"} + if workshop == "SteamWorkshop": + keep.update({"QtWebChannel", "QtWebEngine"}) removed = 0 for entry in sorted(qml_root.iterdir()): if entry.is_dir() and entry.name not in keep: @@ -542,21 +587,38 @@ def _get_dir_size(path: Path) -> int: def main() -> None: - release = "--release" in sys.argv - bundle_qt = "--bundle-qt" in sys.argv + parser = argparse.ArgumentParser(description="Build a standalone PxModRim package.") + parser.add_argument("--release", action="store_true") + parser.add_argument("--bundle-qt", action="store_true") + parser.add_argument( + "--workshop", + choices=("NoWorkshop", "SteamWorkshop", "NativeWorkshop"), + default="NativeWorkshop", + ) + options = parser.parse_args() + release = options.release + bundle_qt = options.bundle_qt + workshop = options.workshop project_root = Path(__file__).parent.parent system = platform.system() print("Step 0: Cleaning QML debug artifacts...") clean_qml_debug_artifacts() - print(f"Step 1: Building standalone (release={release}, bundle_qt={bundle_qt})...") - args = get_standalone_args(release=release) - print(f"Running: {' '.join(args)}") + print( + f"Step 1: Building standalone " + f"(release={release}, bundle_qt={bundle_qt}, workshop={workshop})..." + ) + with TemporaryDirectory(prefix="pxmodrim-workshop-") as profile_dir: + manifest = Path(profile_dir) / "workshop-variant.txt" + manifest.write_text(workshop, encoding="utf-8") + args = get_standalone_args(release=release, workshop=workshop) + args.append(f"--include-data-files={manifest}=pxmodrim/workshop-variant.txt") + print(f"Running: {' '.join(args)}") - result = subprocess.run(args, check=False) - if result.returncode != 0: - sys.exit(result.returncode) + result = subprocess.run(args, check=False) + if result.returncode != 0: + sys.exit(result.returncode) dist_dir = project_root / "dist" / "entrypoint.dist" if system == "Darwin": @@ -572,7 +634,7 @@ def main() -> None: strip_qt_locales(dist_dir) print("\nStep 2b: Stripping unused QML modules...") - strip_unused_qml_modules(dist_dir) + strip_unused_qml_modules(dist_dir, workshop=workshop) print("\nStep 2c: Stripping Qt translations...") strip_qt_translations(dist_dir) diff --git a/packaging/linux/pxmodrim.spec b/packaging/linux/pxmodrim.spec index 40870fc..5d10e04 100644 --- a/packaging/linux/pxmodrim.spec +++ b/packaging/linux/pxmodrim.spec @@ -1,5 +1,5 @@ Name: pxmodrim -Version: %{?version}%{!?version:0.2.0} +Version: %{?version}%{!?version:0.3.0} Release: 1%{?dist} Summary: Mod manager for RimWorld License: LGPL-3.0 diff --git a/packaging/windows/installer.nsi b/packaging/windows/installer.nsi index 8c5db9d..c0db9eb 100644 --- a/packaging/windows/installer.nsi +++ b/packaging/windows/installer.nsi @@ -9,7 +9,7 @@ !define PRODUCT_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" !ifndef VERSION - !define VERSION "0.2.0" + !define VERSION "0.3.0" !endif !ifndef DIST_DIR diff --git a/pyproject.toml b/pyproject.toml index 36e1251..c4cab35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "pxmodrim" description = "A mod manager for RimWorld." -version = "0.2.0" +version = "0.3.0" requires-python = "==3.12.*" dependencies = [ "aiosqlite>=0.22.1", diff --git a/scripts/check-deps.py b/scripts/check-deps.py index 0120329..45ec865 100644 --- a/scripts/check-deps.py +++ b/scripts/check-deps.py @@ -37,16 +37,20 @@ "core.sort": {"core.sort"}, "core.checker": {"core.checker"}, "core.config": {"core.config"}, + "core.plugin_config": {"core.plugin_config"}, "core.context": {"core.context"}, "core.mods_config": {"core.mods_config"}, "core.providers": {"core.providers"}, "core.services": {"core.services"}, "core.mod_service": {"core.mod_service"}, "core.downloads": {"core.downloads"}, + "core.workshop": {"core.workshop"}, "core.organizer": {"core.organizer"}, "core.support": {"core.support"}, "ui.progress": {"ui.progress"}, "ui.context": {"ui.context"}, + "ui.navigation": {"ui.navigation"}, + "ui.settings_section": {"ui.settings_section"}, "ui.theme": {"ui.theme"}, "ui.components": {"ui.components"}, "ui.models": {"ui.models"}, @@ -73,6 +77,7 @@ }, "core.checker": {"core.foundation", "core.models", "core.sort", "core.config"}, "core.config": {"core.foundation", "core.msgspec_hooks", "core.sort"}, + "core.plugin_config": {"core.foundation", "core.config"}, "core.context": {"core"}, "core.mods_config": {"core.foundation", "core.models"}, "core.providers": { @@ -99,11 +104,21 @@ "core.mods_config", }, "core.downloads": { + "core.plugin_config", "core.foundation", "core.models", "core.config", "core.context", }, + "core.workshop": { + "core.plugin_config", + "core.foundation", + "core.models", + "core.config", + "core.context", + "core.downloads", + "core.mod_service", + }, "core.organizer": { "core.foundation", "core.models", @@ -117,11 +132,13 @@ "core.context", }, "ui.progress": set(), - "ui.context": {"ui.ui_prefs"}, + "ui.context": {"ui.ui_prefs", "ui.settings_section"}, + "ui.settings_section": {"core"}, "ui.theme": {"ui.components"}, "ui.components": {"ui.theme"}, "ui.models": {"ui.theme"}, "ui.ui_prefs": set(), + "ui.navigation": set(), "ui.config": {"core", "ui.ui_prefs"}, "ui.mod_selection": {"ui.panels"}, "ui.plugins": { @@ -133,8 +150,11 @@ "ui.views", "ui.panels", "ui.mod_selection", + "ui.navigation", + "ui.settings_section", }, "ui.panels": { + "ui.settings_section", "ui.theme", "ui.components", "ui.models", @@ -162,6 +182,7 @@ "ui.config", "ui.ui_prefs", "ui.mod_selection", + "ui.navigation", }, } diff --git a/src/pxmodrim/_app.py b/src/pxmodrim/_app.py index 3bc6bed..a7be82a 100644 --- a/src/pxmodrim/_app.py +++ b/src/pxmodrim/_app.py @@ -9,7 +9,6 @@ from loguru import logger from PySide6.QtGui import QColor, QIcon, QPalette -from PySide6.QtWebEngineQuick import QtWebEngineQuick from PySide6.QtWidgets import QApplication, QDialog, QMessageBox from qasync import QEventLoop @@ -42,6 +41,7 @@ ) from pxmodrim.core.context import CoreContext # noqa: E402 from pxmodrim.ui.components.dialogs import await_dialog # noqa: E402 +from pxmodrim.ui.components.popup_parent import QuickPopupParenting # noqa: E402 from pxmodrim.ui.config import UIPrefsService # noqa: E402 from pxmodrim.ui.context import AppContext # noqa: E402 from pxmodrim.ui.panels.settings_panel import SettingsPanel # noqa: E402 @@ -101,7 +101,22 @@ def _configure_file_logging() -> int: def _parse_disabled_plugins() -> set[str]: disabled_raw = os.environ.get("PX_DISABLED_PLUGINS", "") - return {n.strip() for n in disabled_raw.split(",") if n.strip()} + disabled = {n.strip() for n in disabled_raw.split(",") if n.strip()} + manifest = resource_files("pxmodrim") / "workshop-variant.txt" + if manifest.is_file(): + exclusions = { + "NoWorkshop": { + "steam_downloader", + "steamworkshop", + "downloads_ui", + "workshop_catalog", + "workshop_ui", + }, + "NativeWorkshop": {"steamworkshop"}, + "SteamWorkshop": {"workshop_catalog", "workshop_ui"}, + } + disabled.update(exclusions[manifest.read_text(encoding="utf-8").strip()]) + return disabled _WHEEL_SCROLL_LINES = 8 @@ -113,16 +128,25 @@ class App: __slots__ = ( "_app_ctx", "_ctx", + "_disabled_plugins", "main_window", "qt_app", ) def __init__(self) -> None: _configure_file_logging() - QtWebEngineQuick.initialize() + self._disabled_plugins = _parse_disabled_plugins() + if ( + "steamworkshop" not in self._disabled_plugins + and "steam_downloader" not in self._disabled_plugins + ): + from PySide6.QtWebEngineQuick import QtWebEngineQuick + + QtWebEngineQuick.initialize() self._ctx: CoreContext | None = None self.qt_app = QApplication(sys.argv) + self.qt_app.installEventFilter(QuickPopupParenting(self.qt_app)) # async_run owns the exit: plugin shutdown awaits I/O after the window closes. self.qt_app.setQuitOnLastWindowClosed(False) icon = QIcon(str(resource_files("pxmodrim.ui.assets") / "logo.svg")) @@ -186,7 +210,7 @@ def _setup( self._app_ctx.register_plugin(OrganizerUiPlugin()) - disabled = _parse_disabled_plugins() + disabled = self._disabled_plugins downloaders: list[str] = [] steam_enabled = "steam_downloader" not in disabled @@ -199,9 +223,14 @@ def _setup( else: self._ctx.register_plugin(SteamDownloader()) downloaders.append("steam") + from pxmodrim.ui.plugins.steam_downloader import ( + SteamDownloaderUiPlugin, + ) + + self._app_ctx.register_plugin(SteamDownloaderUiPlugin()) if "steam" in downloaders and "steamworkshop" not in disabled: - from pxmodrim.ui.plugins import SteamWorkshopUiPlugin + from pxmodrim.ui.plugins.steam_workshop import SteamWorkshopUiPlugin self._app_ctx.register_plugin(SteamWorkshopUiPlugin()) @@ -211,6 +240,15 @@ def _setup( self._app_ctx.register_plugin(DownloadsUiPlugin()) + if downloaders and "workshop_catalog" not in disabled: + from pxmodrim.core.workshop import WorkshopCatalog + + self._ctx.register_plugin(WorkshopCatalog()) + if "workshop_ui" not in disabled: + from pxmodrim.ui.plugins.workshop import WorkshopUiPlugin + + self._app_ctx.register_plugin(WorkshopUiPlugin()) + self._app_ctx.setup_all() self.main_window = MainWindow(self._app_ctx) return ctx @@ -265,7 +303,11 @@ async def async_run(self) -> int: if not ctx.config.paths.game: logger.info("No game path found, showing settings dialog") result, dialog = await await_dialog( - SettingsPanel, ctx, self.main_window.qml_engine, self.main_window + SettingsPanel, + ctx, + self.main_window.qml_engine, + self.main_window, + self._app_ctx.settings_sections, ) if app_close_event.is_set(): await self._app_ctx.shutdown_all() diff --git a/src/pxmodrim/core/config.py b/src/pxmodrim/core/config.py index 018a83d..1ca6574 100644 --- a/src/pxmodrim/core/config.py +++ b/src/pxmodrim/core/config.py @@ -66,12 +66,6 @@ class AppConfig(msgspec.Struct): ) max_snapshots: int = 10 compact_mod_list: bool = False - workshop_auto_update_hours: int = 0 - workshop_parallel_items: int = 8 - workshop_threads_per_item: int = 1 - workshop_proxy: str = "" - workshop_connect_timeout: int = 10 - workshop_stall_timeout: int = 30 log_upload_endpoint: str = "https://paste.rs/" launch_args: str = "" launch_wrapper: str = "" @@ -156,6 +150,23 @@ def save(self, filename: str, data: msgspec.Struct) -> None: encoded = msgspec.json.encode(raw) self._write(path, encoded) + def load_existing( + self, filename: str, struct_type: type[StructT] + ) -> StructT | None: + """Like ``load``, but ``None`` when *filename* does not exist yet.""" + if not (self._config_dir / filename).exists(): + return None + return self.load(filename, struct_type) + + def load_raw(self, filename: str) -> dict[str, Any]: + """The undecoded top-level JSON object in *filename*, or ``{}``.""" + try: + return msgspec.json.decode( + (self._config_dir / filename).read_bytes(), type=dict[str, Any] + ) + except (OSError, msgspec.DecodeError): + return {} + @staticmethod def _write(path: Path, data: bytes | dict[str, Any]) -> None: encoded = msgspec.json.encode(data) if isinstance(data, dict) else data diff --git a/src/pxmodrim/core/downloads/manager.py b/src/pxmodrim/core/downloads/manager.py index 4d8a284..615ff3a 100644 --- a/src/pxmodrim/core/downloads/manager.py +++ b/src/pxmodrim/core/downloads/manager.py @@ -61,7 +61,9 @@ def register(self, downloader: Downloader) -> None: downloader.batch_started.connect(self.batch_started.emit) downloader.busy_changed.connect(self._on_busy) - def _on_busy(self, _busy: bool) -> None: + def _on_busy(self, busy: bool) -> None: + if busy: + self._cancelled = False self.busy_changed.emit(self.is_downloading) @property @@ -131,6 +133,10 @@ async def download_mods(self, mod_ids: list[str]) -> DownloadResult: total.changed += result.changed return total + @property + def cancelled(self) -> bool: + return self._cancelled + def cancel(self) -> None: self._cancelled = True for downloader in self._downloaders: diff --git a/src/pxmodrim/core/downloads/steam.py b/src/pxmodrim/core/downloads/steam.py index 49916c7..7e4f113 100644 --- a/src/pxmodrim/core/downloads/steam.py +++ b/src/pxmodrim/core/downloads/steam.py @@ -21,6 +21,7 @@ DownloadProgress, DownloadResult, ) +from pxmodrim.core.plugin_config import PluginConfig if TYPE_CHECKING: from pxmodrim.core.context import CoreContext @@ -128,6 +129,20 @@ def progress(self) -> DownloadProgress: ) +class SteamSettings(msgspec.Struct, frozen=True): + auto_update_hours: int = 0 + parallel_items: int = 8 + threads_per_item: int = 1 + proxy: str = "" + connect_timeout: int = 10 + stall_timeout: int = 30 + + +_LEGACY_SETTINGS = { + f"workshop_{field}": field for field in SteamSettings.__struct_fields__ +} + + class SteamDownloader(Downloader): """Downloads Steam Workshop items into the local mods folder via PxSteamDL.""" @@ -145,10 +160,15 @@ def __init__(self, client_factory: ClientFactory | None = None) -> None: self._sync = WorkshopSyncState() self._auto_task: asyncio.Task[None] | None = None self._running: asyncio.Future[list[pxsteamdl.Result]] | None = None + self.settings = PluginConfig(None, self.name, SteamSettings) def setup(self, ctx: CoreContext) -> None: super().setup(ctx) self._ctx = ctx + if ctx.has_config_service: + self.settings = PluginConfig( + ctx.config_service, self.name, SteamSettings, legacy=_LEGACY_SETTINGS + ) async def init(self, ctx: CoreContext) -> None: self._sync = await asyncio.to_thread( @@ -279,7 +299,7 @@ async def _auto_update_loop(self) -> None: async def _auto_update_once(self) -> None: if self._ctx is None or self.is_downloading: return - hours = self._ctx.config.workshop_auto_update_hours + hours = self.settings.value.auto_update_hours if hours <= 0: return ids = self.stale_ids(hours * 3600) @@ -326,13 +346,9 @@ async def _run( self._finish_item(batch, pid, error=message) return - cfg = self._ctx.config if self._ctx is not None else None - parallel_items = min( - MAX_PARALLEL_ITEMS, max(1, cfg.workshop_parallel_items if cfg else 8) - ) - threads_per_item = min( - MAX_THREADS_PER_ITEM, max(1, cfg.workshop_threads_per_item if cfg else 1) - ) + cfg = self.settings.value + parallel_items = min(MAX_PARALLEL_ITEMS, max(1, cfg.parallel_items)) + threads_per_item = min(MAX_THREADS_PER_ITEM, max(1, cfg.threads_per_item)) logger.debug( "[workshop] client.download start: {} items, {} parallel x {} threads", len(ids), @@ -402,13 +418,11 @@ def on_progress(p: pxsteamdl.Progress) -> None: self._finish_item(batch, pid, error=result.error) def _wanted_options(self) -> ClientOptions: - cfg = self._ctx.config if self._ctx is not None else None - if cfg is None: - return (None, 10, 30) + cfg = self.settings.value return ( - cfg.workshop_proxy.strip() or None, - max(1, cfg.workshop_connect_timeout), - max(1, cfg.workshop_stall_timeout), + cfg.proxy.strip() or None, + max(1, cfg.connect_timeout), + max(1, cfg.stall_timeout), ) async def _ensure_client(self) -> WorkshopClient: diff --git a/src/pxmodrim/core/plugin_config.py b/src/pxmodrim/core/plugin_config.py new file mode 100644 index 0000000..b86f98b --- /dev/null +++ b/src/pxmodrim/core/plugin_config.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING + +import msgspec +from loguru import logger + +from pxmodrim.core.events import Event + +if TYPE_CHECKING: + from pxmodrim.core.config import ConfigService + + +class PluginConfig[T: msgspec.Struct]: + """A plugin's own settings, kept in ``plugins/.json``. + + *legacy* maps keys of the old shared ``config.json`` to fields of *settings_type*; + they seed the file the first time it is created. Without a *service* the + settings live in memory only. + """ + + __slots__ = ("_filename", "_service", "_value", "changed") + + def __init__( + self, + service: ConfigService | None, + name: str, + settings_type: type[T], + legacy: Mapping[str, str] | None = None, + ) -> None: + self.changed: Event[T] = Event() + self._service = service + self._filename = f"plugins/{name}.json" + stored = ( + None + if service is None + else service.load_existing(self._filename, settings_type) + ) + if stored is None: + stored = self._seed(settings_type, legacy or {}) + self._value: T = stored + + def _seed(self, settings_type: type[T], legacy: Mapping[str, str]) -> T: + if self._service is None or not legacy: + return settings_type() + raw = self._service.load_raw("config.json") + fields = {new: raw[old] for old, new in legacy.items() if old in raw} + try: + value = msgspec.convert(fields, settings_type) + except msgspec.ValidationError as exc: + logger.warning("Ignoring old settings for {}: {}", self._filename, exc) + return settings_type() + if fields: + self._store(value) + return value + + @property + def value(self) -> T: + return self._value + + def update(self, value: T) -> None: + if value == self._value: + return + self._value = value + self._store(value) + self.changed.emit(value) + + def _store(self, value: T) -> None: + if self._service is None: + return + try: + self._service.save(self._filename, value) + except OSError as exc: + logger.warning("Cannot save {}: {}", self._filename, exc) diff --git a/src/pxmodrim/core/services/activation_service.py b/src/pxmodrim/core/services/activation_service.py index e5a35a0..66324b3 100644 --- a/src/pxmodrim/core/services/activation_service.py +++ b/src/pxmodrim/core/services/activation_service.py @@ -21,14 +21,25 @@ class ActivationService: def __init__(self, ctx: CoreContext) -> None: self._ctx = ctx - def dependents_of(self, uuids: Iterable[str]) -> list[str]: - """Active mods that transitively depend on any of *uuids*, in active order.""" + def dependents_of( + self, uuids: Iterable[str], *, enable: Iterable[str] = () + ) -> list[str]: + """Active dependents of packages lost after the enable/disable changes.""" excluded = set(uuids) all_mods = self._ctx.all_mods + remaining = { + PackageId(mod.package_id) + for uuid in (*self._ctx.active_uuids, *enable) + if uuid not in excluded + and isinstance(mod := all_mods.get(uuid), AboutXmlMod) + } roots: set[PackageId] = set() for uuid in excluded: mod = all_mods.get(uuid) - if isinstance(mod, AboutXmlMod): + if ( + isinstance(mod, AboutXmlMod) + and PackageId(mod.package_id) not in remaining + ): roots.add(PackageId(mod.package_id)) if not roots: return [] diff --git a/src/pxmodrim/core/workshop/__init__.py b/src/pxmodrim/core/workshop/__init__.py new file mode 100644 index 0000000..730c9b3 --- /dev/null +++ b/src/pxmodrim/core/workshop/__init__.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pxmodrim.core.workshop.catalog import CatalogSettings, WorkshopCatalog +from pxmodrim.core.workshop.client import CatalogClient, CatalogError +from pxmodrim.core.workshop.types import ( + Author, + CatalogCollection, + CatalogMod, + CatalogPage, + CatalogQuery, + CollectionDetail, + Discover, + DownloadPlan, + InstallState, + Preview, + ResolvedDownload, + Votes, +) + +__all__ = [ + "Author", + "CatalogClient", + "CatalogCollection", + "CatalogError", + "CatalogMod", + "CatalogPage", + "CatalogQuery", + "CatalogSettings", + "CollectionDetail", + "Discover", + "DownloadPlan", + "InstallState", + "Preview", + "ResolvedDownload", + "Votes", + "WorkshopCatalog", +] diff --git a/src/pxmodrim/core/workshop/catalog.py b/src/pxmodrim/core/workshop/catalog.py new file mode 100644 index 0000000..d1ca96e --- /dev/null +++ b/src/pxmodrim/core/workshop/catalog.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +import asyncio +import hashlib +import time +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar + +import msgspec +from loguru import logger + +from pxmodrim.core.downloads.manager import DownloadManager, download_manager +from pxmodrim.core.events import Event +from pxmodrim.core.plugin import Plugin +from pxmodrim.core.plugin_config import PluginConfig +from pxmodrim.core.workshop.client import CatalogClient, CatalogError +from pxmodrim.core.workshop.favourites import FavouriteCollections +from pxmodrim.core.workshop.types import ( + CatalogCollection, + CatalogMod, + CatalogPage, + CatalogQuery, + CollectionDetail, + Discover, + DownloadPlan, + InstallState, +) + +if TYPE_CHECKING: + from pathlib import Path + + from pxmodrim.core.config import AppConfig + from pxmodrim.core.context import CoreContext + from pxmodrim.core.downloads.types import DownloadResult + from pxmodrim.core.models.metadata.structures import ListedMod + +FRESH_SECONDS = 300.0 +_CACHE_LIMIT = 200 +_DISK_LIMIT = 300 + +_V = TypeVar("_V") + + +class _Stored(msgspec.Struct, Generic[_V]): # noqa: UP046 + at: float + value: _V + + +def _cache_file(directory: Path, key: str) -> Path: + return directory / f"{hashlib.sha256(key.encode()).hexdigest()[:32]}.json" + + +def _read_stored(path: Path, value_type: Any) -> tuple[float, Any] | None: + stored_type: Any = _Stored[value_type] + try: + stored = msgspec.json.decode(path.read_bytes(), type=stored_type) + except (OSError, msgspec.DecodeError): + return None + return stored.at, stored.value + + +def _write_stored(directory: Path, path: Path, entry: tuple[float, Any]) -> None: + directory.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".tmp") + temporary.write_bytes(msgspec.json.encode(_Stored(entry[0], entry[1]))) + temporary.replace(path) + files = sorted(directory.glob("*.json"), key=lambda file: file.stat().st_mtime) + for stale in files[:-_DISK_LIMIT]: + stale.unlink(missing_ok=True) + + +DEFAULT_CATALOG_URL = "https://api.modrim.pyxiion.dev" + + +class CatalogSettings(msgspec.Struct, frozen=True): + url: str = DEFAULT_CATALOG_URL + + +class WorkshopCatalog(Plugin): + name = "workshop_catalog" + dependencies: ClassVar[list[str]] = ["downloads"] + + def __init__( + self, + client_factory: Callable[[str], CatalogClient] = CatalogClient, + ) -> None: + self.installed_changed: Event[None] = Event() + self.queue_changed: Event[None] = Event() + self._queue: list[list[str]] = [] + self._queued: set[str] = set() + self._runner: asyncio.Future[None] | None = None + self._idle = asyncio.Event() + self.catalog_url_changed: Event[str] = Event() + self.settings = PluginConfig(None, self.name, CatalogSettings) + self.settings.changed.connect(self._on_settings_changed) + self.favourites = FavouriteCollections(None) + self._client_factory = client_factory + self._base_url = self.settings.value.url.strip().rstrip("/") + self._client: CatalogClient | None = None + self._clients: list[CatalogClient] = [] + self._ctx: CoreContext | None = None + self._manager: DownloadManager | None = None + self._installed_index: dict[str, list[ListedMod]] | None = None + self._responses: dict[str, tuple[float, Any]] = {} + self._cache_dir: Path | None = None + + def setup(self, ctx: CoreContext) -> None: + self._ctx = ctx + if ctx.has_config_service: + self.settings = PluginConfig( + ctx.config_service, + self.name, + CatalogSettings, + legacy={"workshop_catalog_url": "url"}, + ) + self.settings.changed.connect(self._on_settings_changed) + self._change_url(self.settings.value.url) + self._cache_dir = ctx.config_service.config_dir / "workshop-cache" + self.favourites = FavouriteCollections(ctx.config_service) + self._manager = download_manager(ctx) + ctx.mod_service.mods_changed.connect(self._on_installed_changed) + self._manager.download_finished.connect(self._on_download_finished) + self._manager.busy_changed.connect(self._on_busy_changed) + ctx.config_changed.connect(self._on_config_changed) + + def _on_installed_changed(self, _: None) -> None: + self._installed_index = None + self.installed_changed.emit(None) + + def _on_download_finished(self, _: DownloadResult) -> None: + self._installed_index = None + self.installed_changed.emit(None) + + def _on_config_changed(self, _config: AppConfig) -> None: + self._installed_index = None + self.installed_changed.emit(None) + + def _on_settings_changed(self, settings: CatalogSettings) -> None: + self._change_url(settings.url) + + def _change_url(self, url: str) -> None: + url = url.strip().rstrip("/") + if self._base_url == url: + return + self._base_url = url + self._client = None + self.catalog_url_changed.emit(url) + + def set_base_url(self, url: str) -> None: + self.settings.update(CatalogSettings(url.strip().rstrip("/"))) + + @property + def configured(self) -> bool: + return bool(self._base_url) + + def _catalog_client(self) -> CatalogClient: + if not self.configured: + raise CatalogError("Workshop catalog is disabled.") + if self._client is None: + self._client = self._client_factory(self._base_url) + # Keep replaced clients alive until shutdown so in-flight requests finish. + self._clients.append(self._client) + return self._client + + async def shutdown(self) -> None: + self.cancel_queue() + if self._runner is not None: + self._runner.cancel() + self._runner = None + if self._ctx is not None: + self._ctx.mod_service.mods_changed.disconnect(self._on_installed_changed) + self._ctx.config_changed.disconnect(self._on_config_changed) + if self._manager is not None: + self._manager.download_finished.disconnect(self._on_download_finished) + self._manager.busy_changed.disconnect(self._on_busy_changed) + for client in self._clients: + await client.shutdown() + self._clients.clear() + self._client = None + + async def discover(self) -> Discover: + return await self._catalog_client().discover() + + async def mods(self, q: CatalogQuery) -> CatalogPage[CatalogMod]: + return await self._catalog_client().mods(q) + + async def collections(self, q: CatalogQuery) -> CatalogPage[CatalogCollection]: + return await self._catalog_client().collections(q) + + async def mod(self, id: str) -> CatalogMod | None: + return await self._catalog_client().mod(id) + + async def collection(self, id: str) -> CollectionDetail | None: + return await self._catalog_client().collection(id) + + def _installed(self) -> dict[str, list[ListedMod]]: + if self._ctx is None: + raise RuntimeError("Workshop catalog accessed before setup") + if self._installed_index is None: + installed: dict[str, list[ListedMod]] = {} + for mod in self._ctx.all_mods.values(): + id = mod.published_file_id + if id is not None: + installed.setdefault(id, []).append(mod) + self._installed_index = installed + return self._installed_index + + async def cached[T]( + self, + key: str, + produce: Callable[[], Awaitable[T]], + fresh_for: float = FRESH_SECONDS, + value_type: Any = None, + ) -> AsyncIterator[T]: + """Yield the remembered value at once, then the refreshed one if it differs. + + A value younger than *fresh_for* is served without contacting the catalog. + With *value_type*, values are also kept on disk, so they survive a restart. + """ + key = f"{self._base_url}|{key}" + entry = self._responses.get(key) + directory = self._cache_dir if value_type is not None else None + if entry is None and directory is not None: + entry = await asyncio.to_thread( + _read_stored, _cache_file(directory, key), value_type + ) + if entry is not None: + self._responses[key] = entry + while len(self._responses) > _CACHE_LIMIT: + del self._responses[next(iter(self._responses))] + yield entry[1] + if time.time() - entry[0] < fresh_for: + return + value = await produce() + changed = entry is None or value != entry[1] + if entry is not None and not changed: + value = entry[1] + remembered = (time.time(), value) + self._responses.pop(key, None) + self._responses[key] = remembered + while len(self._responses) > _CACHE_LIMIT: + del self._responses[next(iter(self._responses))] + if directory is not None: + try: + await asyncio.to_thread( + _write_stored, directory, _cache_file(directory, key), remembered + ) + except OSError as exc: + logger.warning("[workshop] cannot write cache: {}", exc) + if changed: + yield value + + def _install_state( + self, mod: CatalogMod, installed: dict[str, list[ListedMod]] + ) -> InstallState: + local = installed.get(mod.id) + if not local: + return "missing" + if self._manager is None: + raise RuntimeError("Workshop catalog accessed before setup") + synced = [ + timestamp + for item in local + if (timestamp := self._manager.last_synced(item)) is not None + ] + if synced and mod.updated_at is not None and max(synced) < mod.updated_at: + return "outdated" + return "installed" + + def install_state(self, mod: CatalogMod) -> InstallState: + return self._install_state(mod, self._installed()) + + async def installed_with_updates(self) -> list[CatalogMod]: + client = self._catalog_client() + items, _ = await client.mods_batch(list(self._installed())) + return items + + async def plan(self, mod_ids: list[str], collection_ids: list[str]) -> DownloadPlan: + resolved = await self._catalog_client().resolve(mod_ids, collection_ids) + installed = self._installed() + to_download: list[str] = [] + already_current: list[str] = [] + for id in resolved.mod_ids: + mod = resolved.items.get(id) + if not isinstance(mod, CatalogMod): + raise CatalogError( + "The Workshop catalog returned an invalid download plan." + ) + if self._install_state(mod, installed) == "installed": + already_current.append(id) + else: + to_download.append(id) + return DownloadPlan( + to_download=to_download, + already_current=already_current, + unavailable_ids=resolved.unavailable_ids, + incomplete_collection_ids=resolved.incomplete_collection_ids, + titles={id: item.title for id, item in resolved.items.items()}, + is_complete=resolved.is_complete, + ) + + async def download(self, plan: DownloadPlan) -> DownloadResult: + if self._manager is None: + raise RuntimeError("Workshop catalog accessed before setup") + return await self._manager.download_mods(plan.to_download) + + @property + def queued_ids(self) -> frozenset[str]: + return frozenset(self._queued) + + def enqueue(self, plan: DownloadPlan) -> None: + """Queue *plan* behind any running download without waiting for it.""" + if self._manager is None: + raise RuntimeError("Workshop catalog accessed before setup") + active = self._manager.active_ids + ids = [ + id for id in plan.to_download if id not in self._queued and id not in active + ] + if not ids: + return + self._queue.append(ids) + self._queued.update(ids) + self.queue_changed.emit(None) + if self._runner is None or self._runner.done(): + self._runner = asyncio.ensure_future(self._drain()) + + def cancel_queue(self) -> None: + self._queue.clear() + self._queued.clear() + self.queue_changed.emit(None) + + async def _drain(self) -> None: + manager = self._manager + if manager is None: + return + while self._queue: + ids = self._queue.pop(0) + waited = manager.is_downloading + while manager.is_downloading: + self._idle.clear() + await self._idle.wait() + if waited and manager.cancelled: + self.cancel_queue() + return + try: + await manager.download_mods(ids) + except (RuntimeError, ValueError) as exc: + logger.warning("[workshop] queued download failed: {}", exc) + self._queued.difference_update(ids) + self.queue_changed.emit(None) + if manager.cancelled: + self.cancel_queue() + return + + def _on_busy_changed(self, busy: bool) -> None: + if not busy: + self._idle.set() diff --git a/src/pxmodrim/core/workshop/client.py b/src/pxmodrim/core/workshop/client.py new file mode 100644 index 0000000..a494aaf --- /dev/null +++ b/src/pxmodrim/core/workshop/client.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from urllib.parse import quote + +import httpx +import msgspec + +from pxmodrim.core.workshop.types import ( + CatalogCollection, + CatalogMod, + CatalogPage, + CatalogQuery, + CollectionDetail, + Discover, + ResolvedDownload, +) + + +class CatalogError(Exception): + def __init__(self, message: str, status: int | None = None) -> None: + super().__init__(message) + self.message = message + self.status = status + + +class _ErrorMessage(msgspec.Struct): + message: str + + +class _ErrorEnvelope(msgspec.Struct): + error: _ErrorMessage + + +class _Batch(msgspec.Struct): + items: list[CatalogMod] + unavailable_ids: list[str] + + +class CatalogClient: + def __init__( + self, base_url: str, transport: httpx.AsyncBaseTransport | None = None + ) -> None: + self._base_url = base_url.strip().rstrip("/") + self._http = httpx.AsyncClient(transport=transport, timeout=30.0) + + async def shutdown(self) -> None: + await self._http.aclose() + + async def _request[T]( + self, + method: str, + path: str, + response_type: type[T], + *, + params: dict[str, str | int] | None = None, + payload: dict[str, list[str]] | None = None, + ) -> T: + if not self._base_url: + raise CatalogError("Workshop catalog is disabled.") + try: + response = await self._http.request( + method, + f"{self._base_url}/catalog/{path}", + params=params, + json=payload, + timeout=30.0, + ) + except (httpx.HTTPError, httpx.InvalidURL) as exc: + raise CatalogError(f"Cannot reach the Workshop catalog: {exc}") from exc + if not response.is_success: + try: + error = msgspec.json.decode(response.content, type=_ErrorEnvelope) + message = error.error.message + except msgspec.DecodeError: + message = ( + f"Workshop catalog request failed " + f"(HTTP {response.status_code} {response.reason_phrase})." + ) + raise CatalogError(message, response.status_code) + try: + return msgspec.json.decode(response.content, type=response_type) + except msgspec.DecodeError as exc: + raise CatalogError( + "The Workshop catalog returned an invalid response.", + response.status_code, + ) from exc + + @staticmethod + def _params(query: CatalogQuery) -> dict[str, str | int]: + params: dict[str, str | int] = { + "q": query.query, + "sort": query.sort, + "limit": query.limit, + "source": query.source, + } + for key, value in ( + ("tag", query.tag), + ("version", query.version), + ("cursor", query.cursor), + ): + if value is not None: + params[key] = value + return params + + async def discover(self) -> Discover: + return await self._request("GET", "discover", Discover) + + async def mods(self, q: CatalogQuery) -> CatalogPage[CatalogMod]: + return await self._request( + "GET", "mods", CatalogPage[CatalogMod], params=self._params(q) + ) + + async def collections(self, q: CatalogQuery) -> CatalogPage[CatalogCollection]: + return await self._request( + "GET", "collections", CatalogPage[CatalogCollection], params=self._params(q) + ) + + async def mod(self, id: str) -> CatalogMod | None: + try: + return await self._request("GET", f"mods/{quote(id, safe='')}", CatalogMod) + except CatalogError as exc: + if exc.status == 404: + return None + raise + + async def collection(self, id: str) -> CollectionDetail | None: + source, separator, key = id.partition(":") + if not separator or source not in {"steam", "picked"} or not key: + raise CatalogError("Invalid Workshop collection ID.") + try: + return await self._request( + "GET", f"collections/{source}/{quote(key, safe='')}", CollectionDetail + ) + except CatalogError as exc: + if exc.status == 404: + return None + raise + + async def mods_batch(self, ids: list[str]) -> tuple[list[CatalogMod], list[str]]: + items: list[CatalogMod] = [] + unavailable: list[str] = [] + for start in range(0, len(ids), 100): + batch = await self._request( + "POST", "mods/batch", _Batch, payload={"ids": ids[start : start + 100]} + ) + items.extend(batch.items) + unavailable.extend(batch.unavailable_ids) + return items, unavailable + + async def resolve( + self, ids: list[str], collection_ids: list[str] + ) -> ResolvedDownload: + roots: dict[str, None] = {} + items: dict[str, CatalogMod | CatalogCollection] = {} + mod_ids: dict[str, None] = {} + unavailable: dict[str, None] = {} + incomplete: dict[str, None] = {} + is_complete = True + batches = max((len(ids) + 49) // 50, (len(collection_ids) + 9) // 10, 1) + for batch in range(batches): + resolved = await self._request( + "POST", + "resolve", + ResolvedDownload, + payload={ + "ids": ids[batch * 50 : (batch + 1) * 50], + "collection_ids": collection_ids[batch * 10 : (batch + 1) * 10], + }, + ) + roots.update(dict.fromkeys(resolved.roots)) + items.update(resolved.items) + mod_ids.update(dict.fromkeys(resolved.mod_ids)) + unavailable.update(dict.fromkeys(resolved.unavailable_ids)) + incomplete.update(dict.fromkeys(resolved.incomplete_collection_ids)) + is_complete = is_complete and resolved.is_complete + return ResolvedDownload( + roots=list(roots), + items=items, + mod_ids=list(mod_ids), + unavailable_ids=list(unavailable), + incomplete_collection_ids=list(incomplete), + is_complete=is_complete, + ) diff --git a/src/pxmodrim/core/workshop/favourites.py b/src/pxmodrim/core/workshop/favourites.py new file mode 100644 index 0000000..dff168c --- /dev/null +++ b/src/pxmodrim/core/workshop/favourites.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import msgspec + +from pxmodrim.core.plugin_config import PluginConfig +from pxmodrim.core.workshop.types import CatalogCollection + +if TYPE_CHECKING: + from pxmodrim.core.config import ConfigService + from pxmodrim.core.events import Event + + +class _Stored(msgspec.Struct, frozen=True): + collections: list[CatalogCollection] = msgspec.field(default_factory=list) + + +class FavouriteCollections: + """Collections the user starred, newest first. + + Each entry is the last catalog snapshot of the collection, so the list shows + without a network round trip and survives the catalog dropping it. + """ + + __slots__ = ("_config", "_ids") + + def __init__(self, service: ConfigService | None) -> None: + self._config = PluginConfig(service, "workshop_favourites", _Stored) + self._ids = {item.id for item in self._config.value.collections} + + @property + def changed(self) -> Event[_Stored]: + return self._config.changed + + @property + def items(self) -> list[CatalogCollection]: + return list(self._config.value.collections) + + def __contains__(self, collection_id: str) -> bool: + return collection_id in self._ids + + def set(self, collection: CatalogCollection, favourite: bool) -> None: + if (collection.id in self._ids) == favourite: + return + rest = [c for c in self._config.value.collections if c.id != collection.id] + self._save([collection, *rest] if favourite else rest) + + def remember(self, collection: CatalogCollection) -> None: + """Refresh the stored snapshot of a starred collection.""" + if collection.id not in self._ids: + return + stored = self._config.value.collections + if collection in stored: + return + self._save([collection if c.id == collection.id else c for c in stored]) + + def _save(self, collections: list[CatalogCollection]) -> None: + self._ids = {item.id for item in collections} + self._config.update(_Stored(collections)) diff --git a/src/pxmodrim/core/workshop/types.py b/src/pxmodrim/core/workshop/types.py new file mode 100644 index 0000000..4522890 --- /dev/null +++ b/src/pxmodrim/core/workshop/types.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from typing import Generic, Literal, TypeVar + +import msgspec + + +class Author(msgspec.Struct, frozen=True): + id: str | None + name: str | None + profile_url: str | None + + +class Preview(msgspec.Struct, frozen=True): + type: Literal["image", "video"] + url: str + + +class Votes(msgspec.Struct, frozen=True): + up: int + down: int + positive_percent: float | None + + +class CatalogMod(msgspec.Struct, frozen=True, tag="mod", tag_field="kind"): + id: str + source: Literal["steam"] + title: str + author: Author + description: str + description_format: Literal["bbcode"] + preview_url: str | None + previews: list[Preview] + workshop_url: str + tags: list[str] + supported_versions: list[str] + created_at: int | None + updated_at: int | None + file_size: str | None + subscriptions: int | None + votes: Votes | None + dependencies: list[str] + package_id: str | None + incompatible: bool + + @property + def kind(self) -> Literal["mod"]: + return "mod" + + +class CatalogCollection( + msgspec.Struct, frozen=True, tag="collection", tag_field="kind" +): + id: str + source: Literal["steam", "picked"] + steam_id: str | None + title: str + author: Author + description: str + description_format: Literal["bbcode", "text"] + preview_url: str | None + previews: list[Preview] + workshop_url: str | None + tags: list[str] + supported_versions: list[str] + created_at: int | None + updated_at: int | None + member_ids: list[str] + member_count: int + member_previews: list[str] = msgspec.field(default_factory=list) + total_size: str | None = None + no_common_version: bool = False + + @property + def kind(self) -> Literal["collection"]: + return "collection" + + +T = TypeVar("T") + + +class CatalogPage(msgspec.Struct, Generic[T], frozen=True): # noqa: UP046 + # msgspec resolves postponed annotations using module globals, not PEP 695 scope. + items: list[T] + total: int + next_cursor: str | None + + +class CollectionDetail(msgspec.Struct, frozen=True): + collection: CatalogCollection + members: list[CatalogMod | CatalogCollection] + unavailable_ids: list[str] + is_complete: bool + + +class Discover(msgspec.Struct, frozen=True): + mods: CatalogPage[CatalogMod] + collections: CatalogPage[CatalogCollection] + + +class ResolvedDownload(msgspec.Struct, frozen=True): + roots: list[str] + items: dict[str, CatalogMod | CatalogCollection] + mod_ids: list[str] + unavailable_ids: list[str] + incomplete_collection_ids: list[str] + is_complete: bool + + +class CatalogQuery(msgspec.Struct, frozen=True): + query: str = "" + tag: str | None = None + version: str | None = None + sort: str = "popular" + limit: int = 24 + cursor: str | None = None + source: str = "all" + + +InstallState = Literal["missing", "installed", "outdated"] + + +class DownloadPlan(msgspec.Struct, frozen=True): + to_download: list[str] + already_current: list[str] + unavailable_ids: list[str] + incomplete_collection_ids: list[str] + titles: dict[str, str] + is_complete: bool diff --git a/src/pxmodrim/ui/assets/logo_nobg_1.svg b/src/pxmodrim/ui/assets/logo_nobg_1.svg index d694825..c618163 100644 --- a/src/pxmodrim/ui/assets/logo_nobg_1.svg +++ b/src/pxmodrim/ui/assets/logo_nobg_1.svg @@ -4,8 +4,8 @@ -PxModRim +PxModRim diff --git a/src/pxmodrim/ui/assets/logo_nobg_2.svg b/src/pxmodrim/ui/assets/logo_nobg_2.svg index 9c723a9..5b099d5 100644 --- a/src/pxmodrim/ui/assets/logo_nobg_2.svg +++ b/src/pxmodrim/ui/assets/logo_nobg_2.svg @@ -4,9 +4,9 @@ + Px ModRim - diff --git a/src/pxmodrim/ui/assets/logo_nobg_4.svg b/src/pxmodrim/ui/assets/logo_nobg_4.svg index 1472f66..3d7fcb4 100644 --- a/src/pxmodrim/ui/assets/logo_nobg_4.svg +++ b/src/pxmodrim/ui/assets/logo_nobg_4.svg @@ -4,9 +4,9 @@ + Px ModRim - diff --git a/src/pxmodrim/ui/components/controls/ModDescription.qml b/src/pxmodrim/ui/components/controls/ModDescription.qml new file mode 100644 index 0000000..8b3294f --- /dev/null +++ b/src/pxmodrim/ui/components/controls/ModDescription.qml @@ -0,0 +1,29 @@ +import QtQuick + +TextEdit { + id: root + + property string description: "" + + text: description.length > 0 + ? "" + + description.replace(/__IMG_WIDTH__/g, Math.max(1, Math.floor(width))) + : "" + textFormat: TextEdit.RichText + readOnly: true + selectByMouse: true + selectedTextColor: Theme.onAccent + selectionColor: Theme.primary + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + wrapMode: TextEdit.WordWrap + + HoverHandler { + cursorShape: root.hoveredLink.length > 0 ? Qt.PointingHandCursor : Qt.IBeamCursor + } +} diff --git a/src/pxmodrim/ui/components/controls/PxBadge.qml b/src/pxmodrim/ui/components/controls/PxBadge.qml index 06627df..73f0603 100644 --- a/src/pxmodrim/ui/components/controls/PxBadge.qml +++ b/src/pxmodrim/ui/components/controls/PxBadge.qml @@ -46,8 +46,12 @@ Rectangle { enabled: root.tooltip.length > 0 } - PxToolTip { - visible: hover.hovered && root.tooltip.length > 0 - text: root.tooltip + Loader { + active: hover.hovered && root.tooltip.length > 0 + sourceComponent: PxToolTip { + parent: root + visible: true + text: root.tooltip + } } } diff --git a/src/pxmodrim/ui/components/controls/PxButton.qml b/src/pxmodrim/ui/components/controls/PxButton.qml index 420365c..f404145 100644 --- a/src/pxmodrim/ui/components/controls/PxButton.qml +++ b/src/pxmodrim/ui/components/controls/PxButton.qml @@ -4,8 +4,8 @@ import QtQuick.Controls Button { id: control - // "secondary" | "primary" | "success" | "danger" (soft, fills on hover) | - // "dangerSolid" | "ghost" + // "secondary" | "primary" | "success" | "warning" | "danger" (soft, fills on + // hover) | "dangerSolid" | "ghost" property string variant: "secondary" // Name from the image://icons provider; icon-only when text is empty. property string iconName: "" @@ -15,6 +15,7 @@ Button { readonly property bool isSuccess: variant === "success" readonly property bool isDangerSolid: variant === "dangerSolid" readonly property bool isDanger: variant === "danger" + readonly property bool isWarning: variant === "warning" readonly property bool isGhost: variant === "ghost" readonly property bool isSolid: isPrimary || isSuccess || isDangerSolid readonly property bool iconOnly: text.length === 0 && iconName.length > 0 @@ -39,16 +40,21 @@ Button { readonly property color foreground: iconColor.a > 0 ? iconColor : isSolid ? Theme.onAccent - : isDanger && hovered ? Theme.onAccent + : (isDanger || isWarning) && hovered ? Theme.onAccent : isDanger ? Theme.danger + : isWarning ? Theme.warning : hovered ? Theme.textMain : Theme.textMuted Accessible.name: text.length > 0 ? text : ToolTip.text - PxToolTip { - visible: control.hovered && text.length > 0 - text: control.ToolTip.text + Loader { + active: control.hovered && control.ToolTip.text.length > 0 + sourceComponent: PxToolTip { + parent: control + visible: true + text: control.ToolTip.text + } } contentItem: Item { @@ -96,6 +102,8 @@ Button { return control.down || control.hovered ? Theme.dangerHover : Theme.danger if (control.isDanger) return control.down || control.hovered ? Theme.danger : Theme.dangerBg + if (control.isWarning) + return control.down || control.hovered ? Theme.warning : Theme.warningBg if (control.isGhost) return control.down || control.hovered ? Theme.elevate3 : "transparent" return control.down || control.hovered ? Theme.elevate4 : Theme.elevate3 @@ -112,6 +120,8 @@ Button { return control.hovered ? Theme.dangerHover : Theme.danger if (control.isDanger) return Theme.danger + if (control.isWarning) + return Theme.warning if (control.isGhost) return control.hovered ? Theme.border : "transparent" return control.hovered ? Theme.elevate4 : Theme.border diff --git a/src/pxmodrim/ui/components/icons.py b/src/pxmodrim/ui/components/icons.py index ce4ecb5..39b69ca 100644 --- a/src/pxmodrim/ui/components/icons.py +++ b/src/pxmodrim/ui/components/icons.py @@ -22,6 +22,7 @@ "chevron-left", "chevron-down", "folder", + "thumbs-up", "tag", "steam", "local", @@ -41,6 +42,8 @@ "donut", "bars", "grip", + "star", + "star-filled", ] from PySide6.QtCore import QRectF @@ -50,9 +53,21 @@ from pxmodrim.ui.theme.palette import PALETTE +_STAR = ( + "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25" + "L7 14.14 2 9.27l6.91-1.01L12 2z" +) + # Each icon is an SVG path data string. # stroke svg uses 24x24 viewBox, stroke-width 2, stroke="currentColor" _ICONS: dict[str, str] = { + "thumbs-up": ( + '' + '' + '' + "" + ), "logo": ( '' @@ -360,6 +375,18 @@ '' "" ), + "star": ( + '' + f'' + "" + ), + "star-filled": ( + '' + f'' + "" + ), } diff --git a/src/pxmodrim/ui/components/mod_activation.py b/src/pxmodrim/ui/components/mod_activation.py index 7d93991..faf8792 100644 --- a/src/pxmodrim/ui/components/mod_activation.py +++ b/src/pxmodrim/ui/components/mod_activation.py @@ -59,7 +59,7 @@ async def apply_activation( enabling = list(enable) disabling = list(disable) activation = ctx.activation - dependents = activation.dependents_of(disabling) + dependents = activation.dependents_of(disabling, enable=enabling) if dependents: all_mods = ctx.all_mods details = [_dependent_detail(all_mods[uuid]) for uuid in dependents] diff --git a/src/pxmodrim/ui/components/popup_parent.py b/src/pxmodrim/ui/components/popup_parent.py new file mode 100644 index 0000000..065e2a8 --- /dev/null +++ b/src/pxmodrim/ui/components/popup_parent.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from PySide6.QtCore import QEvent, QObject +from PySide6.QtQuickWidgets import QQuickWidget + + +class QuickPopupParenting(QObject): + """Gives every QQuickWidget's offscreen window its top-level window as transient + parent when shown. Without it Wayland refuses ``Popup.Window`` popups (menus, + combo box lists, tooltips): "Failed to create popup … transientParent". + """ + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if event.type() == QEvent.Type.Show and isinstance(watched, QQuickWidget): + top = watched.window().windowHandle() + if top is not None: + watched.quickWindow().setTransientParent(top) + return False diff --git a/src/pxmodrim/ui/context.py b/src/pxmodrim/ui/context.py index ebeb53c..05cd402 100644 --- a/src/pxmodrim/ui/context.py +++ b/src/pxmodrim/ui/context.py @@ -6,6 +6,7 @@ from PySide6.QtWidgets import QWidget from pxmodrim.core.plugin import Plugin, PluginRegistry +from pxmodrim.ui.settings_section import SettingsSectionFactory from pxmodrim.ui.ui_prefs import UIPrefs if TYPE_CHECKING: @@ -27,6 +28,12 @@ class RailView(QWidget): view_id: ClassVar[str] icon_name: ClassVar[str] label: ClassVar[str] + #: The download queue: the header's downloads button opens it, and the header + #: hides its own download progress while it is shown. + lists_downloads: ClassVar[bool] = False + + def open_route(self, path: tuple[str, ...]) -> None: + """Handle the part of a ``modrim:///…`` link after the view id.""" def __init__( self, @@ -44,6 +51,7 @@ class AppContext: "_navigate", "_plugins", "_rail_views", + "_settings_sections", "_toasts", "_ui_prefs", ) @@ -52,6 +60,7 @@ def __init__(self, core: CoreContext, ui_prefs: UIPrefs | None = None) -> None: self._core = core self._plugins = PluginRegistry() self._rail_views: list[type[RailView]] = [] + self._settings_sections: list[SettingsSectionFactory] = [] self._toasts: Notifier | None = None self._navigate: Callable[[str], None] | None = None self._ui_prefs = ui_prefs or UIPrefs() @@ -79,6 +88,16 @@ def add_rail_view( def rail_views(self) -> tuple[type[RailView], ...]: return tuple(self._rail_views) + # ── Settings sections ───────────────────────────── + + def add_settings_section(self, factory: SettingsSectionFactory) -> None: + """Show a plugin's section, built per dialog with the dialog as parent.""" + self._settings_sections.append(factory) + + @property + def settings_sections(self) -> tuple[SettingsSectionFactory, ...]: + return tuple(self._settings_sections) + # ── Toasts ──────────────────────────────────────── @property @@ -91,10 +110,10 @@ def toasts(self, notifier: Notifier | None) -> None: # ── Navigation ──────────────────────────────────── - def navigate(self, view_id: str) -> None: - """Switch the main window to the rail view *view_id* (no-op if unknown).""" + def navigate(self, url: str) -> None: + """Open a ``modrim://[/…]`` link (no-op if the view is unknown).""" if self._navigate is not None: - self._navigate(view_id) + self._navigate(url) def set_navigator(self, navigate: Callable[[str], None] | None) -> None: self._navigate = navigate diff --git a/src/pxmodrim/ui/navigation.py b/src/pxmodrim/ui/navigation.py new file mode 100644 index 0000000..77822a9 --- /dev/null +++ b/src/pxmodrim/ui/navigation.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass +from urllib.parse import quote, unquote, urlsplit + +SCHEME = "modrim" +SETTINGS_VIEW_ID = "settings" + + +@dataclass(frozen=True, slots=True) +class Route: + view_id: str + path: tuple[str, ...] = () + + @property + def url(self) -> str: + return build_route(self.view_id, *self.path) + + +def build_route(view_id: str, *path: str) -> str: + segments = "".join(f"/{quote(part, safe='')}" for part in path) + return f"{SCHEME}://{view_id}{segments}" + + +def parse_route(url: str) -> Route | None: + """Parse ``modrim://[/…]``; anything else is not a route.""" + try: + parts = urlsplit(url) + except ValueError: + return None + if parts.scheme != SCHEME or not parts.netloc: + return None + path = tuple(unquote(part) for part in parts.path.split("/") if part) + return Route(parts.netloc, path) diff --git a/src/pxmodrim/ui/panels/ModInfo.qml b/src/pxmodrim/ui/panels/ModInfo.qml index 0a6d541..2f27af8 100644 --- a/src/pxmodrim/ui/panels/ModInfo.qml +++ b/src/pxmodrim/ui/panels/ModInfo.qml @@ -309,32 +309,12 @@ Rectangle { } } - TextEdit { + ModDescription { id: descText objectName: "descText" width: parent.width - text: root.hasInfo - ? "" - + root.description.replace(/__IMG_WIDTH__/g, Math.max(1, Math.floor(width))) - : "" - textFormat: TextEdit.RichText - readOnly: true - selectByMouse: true - selectedTextColor: Theme.onAccent - selectionColor: Theme.primary - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - wrapMode: TextEdit.WordWrap + description: root.hasInfo ? root.description : "" onLinkActivated: link => Qt.openUrlExternally(link) - - HoverHandler { - cursorShape: descText.hoveredLink.length > 0 ? Qt.PointingHandCursor : Qt.IBeamCursor - } } } Text { diff --git a/src/pxmodrim/ui/panels/Settings.qml b/src/pxmodrim/ui/panels/Settings.qml index c6c3884..b5e9a19 100644 --- a/src/pxmodrim/ui/panels/Settings.qml +++ b/src/pxmodrim/ui/panels/Settings.qml @@ -19,12 +19,6 @@ Rectangle { workshop: workshop.text.trim(), config: configFolder.text.trim(), compact: compact.checked, - autoUpdateHours: autoUpdate.currentValue, - parallelItems: parallelItems.value, - threadsPerItem: threadsPerItem.value, - proxy: proxy.text.trim(), - connectTimeout: connectTimeout.value, - stallTimeout: stallTimeout.value, launchArgs: launchArgs.text.trim(), launchWrapper: launchWrapper.text.trim(), afterLaunch: afterLaunch.currentValue, @@ -57,10 +51,7 @@ Rectangle { Layout.fillWidth: true implicitHeight: body.implicitHeight + 52 - radius: Theme.radiusMd - color: Theme.elevate1 - border.width: 1 - border.color: Theme.border + color: "transparent" Text { x: 16 @@ -115,8 +106,8 @@ Rectangle { component Tab: Item { id: tabItem property string label: "" - property int index: 0 - readonly property bool current: root.tab === index + property int page: 0 + readonly property bool current: root.tab === page implicitWidth: tabText.implicitWidth + 32 implicitHeight: 40 @@ -145,7 +136,7 @@ Rectangle { cursorShape: Qt.PointingHandCursor Accessible.role: Accessible.PageTab Accessible.name: tabItem.label - onClicked: root.tab = tabItem.index + onClicked: root.tab = tabItem.page } } @@ -160,8 +151,17 @@ Rectangle { Row { x: 8 - Tab { label: "General"; index: 0 } - Tab { label: "Sorting"; index: 1 } + Tab { label: "General"; page: 0 } + Tab { label: "Sorting"; page: 1 } + Repeater { + model: settings.sections + delegate: Tab { + required property var modelData + required property int index + label: modelData.title + page: index + 2 + } + } } Rectangle { anchors.bottom: parent.bottom @@ -305,157 +305,6 @@ Rectangle { checked: root.initial.compact } } - - Group { - title: "Steam Workshop" - visible: root.initial.steamAvailable - RowLayout { - Layout.fillWidth: true - spacing: 8 - Text { - Layout.preferredWidth: 150 - text: "Auto-update mods" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxComboBox { - id: autoUpdate - Layout.preferredWidth: 180 - Accessible.name: "Auto-update Workshop mods" - textRole: "text" - valueRole: "value" - model: [ - { text: "Off", value: 0 }, - { text: "Every 6 hours", value: 6 }, - { text: "Every 12 hours", value: 12 }, - { text: "Daily", value: 24 }, - { text: "Every 3 days", value: 72 }, - { text: "Weekly", value: 168 } - ] - Component.onCompleted: currentIndex = Math.max(0, indexOfValue(root.initial.autoUpdateHours)) - } - } - Text { - Layout.fillWidth: true - text: "Re-syncs mods downloaded by PxModRim while the app is running." - wrapMode: Text.Wrap - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Text { - Layout.preferredWidth: 150 - text: "Parallel mods" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxSpinBox { - id: parallelItems - from: 1 - to: 8 - value: root.initial.parallelItems - Accessible.name: "Parallel Workshop mod downloads" - toolTipText: "How many mods are downloaded at the same time. Raise it for many small mods; lower it if Steam throttles or drops the connection." - } - } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Text { - Layout.preferredWidth: 150 - text: "Threads per mod" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxSpinBox { - id: threadsPerItem - from: 1 - to: 16 - value: root.initial.threadsPerItem - Accessible.name: "Download threads per Workshop mod" - toolTipText: "Connections used to fetch chunks of a single mod. Raise it for large mods on a fast link; total load is parallel mods \u00d7 threads." - } - } - Text { - Layout.fillWidth: true - text: "Total connections = parallel mods \u00d7 threads per mod. Applies to the next download." - wrapMode: Text.Wrap - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Text { - Layout.preferredWidth: 150 - text: "Proxy" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxTextField { - id: proxy - Layout.fillWidth: true - monospace: true - text: root.initial.proxy - placeholderText: "Use system proxy" - Accessible.name: "Workshop download proxy" - } - } - Text { - Layout.fillWidth: true - text: "URL such as http://host:3128 or socks5h://host:1080. Empty uses https_proxy / all_proxy from the environment." - wrapMode: Text.Wrap - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Text { - Layout.preferredWidth: 150 - text: "Connect timeout (s)" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxSpinBox { - id: connectTimeout - from: 1 - to: 120 - value: root.initial.connectTimeout - Accessible.name: "Workshop connect timeout in seconds" - toolTipText: "How long to wait for a connection before trying another server." - } - } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Text { - Layout.preferredWidth: 150 - text: "Stall timeout (s)" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxSpinBox { - id: stallTimeout - from: 1 - to: 300 - value: root.initial.stallTimeout - Accessible.name: "Workshop stall timeout in seconds" - toolTipText: "How long a transfer may stay below 1 byte/s before it is abandoned and retried." - } - } - } } } @@ -525,6 +374,32 @@ Rectangle { } } } + + Repeater { + model: settings.sections + delegate: ScrollView { + id: sectionScroll + required property var modelData + clip: true + contentWidth: availableWidth + ScrollBar.vertical: PxScrollBar {} + + ColumnLayout { + width: sectionScroll.availableWidth - 32 + x: 16 + y: 16 + spacing: 12 + + Group { + title: sectionScroll.modelData.title + Loader { + Layout.fillWidth: true + Component.onCompleted: setSource(sectionScroll.modelData.source, { section: sectionScroll.modelData.section }) + } + } + } + } + } } PxDialogFooter { diff --git a/src/pxmodrim/ui/panels/mod_info_data.py b/src/pxmodrim/ui/panels/mod_info_data.py index 060545d..9f57671 100644 --- a/src/pxmodrim/ui/panels/mod_info_data.py +++ b/src/pxmodrim/ui/panels/mod_info_data.py @@ -1,5 +1,6 @@ from __future__ import annotations +import html import re import time from typing import TYPE_CHECKING, Any @@ -121,6 +122,15 @@ def link(match: re.Match[str]) -> str: return "".join(out) +def description_to_html(text: str, description_format: str = "bbcode") -> str: + """Render descriptions as safe Qt rich text, preserving plain-text content.""" + if description_format != "bbcode": + return html.escape(text).replace("\n", "
") + return autolink( + bbcode_markup(bbcode_images(restore_entities(unity_rich_text_to_html(text)))) + ) + + def format_age(mtime: float, now: float | None = None) -> str: if mtime <= 0: return "" @@ -278,15 +288,7 @@ def build_mod_info( "needs": needs, "conflicts": conflicts, "neededBy": needed_by, - "description": autolink( - bbcode_markup( - bbcode_images( - restore_entities(unity_rich_text_to_html(mod.description)) - ) - ) - ) - if mod.description - else "", + "description": description_to_html(mod.description), "details": _details(mod, downloads.last_synced(mod)), "canOpenFolder": mod.mod_path is not None and mod.mod_path.exists(), "canUpdate": source is not None, diff --git a/src/pxmodrim/ui/panels/settings_panel.py b/src/pxmodrim/ui/panels/settings_panel.py index f46ba89..35c58a8 100644 --- a/src/pxmodrim/ui/panels/settings_panel.py +++ b/src/pxmodrim/ui/panels/settings_panel.py @@ -1,6 +1,7 @@ from __future__ import annotations import dataclasses +from collections.abc import Sequence from pathlib import Path from typing import Any @@ -23,6 +24,7 @@ from pxmodrim.core.sort.community_service import CommunityRulesService from pxmodrim.ui.components.dialogs import await_dialog from pxmodrim.ui.components.progress_dialog import ProgressDialog +from pxmodrim.ui.settings_section import SettingsSection, SettingsSectionFactory from pxmodrim.ui.theme.palette import PALETTE _QML = Path(__file__).parent / "Settings.qml" @@ -51,10 +53,20 @@ class _SettingsBackend(QObject): communityChanged = Signal() cacheChanged = Signal() - def __init__(self, ctx: CoreContext, dialog: QDialog) -> None: + def __init__( + self, ctx: CoreContext, dialog: QDialog, sections: Sequence[SettingsSection] + ) -> None: super().__init__(dialog) self._ctx = ctx self._dialog = dialog + self._sections = [ + { + "title": section.title, + "source": QUrl.fromLocalFile(str(section.source)).toString(), + "section": section, + } + for section in sections + ] cfg = ctx.config self._initial: dict[str, Any] = { "game": cfg.paths.game, @@ -62,19 +74,12 @@ def __init__(self, ctx: CoreContext, dialog: QDialog) -> None: "workshop": cfg.paths.workshop, "config": cfg.paths.config_folder, "compact": cfg.compact_mod_list, - "autoUpdateHours": cfg.workshop_auto_update_hours, - "parallelItems": cfg.workshop_parallel_items, - "threadsPerItem": cfg.workshop_threads_per_item, - "proxy": cfg.workshop_proxy, - "connectTimeout": cfg.workshop_connect_timeout, - "stallTimeout": cfg.workshop_stall_timeout, "launchArgs": cfg.launch_args, "launchWrapper": cfg.launch_wrapper, "afterLaunch": int(cfg.after_launch), "confirmErrors": cfg.launch_confirm_errors, "confirmUnsaved": cfg.launch_confirm_unsaved, "confirmRunning": cfg.launch_confirm_running, - "steamAvailable": ctx.plugins.get("steam_downloader") is not None, "useAltIds": cfg.sort.use_alternative_package_ids, "checkMissing": cfg.sort.check_missing_dependencies, "useCommunity": cfg.sort.use_community_rules, @@ -96,6 +101,10 @@ def __init__(self, ctx: CoreContext, dialog: QDialog) -> None: def initial(self) -> dict[str, Any]: return self._initial + @Property(list, constant=True) # type: ignore[arg-type] + def sections(self) -> list[dict[str, Any]]: + return self._sections + @Property(str, notify=communityChanged) # type: ignore[arg-type] def communityStatus(self) -> str: return self._community_status @@ -193,6 +202,7 @@ def __init__( ctx: CoreContext, qml_engine: QQmlEngine, parent: QWidget, + sections: Sequence[SettingsSectionFactory] = (), ) -> None: super().__init__(parent) self.setObjectName("settingsPanel") @@ -203,7 +213,8 @@ def __init__( self._ctx = ctx self._config = ctx.config - self._backend = _SettingsBackend(ctx, self) + self._sections = [factory(self) for factory in sections] + self._backend = _SettingsBackend(ctx, self, self._sections) self._backend.saveRequested.connect(self._save) self._backend.cancelRequested.connect(self.reject) @@ -216,6 +227,13 @@ def __init__( self._qml.rootContext().setContextProperty("settings", self._backend) self._qml.setSource(QUrl.fromLocalFile(str(_QML))) layout.addWidget(self._qml) + # Unload before the backend and sections (children of this dialog) are + # destroyed, or every binding to them re-evaluates against null. Queued: + # Save/Cancel finish the dialog from inside a QML click handler. + self.finished.connect(self._unload, Qt.ConnectionType.QueuedConnection) + + def _unload(self) -> None: + self._qml.setSource(QUrl()) def _save(self, values: dict[str, Any]) -> None: self._config = msgspec.structs.replace( @@ -234,12 +252,6 @@ def _save(self, values: dict[str, Any]) -> None: use_community_rules=bool(values["useCommunity"]), ), compact_mod_list=bool(values["compact"]), - workshop_auto_update_hours=int(values["autoUpdateHours"]), - workshop_parallel_items=int(values["parallelItems"]), - workshop_threads_per_item=int(values["threadsPerItem"]), - workshop_proxy=values["proxy"].strip(), - workshop_connect_timeout=int(values["connectTimeout"]), - workshop_stall_timeout=int(values["stallTimeout"]), launch_args=values["launchArgs"].strip(), launch_wrapper=values["launchWrapper"].strip(), after_launch=AfterLaunch(int(values["afterLaunch"])), @@ -247,6 +259,8 @@ def _save(self, values: dict[str, Any]) -> None: launch_confirm_unsaved=bool(values["confirmUnsaved"]), launch_confirm_running=bool(values["confirmRunning"]), ) + for section in self._sections: + section.apply() self.accept() def get_config(self) -> AppConfig: diff --git a/src/pxmodrim/ui/plugins/__init__.py b/src/pxmodrim/ui/plugins/__init__.py index 4fc7240..ab1c8e0 100644 --- a/src/pxmodrim/ui/plugins/__init__.py +++ b/src/pxmodrim/ui/plugins/__init__.py @@ -1,7 +1,3 @@ """UI-layer plugins for PxModRim.""" from __future__ import annotations - -from pxmodrim.ui.plugins.steam_workshop import SteamWorkshopUiPlugin - -__all__ = ["SteamWorkshopUiPlugin"] diff --git a/src/pxmodrim/ui/plugins/downloads/view.py b/src/pxmodrim/ui/plugins/downloads/view.py index 3d42679..456d2c8 100644 --- a/src/pxmodrim/ui/plugins/downloads/view.py +++ b/src/pxmodrim/ui/plugins/downloads/view.py @@ -27,6 +27,7 @@ class DownloadsViewPanel(BaseViewPanel): view_id = "downloads" + lists_downloads = True icon_name = "download" label = "Downloads" diff --git a/src/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml b/src/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml new file mode 100644 index 0000000..947bff0 --- /dev/null +++ b/src/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml @@ -0,0 +1,114 @@ +import QtQuick +import QtQuick.Layouts +import "../../components/controls" + +ColumnLayout { + id: root + required property QtObject section + readonly property var initial: section.initial + spacing: 10 + + component Label: Text { + Layout.preferredWidth: 150 + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + component Hint: Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: "Auto-update mods" } + PxComboBox { + Layout.preferredWidth: 180 + Accessible.name: "Auto-update Workshop mods" + textRole: "text" + valueRole: "value" + model: [ + { text: "Off", value: 0 }, + { text: "Every 6 hours", value: 6 }, + { text: "Every 12 hours", value: 12 }, + { text: "Daily", value: 24 }, + { text: "Every 3 days", value: 72 }, + { text: "Weekly", value: 168 } + ] + Component.onCompleted: currentIndex = Math.max(0, indexOfValue(root.initial.auto_update_hours)) + onActivated: root.section.set("auto_update_hours", currentValue) + } + } + Hint { text: "Re-syncs mods downloaded by PxModRim while the app is running." } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: "Parallel mods" } + PxSpinBox { + from: 1 + to: 8 + value: root.initial.parallel_items + Accessible.name: "Parallel Workshop mod downloads" + toolTipText: "How many mods are downloaded at the same time. Raise it for many small mods; lower it if Steam throttles or drops the connection." + onValueModified: root.section.set("parallel_items", value) + } + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: "Threads per mod" } + PxSpinBox { + from: 1 + to: 16 + value: root.initial.threads_per_item + Accessible.name: "Download threads per Workshop mod" + toolTipText: "Connections used to fetch chunks of a single mod. Raise it for large mods on a fast link; total load is parallel mods \u00d7 threads." + onValueModified: root.section.set("threads_per_item", value) + } + } + Hint { text: "Total connections = parallel mods \u00d7 threads per mod. Applies to the next download." } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: "Proxy" } + PxTextField { + Layout.fillWidth: true + monospace: true + text: root.initial.proxy + placeholderText: "Use system proxy" + Accessible.name: "Workshop download proxy" + onTextEdited: root.section.set("proxy", text.trim()) + } + } + Hint { text: "URL such as http://host:3128 or socks5h://host:1080. Empty uses https_proxy / all_proxy from the environment." } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: "Connect timeout (s)" } + PxSpinBox { + from: 1 + to: 120 + value: root.initial.connect_timeout + Accessible.name: "Workshop connect timeout in seconds" + toolTipText: "How long to wait for a connection before trying another server." + onValueModified: root.section.set("connect_timeout", value) + } + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: "Stall timeout (s)" } + PxSpinBox { + from: 1 + to: 300 + value: root.initial.stall_timeout + Accessible.name: "Workshop stall timeout in seconds" + toolTipText: "How long a transfer may stay below 1 byte/s before it is abandoned and retried." + onValueModified: root.section.set("stall_timeout", value) + } + } +} diff --git a/src/pxmodrim/ui/plugins/steam_downloader/__init__.py b/src/pxmodrim/ui/plugins/steam_downloader/__init__.py new file mode 100644 index 0000000..3d72aa1 --- /dev/null +++ b/src/pxmodrim/ui/plugins/steam_downloader/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from pxmodrim.ui.plugins.steam_downloader.plugin import SteamDownloaderUiPlugin + +__all__ = ["SteamDownloaderUiPlugin"] diff --git a/src/pxmodrim/ui/plugins/steam_downloader/plugin.py b/src/pxmodrim/ui/plugins/steam_downloader/plugin.py new file mode 100644 index 0000000..e5ee0d4 --- /dev/null +++ b/src/pxmodrim/ui/plugins/steam_downloader/plugin.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar, cast + +from pxmodrim.core.plugin import Plugin +from pxmodrim.ui.settings_section import PluginConfigSection + +if TYPE_CHECKING: + from PySide6.QtCore import QObject + + from pxmodrim.core.downloads.steam import SteamDownloader + from pxmodrim.ui.context import AppContext + + +class SteamSettingsSection(PluginConfigSection): + title = "Steam Workshop" + source = Path(__file__).with_name("SteamSettings.qml") + + +class SteamDownloaderUiPlugin(Plugin): + """Settings for the Steam downloader; imports nothing from it at runtime.""" + + name = "steam_downloader_ui" + dependencies: ClassVar[list[str]] = ["steam_downloader"] + + def setup(self, ctx: AppContext) -> None: # type: ignore[override] + downloader = cast("SteamDownloader", ctx.core.plugins.get("steam_downloader")) + + def section(parent: QObject) -> SteamSettingsSection: + return SteamSettingsSection(downloader.settings, parent) + + ctx.add_settings_section(section) diff --git a/src/pxmodrim/ui/plugins/steam_workshop/plugin.py b/src/pxmodrim/ui/plugins/steam_workshop/plugin.py index 3899f70..8fd04a9 100644 --- a/src/pxmodrim/ui/plugins/steam_workshop/plugin.py +++ b/src/pxmodrim/ui/plugins/steam_workshop/plugin.py @@ -8,6 +8,7 @@ from pxmodrim.core.events import Event from pxmodrim.core.plugin import Plugin +from pxmodrim.ui.navigation import build_route if TYPE_CHECKING: from pxmodrim.core.context import CoreContext @@ -173,7 +174,7 @@ async def request_download(self) -> None: self._own_download = True if self._app_ctx is not None: - self._app_ctx.navigate("downloads") + self._app_ctx.navigate(build_route("downloads")) try: for mod_id in ids: self._download_statuses[mod_id] = "queued" diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml new file mode 100644 index 0000000..825aea7 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml @@ -0,0 +1,135 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "../../components/controls" + +Rectangle { + id: card + required property string itemId + required property string kind + required property string title + required property string author + required property string previewUrl + required property var collage + required property string summary + required property string compatLabel + required property string state + required property string actionLabel + required property bool incompatible + required property bool queued + required property int memberCount + required property string votes + required property string fileSize + required property bool active + + readonly property bool isMod: kind === "mod" + readonly property string meta: [ + isMod ? author : memberCount + " mods", + isMod ? "" : author, + isMod && votes !== "Unrated" ? votes + " liked" : "", + fileSize !== "Unknown" ? fileSize : "" + ].filter(part => part.length > 0).join(" · ") + + color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 + radius: Theme.radiusMd + border.color: hover.hovered ? Theme.elevate4 : Theme.border + clip: true + HoverHandler { id: hover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: workshopPanel.openItem(card.itemId, card.kind) } + Accessible.role: Accessible.Button + Accessible.name: card.title + Accessible.onPressAction: workshopPanel.openItem(card.itemId, card.kind) + + FontMetrics { id: bodyMetrics; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSizeMd } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 6 + CatalogThumb { + Layout.fillWidth: true + Layout.preferredHeight: 100 + previewUrl: card.previewUrl + collage: card.collage + } + Text { + Layout.fillWidth: true + Layout.preferredHeight: bodyMetrics.lineSpacing * 2 + text: card.title + textFormat: Text.PlainText + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.bold: true + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + verticalAlignment: Text.AlignTop + } + Text { + Layout.fillWidth: true + text: card.meta + textFormat: Text.PlainText + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + Layout.preferredHeight: bodyMetrics.lineSpacing * 2 + text: card.summary + textFormat: Text.PlainText + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + verticalAlignment: Text.AlignTop + } + RowLayout { + Layout.fillWidth: true + spacing: 6 + Text { + Layout.fillWidth: true + visible: card.compatLabel.length > 0 + text: card.compatLabel + color: card.incompatible ? Theme.warning : card.compatLabel.indexOf("Works") === 0 ? Theme.success : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + } + PxBadge { + visible: card.isMod && card.state !== "missing" + compact: true + text: card.state === "outdated" ? "UPDATE" : "INSTALLED" + textColor: card.state === "outdated" ? Theme.primary : Theme.success + fillColor: card.state === "outdated" ? Theme.primaryBg : Theme.successBg + } + PxBadge { + visible: !card.isMod + compact: true + text: "COLLECTION" + textColor: Theme.textMuted + fillColor: Theme.elevate3 + } + } + Item { Layout.fillHeight: true } + PxButton { + Layout.fillWidth: true + visible: card.state !== "installed" + text: card.actionLabel + variant: card.state === "outdated" ? "warning" : "primary" + enabled: !card.queued + onClicked: workshopPanel.downloadItem(card.itemId, card.kind) + } + PxButton { + Layout.fillWidth: true + visible: card.isMod && card.state === "installed" + text: card.active ? "Deactivate" : "Activate" + variant: card.active ? "danger" : "success" + onClicked: workshopPanel.toggleActivation(card.itemId) + } + } +} diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogImage.qml b/src/pxmodrim/ui/plugins/workshop/CatalogImage.qml new file mode 100644 index 0000000..9f99994 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/CatalogImage.qml @@ -0,0 +1,18 @@ +import QtQuick +import "thumbs.js" as Thumbs + +Image { + property string previewUrl: "" + property int widthStep: 64 + property int heightStep: 32 + // Qt updates sourceSize during reloads; URL dimensions must stay independent. + readonly property int pixelWidth: Thumbs.bucket(width * Screen.devicePixelRatio, widthStep) + readonly property int pixelHeight: Thumbs.bucket(height * Screen.devicePixelRatio, heightStep) + + asynchronous: true + fillMode: Image.PreserveAspectCrop + sourceSize.width: pixelWidth + sourceSize.height: pixelHeight + source: visible && width > 0 && height > 0 + ? Thumbs.sized(previewUrl, pixelWidth, pixelHeight) : "" +} diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogSettings.qml b/src/pxmodrim/ui/plugins/workshop/CatalogSettings.qml new file mode 100644 index 0000000..9aa0df2 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/CatalogSettings.qml @@ -0,0 +1,36 @@ +import QtQuick +import QtQuick.Layouts +import "../../components/controls" + +ColumnLayout { + id: root + required property QtObject section + spacing: 10 + + RowLayout { + Layout.fillWidth: true + Text { + Layout.preferredWidth: 150 + text: "Workshop catalog URL" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + PxTextField { + objectName: "catalogUrlField" + Layout.fillWidth: true + text: root.section.initial.url + placeholderText: "https://api.modrim.pyxiion.dev" + Accessible.name: "Workshop catalog URL" + onTextEdited: root.section.set("url", text.trim()) + } + } + Text { + Layout.fillWidth: true + text: "Catalog service used by the native Workshop browser. Leave empty to disable catalog network requests. Applies on Save." + wrapMode: Text.Wrap + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + } +} diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogThumb.qml b/src/pxmodrim/ui/plugins/workshop/CatalogThumb.qml new file mode 100644 index 0000000..7fb0a9e --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/CatalogThumb.qml @@ -0,0 +1,45 @@ +import QtQuick +import "../../components/controls" + +Rectangle { + id: thumb + property string previewUrl: "" + property var collage: [] + property int widthStep: 64 + property int heightStep: 32 + + color: Theme.elevate3 + radius: Theme.radiusMd + clip: true + + CatalogImage { + id: preview + anchors.fill: parent + previewUrl: thumb.previewUrl + widthStep: thumb.widthStep + heightStep: thumb.heightStep + } + Grid { + id: collageGrid + anchors.fill: parent + visible: thumb.previewUrl === "" && thumb.collage.length > 0 + columns: thumb.collage.length === 1 ? 1 : 2 + readonly property int rowCount: thumb.collage.length > 2 ? 2 : 1 + Repeater { + model: collageGrid.visible ? thumb.collage : [] + CatalogImage { + required property string modelData + width: collageGrid.width / collageGrid.columns + height: collageGrid.height / collageGrid.rowCount + previewUrl: modelData + } + } + } + Image { + anchors.centerIn: parent + width: Math.min(32, parent.height * 0.6) + height: width + visible: preview.status !== Image.Ready && !collageGrid.visible + source: "image://icons/grid?color=" + encodeURIComponent(Theme.textDim) + } +} diff --git a/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml b/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml new file mode 100644 index 0000000..bdaab60 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml @@ -0,0 +1,126 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "../../components/controls" + +Rectangle { + id: banner + required property string itemId + required property string kind + required property string title + required property string author + required property string previewUrl + required property var collage + required property string summary + required property string sourceLabel + required property string compatLabel + required property bool incompatible + required property bool queued + required property int memberCount + required property string fileSize + required property string actionLabel + required property bool favourite + required property string state + + color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 + radius: Theme.radiusMd + border.color: hover.hovered ? Theme.elevate4 : Theme.border + clip: true + HoverHandler { id: hover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: workshopPanel.openItem(banner.itemId, banner.kind) } + Accessible.role: Accessible.Button + Accessible.name: banner.title + Accessible.onPressAction: workshopPanel.openItem(banner.itemId, banner.kind) + + RowLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 12 + CatalogThumb { + Layout.fillHeight: true + Layout.preferredWidth: Math.min(180, banner.width * 0.4) + previewUrl: banner.previewUrl + collage: banner.collage + widthStep: 128 + heightStep: 64 + } + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 4 + Text { + Layout.fillWidth: true + text: banner.title + textFormat: Text.PlainText + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLg + font.bold: true + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: banner.memberCount + " mods" + (banner.fileSize !== "Unknown" ? " · " + banner.fileSize : "") + textFormat: Text.PlainText + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: banner.sourceLabel + " · " + banner.author + textFormat: Text.PlainText + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + Layout.fillHeight: true + text: banner.summary + textFormat: Text.PlainText + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + wrapMode: Text.Wrap + elide: Text.ElideRight + verticalAlignment: Text.AlignTop + } + RowLayout { + Layout.fillWidth: true + Text { + Layout.fillWidth: true + text: banner.compatLabel + color: banner.incompatible ? Theme.warning : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + } + PxButton { + objectName: "favouriteToggle" + variant: "ghost" + iconName: banner.favourite ? "star-filled" : "star" + iconColor: banner.favourite ? Theme.warning : "transparent" + ToolTip.text: banner.favourite ? "Remove from favourites" : "Add to favourites" + onClicked: workshopPanel.toggleFavourite(banner.itemId) + } + Text { + visible: banner.state === "installed" + text: "Installed" + color: Theme.success + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + PxButton { + visible: banner.state !== "installed" + text: banner.actionLabel + variant: "primary" + enabled: !banner.queued + onClicked: workshopPanel.downloadItem(banner.itemId, banner.kind) + } + } + } + } +} diff --git a/src/pxmodrim/ui/plugins/workshop/ModRow.qml b/src/pxmodrim/ui/plugins/workshop/ModRow.qml new file mode 100644 index 0000000..5be102d --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/ModRow.qml @@ -0,0 +1,89 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "../../components/controls" + +Rectangle { + id: row + required property string itemId + required property string kind + required property string title + required property string author + required property string previewUrl + required property string state + required property string actionLabel + required property bool incompatible + required property bool queued + required property bool active + required property string fileSize + + color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 + radius: Theme.radiusMd + border.color: row.active ? Theme.success : Theme.border + HoverHandler { id: hover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: workshopPanel.openItem(row.itemId, row.kind) } + Accessible.role: Accessible.Button + Accessible.name: row.title + Accessible.onPressAction: workshopPanel.openItem(row.itemId, row.kind) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 10 + anchors.topMargin: 6 + anchors.bottomMargin: 6 + spacing: 10 + Item { + implicitWidth: activeBox.implicitWidth + implicitHeight: activeBox.implicitHeight + PxCheckBox { + id: activeBox + objectName: "activeToggle" + anchors.centerIn: parent + visible: row.kind === "mod" && row.state !== "missing" + checked: row.active + Accessible.name: "Active" + Accessible.description: "Enable or disable " + row.title + onToggled: { + workshopPanel.toggleActivation(row.itemId) + checked = Qt.binding(() => row.active) + } + } + } + CatalogThumb { + Layout.fillHeight: true + Layout.preferredWidth: height + previewUrl: row.previewUrl + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { + Layout.fillWidth: true + text: row.title + textFormat: Text.PlainText + color: Theme.textMain + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.bold: true + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: row.incompatible ? "Not for this game version" : row.author + (row.fileSize !== "Unknown" ? " · " + row.fileSize : "") + textFormat: Text.PlainText + color: row.incompatible ? Theme.warning : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + } + } + PxButton { + visible: row.state !== "installed" + text: row.actionLabel + variant: row.state === "outdated" ? "warning" : "primary" + enabled: !row.queued + onClicked: workshopPanel.downloadItem(row.itemId, row.kind) + } + } +} diff --git a/src/pxmodrim/ui/plugins/workshop/TagPicker.qml b/src/pxmodrim/ui/plugins/workshop/TagPicker.qml new file mode 100644 index 0000000..c576549 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/TagPicker.qml @@ -0,0 +1,94 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "../../components/controls" + +PxComboBox { + id: control + property var options: [] + property string selectedTag: "" + displayText: selectedTag || "All tags" + model: options.filter(function (tag) { + return tag === "All tags" || tag.toLowerCase().indexOf(tagSearch.text.toLowerCase()) >= 0 + }) + Accessible.name: "Tag filter" + onActivated: selectedTag = currentText === "All tags" ? "" : currentText + delegate: ItemDelegate { + id: option + required property int index + required property var model + width: ListView.view ? ListView.view.width : control.width + height: 32 + font: control.font + text: model.modelData + highlighted: optionsList.currentIndex === index + onClicked: control.choose(index) + contentItem: Text { + text: option.text + font: option.font + color: option.text === control.displayText ? Theme.primary : Theme.textMain + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + background: Rectangle { + radius: Theme.radiusSm + color: option.highlighted ? Theme.elevate3 : "transparent" + } + } + + function choose(index) { + if (index < 0 || index >= count) + return + currentIndex = index + activated(index) + popup.close() + } + + popup: Popup { + popupType: Popup.Window + y: control.height + 4 + width: control.width + padding: 8 + implicitHeight: contents.implicitHeight + 16 + onOpened: { + tagSearch.text = "" + tagSearch.forceActiveFocus() + } + onClosed: control.forceActiveFocus() + contentItem: ColumnLayout { + id: contents + spacing: 8 + PxTextField { + id: tagSearch + Layout.fillWidth: true + placeholderText: "Find a tag…" + Accessible.name: "Search known tags" + onAccepted: control.choose(control.count > 1 && text.length > 0 ? 1 : 0) + Keys.onDownPressed: { + optionsList.currentIndex = 0 + optionsList.forceActiveFocus() + } + Keys.onEscapePressed: control.popup.close() + } + ListView { + id: optionsList + Layout.fillWidth: true + Layout.preferredHeight: Math.min(contentHeight, 240) + clip: true + model: control.popup.visible ? control.delegateModel : null + currentIndex: 0 + keyNavigationEnabled: true + Keys.onReturnPressed: control.choose(currentIndex) + Keys.onSpacePressed: control.choose(currentIndex) + Keys.onEscapePressed: control.popup.close() + ScrollBar.vertical: PxScrollBar {} + } + } + background: Rectangle { + radius: Theme.radiusMd + color: Theme.elevate2 + border.width: 1 + border.color: Theme.border + } + } +} diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml new file mode 100644 index 0000000..8528bc8 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -0,0 +1,582 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "../../components/controls" + +Rectangle { + id: root + color: Theme.elevate0 + + readonly property var detail: workshopPanel.detail + + function applyFilters() { + workshopPanel.filter(search.text.trim(), version.currentIndex === 0 ? "" : version.currentText, + source.currentValue, sort.currentValue, tag.selectedTag) + } + + component Copy: Text { + textFormat: Text.PlainText + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + wrapMode: Text.Wrap + Layout.fillWidth: true + } + component Heading: Copy { + color: Theme.textMain + font.pixelSize: Theme.fontSizeLg + font.bold: true + } + component CatalogGrid: GridView { + property int columns: Math.max(1, Math.floor(width / 230)) + Layout.fillWidth: true + Layout.preferredHeight: Math.ceil(count / columns) * cellHeight + cellWidth: width / columns + cellHeight: 312 + interactive: false + clip: true + delegate: CatalogCard { + width: GridView.view.cellWidth - 12 + height: GridView.view.cellHeight - 12 + } + } + component CollectionGrid: GridView { + property int columns: Math.max(1, Math.floor(width / 400)) + Layout.fillWidth: true + Layout.preferredHeight: Math.ceil(count / columns) * cellHeight + cellWidth: width / columns + cellHeight: 162 + interactive: false + clip: true + delegate: CollectionBanner { + width: GridView.view.cellWidth - 12 + height: GridView.view.cellHeight - 12 + } + } + component ListingHeader: Item { + height: pageHeading.implicitHeight + 16 + Heading { id: pageHeading; width: parent.width; text: workshopPanel.tab } + } + component ListingFooter: ColumnLayout { + required property var listing + height: implicitHeight + spacing: 12 + Copy { + visible: !workshopPanel.busy && !workshopPanel.error && listing.count === 0 + text: workshopPanel.tab === "Installed" ? "No installed Workshop mods were found." + : workshopPanel.tab === "Favourites" ? (workshopPanel.searchQuery ? "No favourite matches this search." : "No favourites yet. Star a collection to keep it here.") + : "No matching items. Try another search or filter." + } + PxButton { + Layout.alignment: Qt.AlignHCenter + text: "Load more" + visible: listing.hasMore + enabled: !workshopPanel.busy + onClicked: workshopPanel.loadMore() + } + Item { implicitHeight: 12 } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 20 + spacing: 12 + RowLayout { + Layout.fillWidth: true + ColumnLayout { + spacing: 2 + Heading { text: "Workshop" } + Copy { text: "Mods & collections for RimWorld" } + } + PxTextField { + id: search + Layout.fillWidth: true + Layout.maximumWidth: 540 + placeholderText: "Search mods or collections…" + Accessible.name: "Search Workshop" + maximumLength: 200 + enabled: workshopPanel.configured + onAccepted: root.applyFilters() + } + PxButton { text: "Search"; variant: "primary"; enabled: workshopPanel.configured; onClicked: root.applyFilters() } + PxProgressBar { + Layout.preferredWidth: 48 + Layout.alignment: Qt.AlignVCenter + thickness: 3 + indeterminate: true + opacity: workshopPanel.busy || workshopPanel.planning ? 1 : 0 + Accessible.name: "Loading" + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + RowLayout { + Layout.fillWidth: true + spacing: 4 + Repeater { + model: ["Discover", "Mods", "Collections", "Favourites", "Installed"] + Item { + id: tabItem + required property string modelData + readonly property bool current: workshopPanel.tab === modelData + activeFocusOnTab: true + Keys.onReturnPressed: workshopPanel.selectTab(modelData) + Keys.onSpacePressed: workshopPanel.selectTab(modelData) + Rectangle { + anchors.fill: parent + visible: tabItem.activeFocus + color: "transparent" + border.color: Theme.primary + radius: Theme.radiusSm + } + implicitWidth: tabLabel.implicitWidth + 32 + implicitHeight: 36 + Text { + id: tabLabel + anchors.centerIn: parent + text: tabItem.modelData + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.weight: tabItem.current ? Font.DemiBold : Font.Medium + color: tabItem.current ? Theme.primary : tabHover.hovered ? Theme.textMain : Theme.textMuted + } + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 2 + color: tabItem.current ? Theme.primary : "transparent" + } + HoverHandler { id: tabHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: workshopPanel.selectTab(tabItem.modelData) } + Accessible.role: Accessible.PageTab + Accessible.name: modelData + Accessible.selected: current + Accessible.onPressAction: workshopPanel.selectTab(modelData) + } + } + Item { Layout.fillWidth: true } + PxButton { + visible: workshopPanel.tab === "Installed" + text: "Update all (" + workshopPanel.updateCount + ")" + variant: "warning" + enabled: workshopPanel.updateCount > 0 && !workshopPanel.planning + onClicked: workshopPanel.updateAll() + } + } + Rectangle { Layout.fillWidth: true; height: 1; color: Theme.border } + } + RowLayout { + Layout.fillWidth: true + visible: workshopPanel.configured && !workshopPanel.hasDetail && (workshopPanel.tab === "Mods" || workshopPanel.tab === "Collections") + Copy { Layout.fillWidth: false; text: "Game version" } + PxComboBox { + id: version + model: workshopPanel.versionOptions + Accessible.name: "Game version filter" + Component.onCompleted: currentIndex = Math.max(0, model.indexOf(workshopPanel.gameVersion)) + onActivated: root.applyFilters() + } + PxComboBox { + id: source + visible: workshopPanel.tab === "Collections" + textRole: "text" + valueRole: "value" + model: [ {text: "All sources", value: "all"}, {text: "Steam collections", value: "steam"}, {text: "PxModRim picks", value: "picked"} ] + Accessible.name: "Collection source" + onActivated: root.applyFilters() + } + TagPicker { + id: tag + Layout.fillWidth: true + Layout.maximumWidth: 230 + options: workshopPanel.tagOptions + onSelectedTagChanged: root.applyFilters() + } + Item { Layout.fillWidth: true } + PxComboBox { + id: sort + textRole: "text" + valueRole: "value" + model: [ {text: "Popular", value: "popular"}, {text: "Recently updated", value: "updated"}, {text: "Newest", value: "newest"}, {text: "Trending", value: "trending"}, {text: "Relevance", value: "relevance"} ] + Accessible.name: "Catalog sort order" + onActivated: root.applyFilters() + } + } + Rectangle { + Layout.fillWidth: true + implicitHeight: errorRow.implicitHeight + 24 + visible: workshopPanel.error.length > 0 + color: Theme.elevate2 + radius: Theme.radiusMd + RowLayout { + id: errorRow + anchors.fill: parent + anchors.margins: 12 + Copy { text: workshopPanel.error; color: Theme.danger } + PxButton { text: "Retry"; enabled: !workshopPanel.busy; onClicked: workshopPanel.retry() } + } + } + Rectangle { + Layout.fillWidth: true + implicitHeight: noticeColumn.implicitHeight + 24 + visible: workshopPanel.notice.length > 0 + color: Theme.elevate2 + radius: Theme.radiusMd + ColumnLayout { + id: noticeColumn + anchors.fill: parent + anchors.margins: 12 + Copy { text: workshopPanel.notice; color: workshopPanel.pendingPlan ? Theme.warning : Theme.textMuted } + RowLayout { + visible: workshopPanel.pendingPlan + PxButton { text: "Download available mods"; enabled: workshopPanel.canDownloadAvailable; onClicked: workshopPanel.downloadAvailable() } + PxButton { text: "Cancel"; variant: "ghost"; onClicked: workshopPanel.dismissPlan() } + } + } + } + ScrollView { + id: contentScroll + objectName: "discoverScroll" + visible: !workshopPanel.hasDetail && (!workshopPanel.configured || workshopPanel.tab === "Discover") + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: availableWidth + ColumnLayout { + width: contentScroll.availableWidth + spacing: 16 + ColumnLayout { + Layout.fillWidth: true + visible: !workshopPanel.configured + Heading { text: "Workshop catalog is disabled" } + Copy { text: "Set a Workshop catalog URL in Settings to browse and download mods." } + PxButton { text: "Open Settings"; onClicked: workshopPanel.openSettings() } + } + ColumnLayout { + Layout.fillWidth: true + visible: workshopPanel.configured + Heading { text: "Discover your next colony" } + Copy { text: "Explore Steam mods and collections alongside PxModRim picks. Downloads include required dependencies, without activating mods." } + RowLayout { + Heading { text: "Collections" } + PxButton { text: "Browse all"; variant: "ghost"; onClicked: workshopPanel.selectTab("Collections") } + } + CollectionGrid { model: visible ? catalogCollections : null } + RowLayout { + Heading { text: "Popular mods" } + PxButton { text: "Browse all"; variant: "ghost"; onClicked: workshopPanel.selectTab("Mods") } + } + CatalogGrid { model: visible ? catalogMods : null } + } + } + } + GridView { + id: listingGrid + objectName: "listingScroll" + readonly property bool listingTab: workshopPanel.configured && workshopPanel.tab === "Mods" + property int columns: Math.max(1, Math.floor(width / 230)) + visible: !workshopPanel.hasDetail && listingTab + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + cellWidth: width / columns + cellHeight: 312 + model: listingTab ? catalogMods : null + ScrollBar.vertical: PxScrollBar {} + header: ListingHeader { width: listingGrid.width } + footer: ListingFooter { width: listingGrid.width; listing: catalogMods } + delegate: CatalogCard { + width: GridView.view.cellWidth - 12 + height: GridView.view.cellHeight - 12 + } + } + GridView { + id: collectionsGrid + objectName: "collectionsScroll" + readonly property bool listingTab: workshopPanel.configured && (workshopPanel.tab === "Collections" || workshopPanel.tab === "Favourites") + property int columns: Math.max(1, Math.floor(width / 400)) + visible: !workshopPanel.hasDetail && listingTab + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + cellWidth: width / columns + cellHeight: 162 + model: listingTab ? catalogCollections : null + ScrollBar.vertical: PxScrollBar {} + header: ListingHeader { width: collectionsGrid.width } + footer: ListingFooter { width: collectionsGrid.width; listing: catalogCollections } + delegate: CollectionBanner { + width: GridView.view.cellWidth - 12 + height: GridView.view.cellHeight - 12 + } + } + GridView { + id: installedGrid + objectName: "installedScroll" + readonly property bool active: workshopPanel.configured && workshopPanel.tab === "Installed" + property int columns: Math.max(1, Math.floor(width / 340)) + visible: !workshopPanel.hasDetail && active + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + cellWidth: width / columns + cellHeight: 62 + model: active ? catalogMods : null + ScrollBar.vertical: PxScrollBar {} + header: ListingHeader { width: installedGrid.width } + footer: ListingFooter { width: installedGrid.width; listing: catalogMods } + delegate: ModRow { + width: GridView.view.cellWidth - 8 + height: GridView.view.cellHeight - 8 + } + } + ScrollView { + id: detailScroll + objectName: "detailScroll" + visible: workshopPanel.hasDetail + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: availableWidth + function jumpToMembers() { + const flickable = contentItem + const target = membersHeading.mapToItem(flickable.contentItem, 0, 0).y + flickable.contentY = Math.max(0, Math.min(target, flickable.contentHeight - flickable.height)) + } + ColumnLayout { + width: detailScroll.availableWidth + ColumnLayout { + Layout.fillWidth: true + Layout.maximumWidth: 1200 + Layout.alignment: Qt.AlignHCenter + visible: workshopPanel.configured && workshopPanel.hasDetail + spacing: 16 + PxButton { + text: workshopPanel.backLabel + iconName: "chevron-left" + Accessible.description: "Return to the previous page, keeping the listing's scroll position and filters" + onClicked: workshopPanel.back() + } + RowLayout { + Layout.fillWidth: true + spacing: 16 + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 3 + Layout.alignment: Qt.AlignTop + Layout.preferredHeight: Math.max(220, Math.min(340, width * 0.5)) + color: Theme.elevate2 + border.color: Theme.border + radius: Theme.radiusMd + clip: true + CatalogImage { + id: detailImage + anchors.fill: parent + previewUrl: root.detail.previewUrl || "" + widthStep: 128 + heightStep: 64 + fillMode: Image.PreserveAspectFit + } + Grid { + id: detailCollage + anchors.fill: parent + visible: !root.detail.previewUrl && (root.detail.collage || []).length > 0 + columns: (root.detail.collage || []).length === 1 ? 1 : 2 + readonly property int rowCount: (root.detail.collage || []).length > 2 ? 2 : 1 + Repeater { + model: detailCollage.visible ? root.detail.collage : [] + CatalogImage { + required property string modelData + width: detailCollage.width / detailCollage.columns + height: detailCollage.height / detailCollage.rowCount + previewUrl: modelData + } + } + } + Image { + anchors.centerIn: parent + width: 48 + height: 48 + visible: detailImage.status !== Image.Ready && !detailCollage.visible + source: "image://icons/grid?color=" + encodeURIComponent(Theme.textDim) + } + } + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: 2 + Layout.alignment: Qt.AlignTop + spacing: 10 + Flow { + Layout.fillWidth: true + spacing: 6 + PxBadge { text: (root.detail.sourceLabel || "").toUpperCase(); textColor: Theme.textMuted } + PxBadge { visible: root.detail.state !== "missing"; text: "INSTALLED"; textColor: Theme.success } + PxBadge { visible: root.detail.state === "outdated"; text: "UPDATE AVAILABLE" } + PxBadge { visible: !!root.detail.incompatible; text: "INCOMPATIBLE"; textColor: Theme.warning } + } + Heading { Layout.fillWidth: true; text: root.detail.title || ""; font.pixelSize: 26; wrapMode: Text.Wrap } + Copy { text: "by " + (root.detail.author || "") } + Copy { + objectName: "detailTags" + Layout.fillWidth: true + text: [root.detail.tags, root.detail.versions === "Not specified" ? "" : root.detail.versions].filter(s => !!s).join(" · ") + visible: !!text + } + Copy { Layout.fillWidth: true; visible: !!root.detail.incompatible; text: "Does not list your game version."; color: Theme.warning } + Copy { Layout.fillWidth: true; visible: !!root.detail.warning; text: root.detail.warning || ""; color: Theme.warning } + Flow { + Layout.fillWidth: true + spacing: 8 + PxButton { + readonly property bool pack: root.detail.kind === "collection" + visible: root.detail.state !== "missing" + text: (root.detail.active ? "Deactivate" : "Activate") + (pack ? " all" : "") + variant: root.detail.active ? "danger" : "success" + onClicked: pack ? workshopPanel.toggleCollection() : workshopPanel.toggleActivation(root.detail.itemId) + } + PxButton { + visible: root.detail.kind === "collection" && root.detail.state === "installed" + text: "Activate only this pack" + variant: "warning" + ToolTip.text: "Activate this collection and its dependencies, deactivate every other Workshop mod" + onClicked: workshopPanel.activateOnlyCollection() + } + PxButton { + visible: root.detail.state !== "installed" + text: root.detail.actionLabel || "Download" + variant: root.detail.state === "outdated" ? "warning" : "primary" + enabled: root.detail.state !== "installed" && !root.detail.queued + onClicked: workshopPanel.downloadItem(root.detail.itemId, root.detail.kind) + } + PxButton { visible: !!root.detail.workshopUrl; text: "View on Steam"; variant: "ghost"; onClicked: workshopPanel.openLink(root.detail.workshopUrl) } + PxButton { + objectName: "detailFavourite" + visible: root.detail.kind === "collection" + text: root.detail.favourite ? "Favourited" : "Favourite" + variant: "ghost" + iconName: root.detail.favourite ? "star-filled" : "star" + iconColor: root.detail.favourite ? Theme.warning : "transparent" + onClicked: workshopPanel.toggleFavourite(root.detail.itemId) + } + } + Copy { Layout.fillWidth: true; text: "Local mods · downloading does not enable or subscribe." } + RowLayout { + visible: root.detail.kind === "mod" + spacing: 8 + Image { + width: 16; height: 16 + sourceSize.width: 16; sourceSize.height: 16 + source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) + } + Copy { Layout.fillWidth: false; text: root.detail.votes || "Unrated" } + Copy { Layout.fillWidth: false; text: (root.detail.fileSize || "Unknown") } + } + RowLayout { + visible: root.detail.kind === "collection" + spacing: 8 + Copy { Layout.fillWidth: false; text: root.detail.memberCount + " mods" } + Copy { Layout.fillWidth: false; visible: root.detail.fileSize !== "Unknown"; text: "· " + root.detail.fileSize + " total" } + } + RowLayout { + visible: !!root.detail.votesUp + spacing: 8 + Image { + width: 16; height: 16 + sourceSize.width: 16; sourceSize.height: 16 + source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) + } + Copy { Layout.fillWidth: false; text: root.detail.votesUp || ""; Accessible.name: text + " positive votes" } + Image { + width: 16; height: 16 + sourceSize.width: 16; sourceSize.height: 16 + rotation: 180 + source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) + } + Copy { Layout.fillWidth: false; text: root.detail.votesDown || ""; Accessible.name: text + " negative votes" } + } + } + } + GridLayout { + id: detailBody + readonly property bool pack: root.detail.kind === "collection" + Layout.fillWidth: true + columns: pack ? 1 : 2 + columnSpacing: 16 + rowSpacing: 16 + ColumnLayout { + id: descColumn + objectName: "detailDescription" + Layout.row: 0 + Layout.column: 0 + Layout.fillWidth: true + Layout.preferredWidth: 3 + Layout.alignment: Qt.AlignTop + spacing: 10 + readonly property bool tall: descriptionText.implicitHeight > detailScroll.availableHeight + RowLayout { + Layout.fillWidth: true + Heading { text: "Description" } + PxButton { + objectName: "jumpToMembers" + visible: descColumn.tall && catalogMembers.count > 0 + text: detailBody.pack ? "Jump to mods" : "Jump to required mods" + variant: "ghost" + font.pixelSize: Theme.fontSizeSm + onClicked: detailScroll.jumpToMembers() + } + } + ModDescription { + id: descriptionText + Layout.fillWidth: true + description: root.detail.description || "" + onLinkActivated: link => workshopPanel.openLink(link) + } + } + ColumnLayout { + objectName: "detailMembers" + Layout.row: detailBody.pack ? 1 : 0 + Layout.column: detailBody.pack ? 0 : 1 + Layout.fillWidth: true + Layout.preferredWidth: 2 + Layout.alignment: Qt.AlignTop + spacing: 8 + RowLayout { + id: membersHeading + objectName: "membersHeading" + Layout.fillWidth: true + Heading { text: (detailBody.pack ? "Collection members · " : "Required mods · ") + catalogMembers.count } + PxButton { + objectName: "detailBackToTop" + visible: detailScroll.contentItem.contentY > 0 + text: "Back to top" + variant: "ghost" + font.pixelSize: Theme.fontSizeSm + onClicked: detailScroll.contentItem.contentY = 0 + } + } + Copy { visible: catalogMembers.count === 0; text: "None listed." } + GridLayout { + objectName: "membersGrid" + Layout.fillWidth: true + columns: detailBody.pack ? Math.max(1, Math.floor(Math.min(1200, detailScroll.availableWidth) / 340)) : 1 + columnSpacing: 8 + rowSpacing: 8 + Repeater { + model: catalogMembers + ModRow { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: 54 + } + } + } + } + } + } + } + } + } + Component.onCompleted: workshopPanel.refresh() +} diff --git a/src/pxmodrim/ui/plugins/workshop/__init__.py b/src/pxmodrim/ui/plugins/workshop/__init__.py new file mode 100644 index 0000000..3bb8167 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from pxmodrim.ui.plugins.workshop.plugin import WorkshopUiPlugin +from pxmodrim.ui.plugins.workshop.view import WorkshopViewPanel + +__all__ = ["WorkshopUiPlugin", "WorkshopViewPanel"] diff --git a/src/pxmodrim/ui/plugins/workshop/details.py b/src/pxmodrim/ui/plugins/workshop/details.py new file mode 100644 index 0000000..0d15799 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/details.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +import msgspec + +from pxmodrim.core.workshop import CatalogCollection, CatalogError, CatalogMod + +if TYPE_CHECKING: + from pxmodrim.core.workshop import DownloadPlan, WorkshopCatalog + + +class Detail(msgspec.Struct, frozen=True): + item: CatalogMod | CatalogCollection + members: list[CatalogMod | CatalogCollection] + warning: str + complete: bool + + +async def fetch_detail(catalog: WorkshopCatalog, item_id: str, kind: str) -> Detail: + if kind == "collection": + return await _collection_detail(catalog, item_id) + return await _mod_detail(catalog, item_id) + + +async def _collection_detail(catalog: WorkshopCatalog, item_id: str) -> Detail: + detail = await catalog.collection(item_id) + if detail is None: + raise CatalogError("This collection is no longer available.", 404) + warning = "" + if not detail.is_complete: + warning = ( + "This collection is incomplete. Some members are unavailable or not listed." + ) + if detail.unavailable_ids: + warning += " Unavailable: " + ", ".join(detail.unavailable_ids) + return Detail(detail.collection, list(detail.members), warning, detail.is_complete) + + +async def _mod_detail(catalog: WorkshopCatalog, item_id: str) -> Detail: + mod = await catalog.mod(item_id) + if mod is None: + raise CatalogError("This mod is no longer available.", 404) + dependencies = await asyncio.gather(*(catalog.mod(pid) for pid in mod.dependencies)) + missing = [ + pid + for pid, item in zip(mod.dependencies, dependencies, strict=True) + if item is None + ] + warning = "Unavailable dependencies: " + ", ".join(missing) if missing else "" + found = [item for item in dependencies if item is not None] + return Detail(mod, list(found), warning, not missing) + + +def incomplete_plan_notice(plan: DownloadPlan) -> str: + parts = ["The download plan is incomplete."] + if plan.unavailable_ids: + parts.append("Unavailable: " + ", ".join(plan.unavailable_ids)) + if plan.incomplete_collection_ids: + parts.append( + "Incomplete collections: " + ", ".join(plan.incomplete_collection_ids) + ) + return " ".join(parts) + + +def queued_notice(count: int) -> str: + return ( + f"Queued {count} mod{'s' if count != 1 else ''}. Progress is shown " + "in the header and results in Downloads. Mods are not activated." + ) diff --git a/src/pxmodrim/ui/plugins/workshop/installed.py b/src/pxmodrim/ui/plugins/workshop/installed.py new file mode 100644 index 0000000..a8023a9 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/installed.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pxmodrim.core.workshop import CatalogMod + +PAGE = 48 + + +class InstalledList: + """Installed Workshop mods with a text filter and incremental paging.""" + + def __init__(self) -> None: + self._mods: list[CatalogMod] = [] + self._matches: list[CatalogMod] = [] + self._query = "" + self._shown = PAGE + + @property + def mods(self) -> list[CatalogMod]: + return self._mods + + @property + def loaded(self) -> bool: + return bool(self._mods) + + @property + def total(self) -> int: + return len(self._matches) + + @property + def has_more(self) -> bool: + return self._shown < len(self._matches) + + def replace(self, mods: list[CatalogMod]) -> None: + self._mods = mods + self._rematch() + + def search(self, query: str) -> None: + self._query = query + self._shown = PAGE + self._rematch() + + def reset_paging(self) -> None: + self._shown = PAGE + + def visible(self) -> list[CatalogMod]: + return self._matches[: self._shown] + + def next_page(self) -> list[CatalogMod]: + start = self._shown + self._shown = min(start + PAGE, len(self._matches)) + return self._matches[start : self._shown] + + def _rematch(self) -> None: + needle = self._query.casefold() + self._matches = [mod for mod in self._mods if needle in _haystack(mod)] + + +def _haystack(mod: CatalogMod) -> str: + return " ".join((mod.title, mod.author.name or "", mod.id, *mod.tags)).casefold() diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/src/pxmodrim/ui/plugins/workshop/models.py new file mode 100644 index 0000000..dd094dd --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/models.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import re +from collections.abc import Callable, Sequence +from typing import Any +from urllib.parse import urlsplit + +from PySide6.QtCore import ( + Property, + QAbstractListModel, + QByteArray, + QModelIndex, + QObject, + QPersistentModelIndex, + Qt, + Signal, +) + +from pxmodrim.core.workshop import CatalogCollection, CatalogMod, InstallState +from pxmodrim.ui.panels.mod_info_data import description_to_html + + +def safe_url(value: str) -> str: + if any(char.isspace() or ord(char) < 32 for char in value): + return "" + try: + parsed = urlsplit(value) + except ValueError: + return "" + return value if parsed.scheme.lower() in {"http", "https"} and parsed.netloc else "" + + +_BBCODE_TAG = re.compile(r"\[/?[a-zA-Z*][^\]]*\]") +_BBCODE_MEDIA = re.compile( + r"\[(img|video|previewimage)[^\]]*\].*?\[/\1\]", re.IGNORECASE | re.DOTALL +) +_SUMMARY_LENGTH = 140 + + +def summarize(text: str, description_format: str = "bbcode") -> str: + """First sentence-ish of a description as plain text, for card blurbs.""" + if description_format == "bbcode": + text = _BBCODE_TAG.sub(" ", _BBCODE_MEDIA.sub(" ", text)) + text = " ".join(text.split()) + if len(text) <= _SUMMARY_LENGTH: + return text + cut = text[:_SUMMARY_LENGTH].rsplit(" ", 1)[0].rstrip(".,;:-–— ") + return f"{cut}…" + + +def _compat_label( + supported: set[str], game_version: str, incompatible: bool, no_common: bool +) -> str: + if not game_version: + return "" + if no_common: + return "No common version" + if incompatible: + return f"Not for {game_version}" + if not supported: + return "Version not stated" + return f"Works with {game_version}" + + +def item_row( + item: CatalogMod | CatalogCollection, + install_state: Callable[[CatalogMod], InstallState], + game_version: str, + *, + detail: bool = False, +) -> dict[str, Any]: + versions = set(item.supported_versions) + if isinstance(item, CatalogCollection): + versions.update( + tag for tag in item.tags if re.fullmatch(r"\d+\.\d+(?:\.\d+)?", tag) + ) + incompatible = bool(game_version and versions and game_version not in versions) + file_size, votes, member_count = "Unknown", "Unrated", 0 + votes_up = votes_down = "0" + if isinstance(item, CatalogMod): + state = install_state(item) + incompatible = incompatible or item.incompatible + source_label = "Steam Workshop" + action_label = { + "missing": "Download", + "installed": "Installed", + "outdated": "Update", + }[state] + if item.file_size is not None: + file_size = f"{int(item.file_size) / 1048576:.1f} MB" + if item.votes is not None: + votes_up, votes_down = f"{item.votes.up:,}", f"{item.votes.down:,}" + total = item.votes.up + item.votes.down + if total: + votes = f"{100 * item.votes.up / total:.0f}%" + else: + state = "missing" + source_label = ( + "PxModRim pick" if item.source == "picked" else "Steam collection" + ) + action_label = "Download all" + member_count = item.member_count + if item.total_size is not None: + file_size = f"{int(item.total_size) / 1048576:.1f} MB" + no_common = ( + isinstance(item, CatalogCollection) and item.no_common_version and not versions + ) + incompatible = incompatible or no_common + return { + "itemId": item.id, + "kind": item.kind, + "title": item.title, + "author": item.author.name or item.author.id or "Unknown author", + "previewUrl": safe_url( + item.preview_url + or next((p.url for p in item.previews if p.type == "image"), "") + ), + "collage": [ + url + for url in ( + safe_url(u) + for u in ( + item.member_previews if isinstance(item, CatalogCollection) else [] + ) + ) + if url + ], + "sourceLabel": source_label, + "versions": ", ".join( + sorted(versions, key=lambda version: tuple(map(int, version.split(".")))) + ) + or "Not specified", + "tags": " · ".join(tag for tag in item.tags if tag not in versions), + "state": state, + "active": False, + "queued": False, + "favourite": False, + "stateLabel": { + "missing": "Not installed", + "installed": "Installed", + "outdated": "Update available", + }[state], + "actionLabel": action_label, + "incompatible": incompatible, + "memberCount": member_count, + "description": description_to_html(item.description, item.description_format) + if detail + else "", + "workshopUrl": safe_url(item.workshop_url or ""), + "fileSize": file_size, + "votes": votes, + "votesUp": votes_up, + "votesDown": votes_down, + "summary": summarize(item.description, item.description_format), + "compatLabel": _compat_label(versions, game_version, incompatible, no_common), + } + + +class CatalogListModel(QAbstractListModel): + changed = Signal() + _roles = ( + "itemId", + "kind", + "title", + "author", + "previewUrl", + "collage", + "sourceLabel", + "versions", + "tags", + "state", + "active", + "stateLabel", + "actionLabel", + "incompatible", + "memberCount", + "description", + "workshopUrl", + "fileSize", + "votes", + "votesUp", + "votesDown", + "queued", + "favourite", + "summary", + "compatLabel", + ) + + def __init__(self, parent: QObject) -> None: + super().__init__(parent) + self._rows: list[dict[str, Any]] = [] + self._cursor: str | None = None + self._total = 0 + + def set_page( + self, + rows: Sequence[dict[str, Any]], + total: int, + cursor: str | None, + *, + append: bool = False, + ) -> None: + if append: + known = {row["itemId"] for row in self._rows} + additions = [] + for row in rows: + if row["itemId"] not in known: + additions.append(row) + known.add(row["itemId"]) + if additions: + self.beginInsertRows( + QModelIndex(), len(self._rows), len(self._rows) + len(additions) - 1 + ) + self._rows.extend(additions) + self.endInsertRows() + elif [row["itemId"] for row in rows] == [row["itemId"] for row in self._rows]: + # A background refresh of the same page: resetting would rebuild every + # delegate and reload its thumbnail, so only changed rows are updated. + for index, row in enumerate(rows): + if row != self._rows[index]: + self._rows[index] = row + self.dataChanged.emit(self.index(index), self.index(index)) + else: + self.beginResetModel() + self._rows = list(rows) + self.endResetModel() + self._total, self._cursor = total, cursor + self.changed.emit() + + def refresh_rows(self, convert: Callable[[dict[str, Any]], dict[str, Any]]) -> None: + self._rows = [convert(row) for row in self._rows] + if self._rows: + self.dataChanged.emit(self.index(0), self.index(len(self._rows) - 1)) + + def rowCount( + self, parent: QModelIndex | QPersistentModelIndex | None = None + ) -> int: + return len(self._rows) if parent is None or not parent.isValid() else 0 + + def data( + self, + index: QModelIndex | QPersistentModelIndex, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> Any: + offset = role - int(Qt.ItemDataRole.UserRole) - 1 + if ( + not index.isValid() + or not 0 <= index.row() < len(self._rows) + or not 0 <= offset < len(self._roles) + ): + return None + return self._rows[index.row()][self._roles[offset]] + + def roleNames(self) -> dict[int, QByteArray]: + return { + int(Qt.ItemDataRole.UserRole) + offset + 1: QByteArray(name.encode()) + for offset, name in enumerate(self._roles) + } + + @property + def cursor(self) -> str | None: + return self._cursor + + @Property(bool, notify=changed) # type: ignore[arg-type] + def hasMore(self) -> bool: + return self._cursor is not None + + @Property(int, notify=changed) # type: ignore[arg-type] + def count(self) -> int: + return len(self._rows) + + @Property(int, notify=changed) # type: ignore[arg-type] + def total(self) -> int: + return self._total diff --git a/src/pxmodrim/ui/plugins/workshop/plugin.py b/src/pxmodrim/ui/plugins/workshop/plugin.py new file mode 100644 index 0000000..2d29648 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/plugin.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar, cast + +from pxmodrim.core.plugin import Plugin +from pxmodrim.core.workshop import WorkshopCatalog +from pxmodrim.ui.plugins.workshop.view import WorkshopViewPanel +from pxmodrim.ui.settings_section import PluginConfigSection + +if TYPE_CHECKING: + from PySide6.QtCore import QObject + + from pxmodrim.ui.context import AppContext + + +class CatalogSettingsSection(PluginConfigSection): + title = "Workshop catalog" + source = Path(__file__).with_name("CatalogSettings.qml") + + +class WorkshopUiPlugin(Plugin): + name = "workshop_ui" + dependencies: ClassVar[list[str]] = ["workshop_catalog", "downloads"] + + def setup(self, ctx: AppContext) -> None: # type: ignore[override] + ctx.add_rail_view(WorkshopViewPanel) + catalog = cast(WorkshopCatalog, ctx.core.plugins.get("workshop_catalog")) + + def section(parent: QObject) -> CatalogSettingsSection: + return CatalogSettingsSection(catalog.settings, parent) + + ctx.add_settings_section(section) diff --git a/src/pxmodrim/ui/plugins/workshop/thumbs.js b/src/pxmodrim/ui/plugins/workshop/thumbs.js new file mode 100644 index 0000000..fb16142 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/thumbs.js @@ -0,0 +1,24 @@ +.pragma library + +// Steam's image CDN resizes on request. Asking for the size that is shown keeps +// both the download and the decoded pixmap small. +function sized(url, width, height) { + if (!/^https:\/\/(?:[a-z0-9-]+\.)*(?:steamusercontent\.com|steamstatic\.com)\//i.test(url)) + return url + + const fragmentAt = url.indexOf("#") + const fragment = fragmentAt < 0 ? "" : url.slice(fragmentAt) + const address = fragmentAt < 0 ? url : url.slice(0, fragmentAt) + const queryAt = address.indexOf("?") + const path = queryAt < 0 ? address : address.slice(0, queryAt) + const query = queryAt < 0 ? [] : address.slice(queryAt + 1).split("&").filter(function (part) { + return part !== "" && !/^(imw|imh|ima|impolicy)=/.test(part) + }) + query.push("imw=" + width, "imh=" + height, "ima=fit", "impolicy=Letterbox") + return path + "?" + query.join("&") + fragment +} + +// Round up to a step so that small layout changes do not reload the image. +function bucket(value, step) { + return Math.max(step, Math.ceil(value / step) * step) +} diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py new file mode 100644 index 0000000..03dfad8 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -0,0 +1,867 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Awaitable, Callable, Iterable +from hashlib import sha256 +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +import msgspec +from loguru import logger +from PySide6.QtCore import Property, QUrl, Signal, Slot +from PySide6.QtGui import QColor, QDesktopServices +from PySide6.QtQuickWidgets import QQuickWidget +from qasync import asyncSlot + +from pxmodrim.core.downloads import download_manager +from pxmodrim.core.workshop import ( + CatalogCollection, + CatalogError, + CatalogMod, + CatalogPage, + CatalogQuery, + Discover, + DownloadPlan, + WorkshopCatalog, +) +from pxmodrim.core.workshop.catalog import FRESH_SECONDS +from pxmodrim.ui.components.mod_activation import apply_activation +from pxmodrim.ui.navigation import SETTINGS_VIEW_ID, build_route +from pxmodrim.ui.plugins.workshop.details import ( + Detail, + fetch_detail, + incomplete_plan_notice, + queued_notice, +) +from pxmodrim.ui.plugins.workshop.installed import InstalledList +from pxmodrim.ui.plugins.workshop.models import CatalogListModel, item_row, safe_url +from pxmodrim.ui.theme.palette import PALETTE +from pxmodrim.ui.views.base import BaseViewPanel + +if TYPE_CHECKING: + from PySide6.QtQml import QQmlEngine + from PySide6.QtWidgets import QWidget + + from pxmodrim.core.context import CoreContext + from pxmodrim.ui.context import AppContext + +_FAILURES = (CatalogError, RuntimeError, ValueError) +_TABS = ("Discover", "Mods", "Collections", "Favourites", "Installed") + + +def _message(exc: Exception) -> str: + return exc.message if isinstance(exc, CatalogError) else str(exc) + + +class WorkshopViewPanel(BaseViewPanel): + view_id = "workshop" + icon_name = "steam" + label = "Workshop" + changed = Signal() + + def __init__( + self, + ctx: CoreContext, + qml_engine: QQmlEngine | None = None, + parent: QWidget | None = None, + app_ctx: AppContext | None = None, + *, + catalog: WorkshopCatalog | None = None, + ) -> None: + super().__init__(ctx, qml_engine, parent, app_ctx=app_ctx) + self._catalog = catalog or cast( + WorkshopCatalog, ctx.plugins.get("workshop_catalog") + ) + self._downloads = download_manager(ctx) + self.mods_model = CatalogListModel(self) + self.collections_model = CatalogListModel(self) + self.members_model = CatalogListModel(self) + self._tab = "Discover" + self._criteria = CatalogQuery(version=self._game_version or None) + self._busy = False + self._error = "" + self._notice = "" + self._generation = 0 + self._torn_down = False + self._retry: Callable[[], Awaitable[None]] | None = None + self._detail: dict[str, Any] = {} + self._detail_data: Detail | None = None + self._installed = InstalledList() + self._installed_dirty = False + self._route_tasks: set[asyncio.Future[None]] = set() + self._current: tuple[str, str] | None = None + self._trail: list[tuple[str, str, str]] = [] + self._update_count = 0 + self._pending: DownloadPlan | None = None + self._planning = 0 + self._preparing: set[str] = set() + self._collection_mods: dict[str, frozenset[str]] = {} + self._items: dict[str, CatalogMod | CatalogCollection] = {} + self._known_tags: set[str] = set() + self._installed_ids: set[str] = set() + self._active_ids: set[str] = set() + self._pack_ids: frozenset[str] = frozenset() + self._refresh_mod_ids() + self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] + self._qml.setObjectName("workshopView") + self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) + self._qml.setClearColor(QColor(PALETTE["ELEVATE_0"])) + qml_ctx = self._qml.rootContext() + for name, value in ( + ("workshopPanel", self), + ("catalogMods", self.mods_model), + ("catalogCollections", self.collections_model), + ("catalogMembers", self.members_model), + ): + qml_ctx.setContextProperty(name, value) + self._qml.setSource( + QUrl.fromLocalFile(str(Path(__file__).with_name("Workshop.qml"))) + ) + self._root.addWidget(self._qml, 1) + self._downloads.busy_changed.connect(self._on_queue_changed) + self._catalog.queue_changed.connect(self._on_queue_changed) + self._catalog.catalog_url_changed.connect(self._on_url_changed) + self._catalog.installed_changed.connect(self._on_installed_changed) + self._favourites = self._catalog.favourites + self._favourites.changed.connect(self._on_favourites_changed) + self._ctx.active_state_changed.connect(self._on_active_state_changed) + self.destroyed.connect(lambda: self.teardown()) + + def teardown(self) -> None: + if self._torn_down: + return + self._torn_down = True + self._generation += 1 + self._retry = None + self._downloads.busy_changed.disconnect(self._on_queue_changed) + self._catalog.queue_changed.disconnect(self._on_queue_changed) + self._catalog.catalog_url_changed.disconnect(self._on_url_changed) + self._catalog.installed_changed.disconnect(self._on_installed_changed) + self._favourites.changed.disconnect(self._on_favourites_changed) + self._ctx.active_state_changed.disconnect(self._on_active_state_changed) + + @property + def _game_version(self) -> str: + version = self._ctx.target_version + return "" if version == "Unknown" else version + + # -- rows ------------------------------------------------------------- + + def _refresh_mod_ids(self) -> None: + mods = self._ctx.all_mods + self._installed_ids = { + published_id + for mod in mods.values() + if (published_id := mod.published_file_id) is not None + } + self._active_ids = { + published_id + for uuid in self._ctx.active_uuids + if uuid in mods + and (published_id := mods[uuid].published_file_id) is not None + } + + def _row( + self, item: CatalogMod | CatalogCollection, *, detail: bool = False + ) -> dict[str, Any]: + self._items[item.id] = item + self._known_tags.update(set(item.tags) - set(item.supported_versions)) + row = item_row( + item, self._catalog.install_state, self._game_version, detail=detail + ) + row["active"] = isinstance(item, CatalogMod) and item.id in self._active_ids + if isinstance(item, CatalogCollection): + if not detail: + installed_count = sum( + member_id in self._installed_ids for member_id in item.member_ids + ) + if item.member_ids and installed_count == len(item.member_ids): + row["state"], row["stateLabel"] = "installed", "Installed" + row["actionLabel"] = "Installed" + elif installed_count: + row["actionLabel"] = "Complete download" + if item.id in self._favourites: + row["favourite"] = True + self._favourites.remember(item) + if isinstance(item, CatalogCollection) or row["state"] != "installed": + self._mark_progress(item, row) + return row + + def _mark_progress( + self, item: CatalogMod | CatalogCollection, row: dict[str, Any] + ) -> None: + if item.id in self._preparing: + row["queued"], row["actionLabel"] = True, "Preparing…" + return + if isinstance(item, CatalogMod): + ids: frozenset[str] = frozenset((item.id,)) + else: + ids = frozenset(item.member_ids) | self._collection_mods.get( + item.id, frozenset() + ) + if self._detail_data is not None and self._detail_data.item.id == item.id: + ids |= self._pack_ids + if not ids.isdisjoint(self._downloads.active_ids): + row["queued"], row["actionLabel"] = True, "Downloading" + elif not ids.isdisjoint(self._catalog.queued_ids): + row["queued"], row["actionLabel"] = True, "Queued" + + def _rows(self, items: Iterable[CatalogMod | CatalogCollection]) -> list[Any]: + self._refresh_mod_ids() + return [self._row(item) for item in items] + + def _detail_row(self, detail: Detail) -> dict[str, Any]: + row = self._row(detail.item, detail=True) + row["complete"], row["warning"] = detail.complete, detail.warning + if isinstance(detail.item, CatalogCollection): + if not row["previewUrl"] and not row["collage"]: + for member in detail.members: + if not isinstance(member, CatalogMod): + continue + preview = safe_url( + member.preview_url + or next( + (p.url for p in member.previews if p.type == "image"), "" + ) + ) + if preview: + row["collage"].append(preview) + if len(row["collage"]) == 4: + break + started = any( + isinstance(member, CatalogMod) + and self._catalog.install_state(member) != "missing" + for member in detail.members + ) + if not row["queued"]: + row["actionLabel"] = "Complete download" if started else "Download all" + copies = self._pack_copies() + if copies is not None: + active = set(self._ctx.active_uuids) + row["state"], row["stateLabel"] = "installed", "Installed" + if not row["queued"]: + row["actionLabel"] = "Installed" + row["active"] = all(uuid in active for uuid in copies) + return row + + def _refresh_rows(self) -> None: + for model in (self.mods_model, self.collections_model, self.members_model): + model.refresh_rows(lambda row: self._row(self._items[row["itemId"]])) + if self._detail_data is not None: + self._detail = self._detail_row(self._detail_data) + + def _clear_detail(self) -> None: + self._detail, self._detail_data = {}, None + self._current = None + self._trail.clear() + self._pack_ids = frozenset() + + def _pack_copies(self) -> list[str] | None: + """One installed copy per collection mod, or None until all are downloaded.""" + if self._torn_down or not self._pack_ids: + return None + installed: dict[str, list[str]] = {} + for uuid, mod in self._ctx.all_mods.items(): + published_id = mod.published_file_id + if published_id is not None and published_id in self._pack_ids: + installed.setdefault(published_id, []).append(uuid) + if len(installed) < len(self._pack_ids): + return None + active = set(self._ctx.active_uuids) + return [ + next((uuid for uuid in uuids if uuid in active), uuids[0]) + for uuids in installed.values() + ] + + async def _load_pack(self) -> None: + """Resolve the collection's mods and dependencies for the activation buttons.""" + detail, generation = self._detail_data, self._generation + if detail is None: + return + try: + plan = await self._catalog.plan([], [detail.item.id]) + except _FAILURES as exc: + logger.warning( + "[workshop] cannot resolve collection mods: {}", _message(exc) + ) + return + if generation != self._generation: + return + self._pack_ids = ( + frozenset((*plan.to_download, *plan.already_current)) + if plan.is_complete + else frozenset() + ) + self._refresh_rows() + self.changed.emit() + + # -- running fetches -------------------------------------------------- + + async def _run( + self, fetch: Callable[[], Awaitable[Any]], apply: Callable[[Any], None] + ) -> None: + async def once() -> AsyncIterator[Any]: + yield await fetch() + + await self._run_stream(once, apply) + + async def _run_stream( + self, + stream: Callable[[], AsyncIterator[Any]], + apply: Callable[[Any], None], + ) -> None: + """Apply each value the stream yields; only a failure before the first + value is shown to the user, later ones are background refreshes.""" + if self._torn_down: + return + self._generation += 1 + generation = self._generation + self._busy, self._error = True, "" + self.changed.emit() + + async def retry() -> None: + await self._run_stream(stream, apply) + + self._retry = retry + shown = False + try: + async for result in stream(): + if generation != self._generation: + return + apply(result) + shown = True + self._busy = False + self.changed.emit() + except _FAILURES as exc: + if generation == self._generation: + if shown: + logger.warning( + "[workshop] background refresh failed: {}", _message(exc) + ) + else: + self._error = _message(exc) + finally: + if generation == self._generation: + self._busy = False + self.changed.emit() + if ( + self._installed_dirty + and self._tab == "Installed" + and self._detail_data is None + ): + await self.load() + + async def _run_cached( + self, + key: str, + produce: Callable[[], Awaitable[Any]], + value_type: Any, + apply: Callable[[Any], None], + *, + fresh_for: float = FRESH_SECONDS, + ) -> None: + await self._run_stream( + lambda: self._catalog.cached( + key, produce, fresh_for=fresh_for, value_type=value_type + ), + apply, + ) + + # -- loading tabs ----------------------------------------------------- + + async def load(self, *, append: bool = False) -> None: + if self._torn_down: + return + if not self._catalog.configured: + self._generation += 1 + self._busy = False + self.changed.emit() + return + self._clear_detail() + match self._tab: + case "Discover": + await self._load_discover() + case "Installed": + await self._load_installed(append) + case "Favourites": + self._generation += 1 + self._busy, self._error = False, "" + self._show_favourites() + case _: + await self._load_listing(append) + + async def _load_discover(self) -> None: + def apply(result: Discover) -> None: + self.mods_model.set_page( + self._rows(result.mods.items), result.mods.total, None + ) + self.collections_model.set_page( + self._rows(result.collections.items), result.collections.total, None + ) + + await self._run_cached("discover", self._catalog.discover, Discover, apply) + + def _show_favourites(self) -> None: + query = self._criteria.query.casefold() + items = [ + item for item in self._favourites.items if query in item.title.casefold() + ] + self.collections_model.set_page(self._rows(items), len(items), None) + self.changed.emit() + + async def _load_listing(self, append: bool) -> None: + collections = self._tab == "Collections" + model = self.collections_model if collections else self.mods_model + fetch = self._catalog.collections if collections else self._catalog.mods + query = msgspec.structs.replace( + self._criteria, cursor=model.cursor if append else None + ) + + def apply(page: CatalogPage[Any]) -> None: + model.set_page( + self._rows(page.items), page.total, page.next_cursor, append=append + ) + + if append: + await self._run(lambda: fetch(query), apply) + return + page_type = ( + CatalogPage[CatalogCollection] if collections else CatalogPage[CatalogMod] + ) + await self._run_cached( + f"{self._tab}|{query!r}", lambda: fetch(query), page_type, apply + ) + + async def _load_installed(self, append: bool) -> None: + if append: + self.mods_model.set_page( + self._rows(self._installed.next_page()), + self._installed.total, + self._installed_cursor(), + append=True, + ) + return + + self._installed_dirty = False + + def apply(mods: list[CatalogMod]) -> None: + self._installed.replace(mods) + self._recount() + self._show_installed() + + installed_ids = { + mod.published_file_id + for mod in self._ctx.all_mods.values() + if mod.published_file_id is not None + } + fingerprint = sha256(",".join(sorted(installed_ids)).encode()).hexdigest() + + await self._run_cached( + f"installed|{fingerprint}", + self._catalog.installed_with_updates, + list[CatalogMod], + apply, + ) + + def _installed_cursor(self) -> str | None: + return "more" if self._installed.has_more else None + + def _show_installed(self) -> None: + self._clear_detail() + self.mods_model.set_page( + self._rows(self._installed.visible()), + self._installed.total, + self._installed_cursor(), + ) + self.changed.emit() + + def _recount(self) -> None: + self._update_count = sum( + self._catalog.install_state(mod) == "outdated" + for mod in self._installed.mods + ) + + # -- QML slots -------------------------------------------------------- + + @asyncSlot() + async def refresh(self) -> None: + await self.load() + + @Slot(str) + def selectTab(self, tab: str) -> None: + self._go(tab.lower()) + + async def select_tab(self, tab: str) -> None: + self._tab, self._notice, self._pending = tab, "", None + self._clear_detail() + self._installed.reset_paging() + await self.load() + + @asyncSlot(str, str, str, str, str) + async def filter( + self, query: str, version: str, source: str, sort: str, tag: str + ) -> None: + self._criteria = CatalogQuery( + query=query, + tag=tag or None, + version=version or None, + sort=sort, + source=source, + ) + self._installed.search(query) + if self._tab == "Installed" and self._installed.loaded: + self._show_installed() + return + if self._tab == "Discover": + self._tab = "Mods" + await self.load() + + @asyncSlot() + async def loadMore(self) -> None: + await self.load(append=True) + + @asyncSlot() + async def retry(self) -> None: + if self._retry: + await self._retry() + + @Slot(str, str) + def openItem(self, item_id: str, kind: str) -> None: + self._go(kind, item_id) + + def _go(self, *path: str) -> None: + if self._app_ctx is None: + self.open_route(path) + else: + self._app_ctx.navigate(build_route(self.view_id, *path)) + + async def open_item(self, item_id: str, kind: str, *, record: bool = True) -> None: + self._pack_ids = frozenset() + + def apply(detail: Detail) -> None: + ref = (kind, item_id) + if self._current != ref: + if not self._detail: + self._trail.clear() + elif record and self._current is not None: + title = str(self._detail.get("title", "")) + self._trail.append((*self._current, title)) + self._current = ref + members = self._rows(detail.members) + self._detail_data = detail + self._detail = self._detail_row(detail) + self.members_model.set_page(members, len(members), None) + + await self._run_cached( + f"detail|{kind}|{item_id}", + lambda: fetch_detail(self._catalog, item_id, kind), + Detail, + apply, + ) + if kind == "collection": + await self._load_pack() + + def open_route(self, path: tuple[str, ...]) -> None: + task = asyncio.ensure_future(self.follow_route(path)) + self._route_tasks.add(task) + task.add_done_callback(self._route_tasks.discard) + + async def follow_route(self, path: tuple[str, ...]) -> None: + if self._torn_down or not path: + return + if len(path) == 1 and path[0].title() in _TABS: + await self.select_tab(path[0].title()) + elif len(path) == 2 and path[0] in ("mod", "collection"): + await self.open_item(path[1], path[0]) + else: + logger.warning( + "[workshop] unknown route {}", build_route(self.view_id, *path) + ) + + @asyncSlot() + async def back(self) -> None: + if self._torn_down: + return + if self._trail: + kind, item_id, _ = self._trail[-1] + await self.open_item(item_id, kind, record=False) + if self._current == (kind, item_id) and self._trail: + self._trail.pop() + self.changed.emit() + return + self._generation += 1 + self._clear_detail() + self._error, self._busy = "", False + self.changed.emit() + if self._tab == "Favourites": + self._show_favourites() + elif self._tab == "Installed" and self._installed_dirty: + await self.load() + + @asyncSlot(str, str) + async def downloadItem(self, item_id: str, kind: str) -> None: + await self._download( + [item_id] if kind == "mod" else [], + [item_id] if kind == "collection" else [], + ) + + @asyncSlot(str) + async def toggleActivation(self, item_id: str) -> None: + if self._torn_down: + return + installed = [ + uuid + for uuid, mod in self._ctx.all_mods.items() + if mod.published_file_id == item_id + ] + if not installed: + return + active = set(self._ctx.active_uuids) + active_copies = [uuid for uuid in installed if uuid in active] + if active_copies: + await apply_activation(self._ctx, self, disable=active_copies) + else: + await apply_activation(self._ctx, self, enable=[installed[0]]) + + @asyncSlot() + async def toggleCollection(self) -> None: + copies = self._pack_copies() + if copies is None: + return + active = set(self._ctx.active_uuids) + if all(uuid in active for uuid in copies): + await apply_activation( + self._ctx, + self, + disable=[ + uuid + for uuid in self._ctx.active_uuids + if (mod := self._ctx.all_mods.get(uuid)) is not None + and mod.published_file_id in self._pack_ids + ], + ) + else: + await apply_activation(self._ctx, self, enable=copies) + + @asyncSlot() + async def activateOnlyCollection(self) -> None: + copies = self._pack_copies() + if copies is None: + return + keep = set(copies) + mods = self._ctx.all_mods + others = [ + uuid + for uuid in self._ctx.active_uuids + if uuid not in keep + and (mod := mods.get(uuid)) is not None + and mod.published_file_id is not None + ] + await apply_activation(self._ctx, self, enable=copies, disable=others) + + @asyncSlot() + async def updateAll(self) -> None: + ids = [ + mod.id + for mod in self._installed.mods + if self._catalog.install_state(mod) == "outdated" + ] + if ids: + await self._download(ids, []) + + @asyncSlot() + async def downloadAvailable(self) -> None: + if self._torn_down: + return + plan, self._pending = self._pending, None + if plan and plan.to_download: + self._queue_plan(plan) + self.changed.emit() + + @Slot() + def dismissPlan(self) -> None: + self._pending, self._notice = None, "" + self.changed.emit() + + @Slot(str) + def openLink(self, url: str) -> None: + if safe_url(url): + QDesktopServices.openUrl(QUrl(url)) + + @Slot() + def openSettings(self) -> None: + if self._app_ctx is not None: + self._app_ctx.navigate(build_route(SETTINGS_VIEW_ID)) + + @Slot(str) + def toggleFavourite(self, item_id: str) -> None: + item = self._items.get(item_id) + if isinstance(item, CatalogCollection): + self._favourites.set(item, item_id not in self._favourites) + + # -- downloads -------------------------------------------------------- + + def _queue_plan(self, plan: DownloadPlan) -> None: + self._catalog.enqueue(plan) + self._notice = queued_notice(len(plan.to_download)) + + async def _download(self, ids: list[str], collections: list[str]) -> None: + if self._torn_down: + return + self._notice, self._pending = "Preparing download…", None + self._planning += 1 + requested = {*ids, *collections} + self._preparing |= requested + self._refresh_rows() + self.changed.emit() + try: + plan = await self._catalog.plan(ids, collections) + except _FAILURES as exc: + if not self._torn_down: + self._notice = f"Could not prepare the download: {_message(exc)}" + return + finally: + self._planning -= 1 + self._preparing -= requested + if not self._torn_down: + self._refresh_rows() + self.changed.emit() + for collection in collections: + self._collection_mods[collection] = frozenset(plan.to_download) + if self._torn_down: + return + if not plan.is_complete: + self._pending, self._notice = plan, incomplete_plan_notice(plan) + elif not plan.to_download: + self._notice = "All available mods are already installed and up to date." + else: + self._queue_plan(plan) + self.changed.emit() + + # -- catalog events --------------------------------------------------- + + def _on_active_state_changed(self, _uuids: tuple[str, ...]) -> None: + self._refresh_mod_ids() + self._refresh_rows() + self.changed.emit() + + def _on_queue_changed(self, *_: object) -> None: + self._refresh_rows() + self.changed.emit() + + def _on_favourites_changed(self, _value: object) -> None: + if self._tab == "Favourites" and self._detail_data is None: + self._show_favourites() + return + self._refresh_rows() + self.changed.emit() + + @asyncSlot(str) + async def _on_url_changed(self, _url: str) -> None: + if self._torn_down: + return + self._clear_detail() + self._error, self._notice, self._pending = "", "", None + self._items.clear() + self._known_tags.clear() + for model in (self.mods_model, self.collections_model, self.members_model): + model.set_page([], 0, None) + await self.load() + + @asyncSlot(object) + async def _on_installed_changed(self, _value: None) -> None: + if self._torn_down: + return + self._installed_dirty = True + self._refresh_mod_ids() + self._recount() + self._refresh_rows() + self.changed.emit() + if self._tab == "Installed" and not self._busy and self._detail_data is None: + await self.load() + + # -- QML properties --------------------------------------------------- + + @Property(str, notify=changed) # type: ignore[arg-type] + def tab(self) -> str: + return self._tab + + @Property(str, notify=changed) # type: ignore[arg-type] + def url(self) -> str: + if self._current is not None: + return build_route(self.view_id, *self._current) + return build_route(self.view_id, self._tab.lower()) + + @Property(str, notify=changed) # type: ignore[arg-type] + def backLabel(self) -> str: + if self._trail: + return f"Back to {self._trail[-1][2]}" + return f"Back to {self._tab}" + + @Property(bool, notify=changed) # type: ignore[arg-type] + def configured(self) -> bool: + return self._catalog.configured + + @Property(bool, notify=changed) # type: ignore[arg-type] + def busy(self) -> bool: + return self._busy + + @Property(str, notify=changed) # type: ignore[arg-type] + def error(self) -> str: + return self._error + + @Property(str, notify=changed) # type: ignore[arg-type] + def notice(self) -> str: + return self._notice + + @Property(dict, notify=changed) # type: ignore[arg-type] + def detail(self) -> dict[str, Any]: + return self._detail + + @Property(bool, notify=changed) # type: ignore[arg-type] + def hasDetail(self) -> bool: + return bool(self._detail) + + @Property(bool, notify=changed) # type: ignore[arg-type] + def pendingPlan(self) -> bool: + return self._pending is not None + + @Property(bool, notify=changed) # type: ignore[arg-type] + def canDownloadAvailable(self) -> bool: + return self._pending is not None and bool(self._pending.to_download) + + @Property(str, notify=changed) # type: ignore[arg-type] + def searchQuery(self) -> str: + return self._criteria.query + + @Property(list, notify=changed) # type: ignore[arg-type] + def tagOptions(self) -> list[str]: + return ["All tags", *sorted(self._known_tags)] + + @Property(str, constant=True) # type: ignore[arg-type] + def gameVersion(self) -> str: + return self._game_version + + @Property(list, constant=True) # type: ignore[arg-type] + def versionOptions(self) -> list[str]: + current = self._criteria.version + return list( + dict.fromkeys( + [ + "All versions", + *([current] if current else []), + "1.6", + "1.5", + "1.4", + "1.3", + ] + ) + ) + + @Property(int, notify=changed) # type: ignore[arg-type] + def updateCount(self) -> int: + return self._update_count + + @Property(bool, notify=changed) # type: ignore[arg-type] + def planning(self) -> bool: + return self._planning > 0 diff --git a/src/pxmodrim/ui/settings_section.py b/src/pxmodrim/ui/settings_section.py new file mode 100644 index 0000000..03610e5 --- /dev/null +++ b/src/pxmodrim/ui/settings_section.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any, ClassVar + +import msgspec +from PySide6.QtCore import Property, QObject, Slot + +from pxmodrim.core.plugin_config import PluginConfig + + +class SettingsSection(QObject): + """A plugin's group on the Settings dialog. + + ``source`` is loaded into a group titled ``title`` with this object as its + required ``section`` property. Edits stay on the object until the dialog is + saved, when ``apply()`` commits them; a cancelled dialog just drops it. + """ + + title: ClassVar[str] + source: ClassVar[Path] + + def apply(self) -> None: ... + + +type SettingsSectionFactory = Callable[[QObject], SettingsSection] + + +class PluginConfigSection(SettingsSection): + """Edits a ``PluginConfig``: QML reads ``initial`` and reports edits via ``set``.""" + + def __init__(self, config: PluginConfig[Any], parent: QObject) -> None: + super().__init__(parent) + self._config = config + self._initial: dict[str, Any] = msgspec.to_builtins(config.value) + self._draft: dict[str, Any] = {} + + @Property(dict, constant=True) # type: ignore[arg-type] + def initial(self) -> dict[str, Any]: + return self._initial + + @Slot(str, "QVariant") + def set(self, key: str, value: Any) -> None: + if key not in self._initial: + raise KeyError(key) + self._draft[key] = value + + def apply(self) -> None: + if self._draft: + self._config.update( + msgspec.convert( + {**self._initial, **self._draft}, + type(self._config.value), + strict=False, + ) + ) diff --git a/src/pxmodrim/ui/window/main_window.py b/src/pxmodrim/ui/window/main_window.py index ede8c2f..b1fd429 100644 --- a/src/pxmodrim/ui/window/main_window.py +++ b/src/pxmodrim/ui/window/main_window.py @@ -52,6 +52,7 @@ from pxmodrim.ui.config import save_ui_prefs from pxmodrim.ui.context import AppContext from pxmodrim.ui.mod_selection import ModSelectionPresenter +from pxmodrim.ui.navigation import SETTINGS_VIEW_ID, build_route, parse_route from pxmodrim.ui.panels.about_panel import AboutPanel from pxmodrim.ui.panels.keyboard_shortcuts_dialog import ( QML_SHORTCUTS, @@ -151,6 +152,7 @@ def __init__(self, app_ctx: AppContext) -> None: self._close_task: asyncio.Task[None] | None = None self._update_service = UpdateService(get_app_version()) self._update_task: asyncio.Task[None] | None = None + self._settings_task: asyncio.Task[None] | None = None self._launch_task: asyncio.Task[None] | None = None self._setup_window_basics() @@ -211,10 +213,8 @@ def _setup_header_and_shortcuts(self) -> None: self._header_controller.maximize_requested.connect(self._toggle_maximized) self._header_controller.close_requested.connect(self.close) self._header_controller.drag_started.connect(self._start_system_move) - self._header_controller.downloads_requested.connect( - lambda: self._show_view("downloads") - ) - self._app_ctx.set_navigator(self._show_view) + self._header_controller.downloads_requested.connect(self._open_downloads) + self._app_ctx.set_navigator(self._open_route) self._header_controller.update_mods_requested.connect(self._update_mods) self._header = HeaderPanel(self._header_controller, self._qml_engine) @@ -223,7 +223,9 @@ def _setup_header_and_shortcuts(self) -> None: handlers = { ActionId.SAVE: self._save_mods_config, ActionId.RESTORE: self._restore_snapshot, - ActionId.SETTINGS: self._open_settings, + ActionId.SETTINGS: lambda: self._app_ctx.navigate( + build_route(SETTINGS_VIEW_ID) + ), ActionId.QUIT: self.close, ActionId.REFRESH: self._refresh_mods, ActionId.FULL_RESCAN: self._full_rescan, @@ -265,16 +267,34 @@ def _toggle_fullscreen(self) -> None: else: self.showFullScreen() - def _show_view(self, view_id: str) -> None: + def _open_route(self, url: str) -> None: + route = parse_route(url) + if route is None: + logger.warning("main_window: ignoring non-route link {}", url) + return + logger.debug("main_window: navigating to {}", url) + if route.view_id == SETTINGS_VIEW_ID: + self._start_settings() + return for index, view in enumerate(self._views): - if view.view_id == view_id: - self._select_view(index) + if view.view_id == route.view_id: + self._show_view(index) + view.open_route(route.path) return + logger.warning("main_window: no view handles {}", url) + + def _open_downloads(self) -> None: + view = next((v for v in self._views if v.lists_downloads), None) + if view is not None: + self._app_ctx.navigate(build_route(view.view_id)) def _select_view(self, index: int) -> None: if 0 <= index < self._stack.count(): - self._rail.set_current(index) - self._on_rail_tab_changed(index) + self._app_ctx.navigate(build_route(self._views[index].view_id)) + + def _show_view(self, index: int) -> None: + self._rail.set_current(index) + self._on_rail_tab_changed(index) def _cycle_view(self, direction: int) -> None: count = self._stack.count() @@ -346,9 +366,11 @@ def _setup_content_and_views(self) -> None: self._splitter.setCollapsible(1, False) rail_width = RAIL_MIN_WIDTH if self._ui_prefs.rail_collapsed else RAIL_MAX_WIDTH self._splitter.setSizes([rail_width, self.width() - rail_width]) - self._rail.currentChanged.connect(self._on_rail_tab_changed) + self._rail.currentChanged.connect(self._select_view) self._rail.hovered.connect(self._on_rail_hovered) - self._rail.settings_requested.connect(self._open_settings) + self._rail.settings_requested.connect( + lambda: self._app_ctx.navigate(build_route(SETTINGS_VIEW_ID)) + ) self._rail.help_action_requested.connect( lambda action_id: self._actions[ActionId(action_id)].trigger() ) @@ -576,10 +598,18 @@ def _on_downloads_finished(self, result: DownloadResult) -> None: self._app_ctx.refresh_mods() ) - @asyncSlot() + def _start_settings(self) -> None: + if self._settings_task is not None and not self._settings_task.done(): + return + self._settings_task = asyncio.create_task(self._open_settings()) + async def _open_settings(self) -> None: result, dialog = await await_dialog( - SettingsPanel, self._ctx, self._qml_engine, self + SettingsPanel, + self._ctx, + self._qml_engine, + self, + self._app_ctx.settings_sections, ) if result != QDialog.DialogCode.Accepted: return @@ -858,7 +888,7 @@ def _on_rail_tab_changed(self, index: int) -> None: logger.debug("main_window: rail tab changed to {}", index) self._stack.setCurrentIndex(index) self._header_controller.set_downloads_progress_shown( - self._views[index].view_id != "downloads" + not self._views[index].lists_downloads ) # Preload an adjacent tab (e.g. the Steam view next to Mods) so its # content is already warm when the user moves to it. diff --git a/tests/conftest.py b/tests/conftest.py index 8dad47a..6fdc7b5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ from collections.abc import Iterator from pathlib import Path +os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Basic") import pytest from pxmodrim.core.config import ConfigService diff --git a/tests/test_downloads/test_steam.py b/tests/test_downloads/test_steam.py index 9d4c5d7..45b0ed9 100644 --- a/tests/test_downloads/test_steam.py +++ b/tests/test_downloads/test_steam.py @@ -14,12 +14,16 @@ from pxmodrim.core.config import AppConfig, ConfigService from pxmodrim.core.context import CoreContext from pxmodrim.core.downloads import DownloadItemStatus, DownloadResult +from pxmodrim.core.downloads.manager import download_manager from pxmodrim.core.downloads.steam import ( ClientOptions, SteamDownloader, + SteamSettings, WorkshopSyncState, ) +from pxmodrim.core.mod_service import ModService from pxmodrim.core.models.metadata.structures import ListedMod +from pxmodrim.core.workshop import DownloadPlan, WorkshopCatalog class FakeClient: @@ -111,9 +115,9 @@ async def test_login_uses_configured_proxy_and_timeouts(tmp_path: Path) -> None: svc, logins = _service(tmp_path, FakeClient()) assert svc._ctx is not None await svc.download_mods(["111"]) - svc._ctx.config.workshop_proxy = " socks5h://host:1080 " - svc._ctx.config.workshop_connect_timeout = 3 - svc._ctx.config.workshop_stall_timeout = 7 + svc.settings.update( + SteamSettings(proxy=" socks5h://host:1080 ", connect_timeout=3, stall_timeout=7) + ) await svc.download_mods(["222"]) assert logins == [(None, 10, 30), ("socks5h://host:1080", 3, 7)] @@ -364,7 +368,7 @@ async def test_auto_update_only_syncs_stale_mods_when_enabled(tmp_path: Path) -> await svc._auto_update_once() assert _synced(tmp_path, svc, "1") is None - svc._ctx.config.workshop_auto_update_hours = 6 + svc.settings.update(SteamSettings(auto_update_hours=6)) await svc._auto_update_once() assert _synced(tmp_path, svc, "1") is not None synced = _synced(tmp_path, svc, "1") @@ -372,6 +376,70 @@ async def test_auto_update_only_syncs_stale_mods_when_enabled(tmp_path: Path) -> assert _synced(tmp_path, svc, "1") == synced +@pytest.mark.parametrize("cancel_auto_update", [False, True]) +async def test_cancelled_manual_batch_does_not_discard_queue_after_auto_update( + tmp_path: Path, + cancel_auto_update: bool, +) -> None: + manual_gate = threading.Event() + auto_gate = threading.Event() + client = FakeClient(gates={111: manual_gate, 1: auto_gate}) + svc, _ = _service(tmp_path, client) + assert svc._ctx is not None + ctx = svc._ctx + ctx._mod_service = ModService(ctx, []) + _load(svc, [_mod_at(tmp_path / "Mods" / "1", "1")]) + svc.settings.update(SteamSettings(auto_update_hours=6)) + manager = download_manager(ctx) + catalog = WorkshopCatalog() + catalog.setup(ctx) + tasks: list[asyncio.Task[Any]] = [] + try: + manual = asyncio.create_task(manager.download_mods(["111"])) + tasks.append(manual) + assert await asyncio.to_thread(client.blocked.wait, 5) + manager.cancel() + client.stopped = True + manual_gate.set() + assert (await asyncio.wait_for(manual, 5)).succeeded == [] + assert manager.cancelled + + client.stopped = False + client.blocked.clear() + auto_update = asyncio.create_task(svc._auto_update_once()) + tasks.append(auto_update) + assert await asyncio.to_thread(client.blocked.wait, 5) + assert manager.is_downloading + catalog.enqueue(DownloadPlan(["222"], [], [], [], {}, True)) + runner = catalog._runner + assert runner is not None + await asyncio.sleep(0) + assert not runner.done() + assert catalog.queued_ids == {"222"} + if cancel_auto_update: + manager.cancel() + client.stopped = True + + auto_gate.set() + await asyncio.wait_for(auto_update, 5) + await asyncio.wait_for(runner, 5) + assert not catalog.queued_ids + if cancel_auto_update: + assert [call[0] for call in client.calls] == [[111], [1]] + assert manager.cancelled + assert _synced(tmp_path, svc, "222") is None + else: + assert [call[0] for call in client.calls] == [[111], [1], [222]] + assert not manager.cancelled + assert _synced(tmp_path, svc, "222") is not None + finally: + manual_gate.set() + auto_gate.set() + await asyncio.gather(*tasks, return_exceptions=True) + await catalog.shutdown() + await svc.shutdown() + + async def test_titles_from_steam_are_emitted(tmp_path: Path) -> None: svc, _ = _service(tmp_path, FakeClient()) titles: dict[str, str] = {} @@ -431,10 +499,8 @@ async def test_download_uses_configured_parallelism_clamped(tmp_path: Path) -> N client = FakeClient() svc, _ = _service(tmp_path, client) assert svc._ctx is not None - svc._ctx.config.workshop_parallel_items = 3 - svc._ctx.config.workshop_threads_per_item = 5 + svc.settings.update(SteamSettings(parallel_items=3, threads_per_item=5)) await svc.download_mods(["1"]) - svc._ctx.config.workshop_parallel_items = 0 - svc._ctx.config.workshop_threads_per_item = 999 + svc.settings.update(SteamSettings(parallel_items=0, threads_per_item=999)) await svc.download_mods(["2"]) assert [c[2] for c in client.calls] == [15, 16] diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 04d9058..7e66cfb 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -85,6 +85,40 @@ def test_get_standalone_platform_args(monkeypatch) -> None: assert "--windows-product-name=PxModRim" in args +@pytest.mark.parametrize( + ("workshop", "left_out", "shipped"), + [ + ( + "NoWorkshop", + [ + "pxsteamdl", + "ui.plugins.steam_workshop", + "ui.plugins.workshop", + "ui.plugins.downloads", + "QtWebEngine", + ], + [], + ), + ( + "SteamWorkshop", + ["ui.plugins.workshop"], + ["pxsteamdl", "ui.plugins.steam_workshop", "ui.plugins.downloads"], + ), + ( + "NativeWorkshop", + ["ui.plugins.steam_workshop", "QtWebEngine"], + ["pxsteamdl", "ui.plugins.workshop", "ui.plugins.downloads"], + ), + ], +) +def test_workshop_variants_leave_out_only_what_they_do_not_ship( + workshop: str, left_out: list[str], shipped: list[str] +) -> None: + options = " ".join(pkg_build.get_standalone_args(workshop=workshop)) + assert all(name in options for name in left_out) + assert not any(name in options for name in shipped) + + def test_strip_unused_qml_modules_leaves_missing_directory_unchanged( tmp_path: Path, ) -> None: diff --git a/tests/test_plugin.py b/tests/test_plugin.py index f091674..2660f7d 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -2,6 +2,7 @@ import asyncio import os +from pathlib import Path from unittest.mock import patch import pytest @@ -126,3 +127,29 @@ def test_not_set(self) -> None: with patch.dict(os.environ, {"PX_DISABLED_PLUGINS": ""}): os.environ.pop("PX_DISABLED_PLUGINS", None) assert _parse_disabled_plugins() == set() + + +@pytest.mark.parametrize( + ("variant", "disabled"), + [ + ( + "NoWorkshop", + { + "steam_downloader", + "steamworkshop", + "downloads_ui", + "workshop_catalog", + "workshop_ui", + }, + ), + ("SteamWorkshop", {"workshop_catalog", "workshop_ui"}), + ("NativeWorkshop", {"steamworkshop"}), + ], +) +def test_baked_workshop_variant_disables_the_plugins_it_omits( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, variant: str, disabled: set[str] +) -> None: + (tmp_path / "workshop-variant.txt").write_text(variant, encoding="utf-8") + monkeypatch.setattr("pxmodrim._app.resource_files", lambda _: tmp_path) + monkeypatch.delenv("PX_DISABLED_PLUGINS", raising=False) + assert _parse_disabled_plugins() == disabled diff --git a/tests/test_services/test_activation_service.py b/tests/test_services/test_activation_service.py index 103011c..4d1cd1c 100644 --- a/tests/test_services/test_activation_service.py +++ b/tests/test_services/test_activation_service.py @@ -340,6 +340,29 @@ def test_reflects_graph_after_state_change( def test_mod_without_dependents(self, activation: ActivationService) -> None: assert activation.dependents_of(["uuid-d", "uuid-missing"]) == [] + def test_removing_duplicate_copy_keeps_dependents( + self, ctx: CoreContext, activation: ActivationService + ) -> None: + mods = {**_MODS, "uuid-a-copy": _mod("Copy A", "mod.a")} + ctx.load(mods, ["uuid-a", "uuid-a-copy", "uuid-b", "uuid-c"]) + ctx.diagnostics_service.rebuild() + + assert activation.dependents_of(["uuid-a-copy"]) == [] + assert activation.apply(disable=["uuid-a-copy"]) + assert ctx.active_uuids == ["uuid-a", "uuid-b", "uuid-c"] + assert activation.dependents_of(["uuid-a"]) == ["uuid-b", "uuid-c"] + + def test_enabling_replacement_copy_keeps_dependents( + self, ctx: CoreContext, activation: ActivationService + ) -> None: + mods = {**_MODS, "uuid-a-copy": _mod("Copy A", "mod.a")} + ctx.load(mods, ["uuid-a", "uuid-b", "uuid-c"]) + ctx.diagnostics_service.rebuild() + + assert activation.dependents_of(["uuid-a"], enable=["uuid-a-copy"]) == [] + assert activation.apply(enable=["uuid-a-copy"], disable=["uuid-a"]) + assert ctx.active_uuids == ["uuid-a-copy", "uuid-b", "uuid-c"] + def test_sidebar_entries_are_published_before_summary( ctx: CoreContext, activation: ActivationService diff --git a/tests/test_ui/test_main_window.py b/tests/test_ui/test_main_window.py index bf3cd6e..6c28f50 100644 --- a/tests/test_ui/test_main_window.py +++ b/tests/test_ui/test_main_window.py @@ -30,13 +30,27 @@ class Rail: def set_current(self, index: int) -> None: selections.append(index) + views = [ + SimpleNamespace(view_id=v, open_route=lambda path: None) + for v in ("a", "b", "c") + ] + class Window: _stack = stack _rail = Rail() + _views = views + + def __init__(self) -> None: + self._app_ctx = SimpleNamespace( + navigate=lambda url: MainWindow._open_route(cast(MainWindow, self), url) + ) def _select_view(self, index: int) -> None: MainWindow._select_view(cast(MainWindow, self), index) + def _show_view(self, index: int) -> None: + MainWindow._show_view(cast(MainWindow, self), index) + def _on_rail_tab_changed(self, index: int) -> None: stack.index = index @@ -51,6 +65,45 @@ def _on_rail_tab_changed(self, index: int) -> None: assert selections == [2, 0] +def test_route_selects_the_view_then_hands_it_the_path() -> None: + opened: list[tuple[str, ...]] = [] + shown: list[int] = [] + + def view(view_id: str) -> SimpleNamespace: + return SimpleNamespace( + view_id=view_id, + open_route=lambda path, v=view_id: opened.append((v, *path)), + ) + + window = cast( + MainWindow, + SimpleNamespace( + _views=[view("mods"), view("workshop")], _show_view=shown.append + ), + ) + MainWindow._open_route(window, "modrim://workshop/mod/42") + MainWindow._open_route(window, "modrim://missing/x") + MainWindow._open_route(window, "workshop") + assert shown == [1] + assert opened == [("workshop", "mod", "42")] + + +def test_settings_route_opens_settings_without_switching_views() -> None: + shown: list[int] = [] + opened: list[bool] = [] + window = cast( + MainWindow, + SimpleNamespace( + _views=[], + _show_view=shown.append, + _start_settings=lambda: opened.append(True), + ), + ) + MainWindow._open_route(window, "modrim://settings") + assert opened == [True] + assert shown == [] + + def test_dirty_state_tracks_active_list_order_and_updates_header() -> None: class Header: unsaved_changes = False diff --git a/tests/test_ui/test_mod_info_data.py b/tests/test_ui/test_mod_info_data.py index 6a2d49e..023662d 100644 --- a/tests/test_ui/test_mod_info_data.py +++ b/tests/test_ui/test_mod_info_data.py @@ -3,6 +3,8 @@ from types import SimpleNamespace from typing import Any, cast +import pytest + from pxmodrim.core.downloads import DownloadManager from pxmodrim.core.models.metadata.structures import ( AboutXmlMod, @@ -13,7 +15,6 @@ ) from pxmodrim.core.models.view.diagnostics import ModIssueView from pxmodrim.core.plugin import PluginRegistry -from pxmodrim.ui.components.unity_rich_text import unity_rich_text_to_html from pxmodrim.ui.panels.mod_info_data import ( IMG_WIDTH_TOKEN, _conflicts, @@ -24,16 +25,11 @@ bbcode_images, bbcode_markup, build_mod_info, + description_to_html, restore_entities, ) -def render(text: str) -> str: - return autolink( - bbcode_markup(bbcode_images(restore_entities(unity_rich_text_to_html(text)))) - ) - - def make_mod( name: str, pid: str, @@ -150,12 +146,12 @@ def test_plain_ampersand_stays_escaped(self) -> None: class TestAutolinkEscaped: def test_quoted_url_excludes_entity(self) -> None: - assert render('see "https://a.com/x" ok') == ( + assert description_to_html('see "https://a.com/x" ok') == ( 'see "https://a.com/x" ok' ) def test_angle_bracketed_url_excludes_entity(self) -> None: - assert render("") == ( + assert description_to_html("") == ( '<https://a.com/x>' ) @@ -165,29 +161,73 @@ def test_ampersand_in_query_is_kept(self) -> None: ) +class TestDescription: + def test_rich_description_preserves_formatting_entities_and_images(self) -> None: + assert description_to_html( + "[h2]Title[/h2]\nBold •\n" + "[img]https://a.com/image.png[/img]\nhttps://a.com/wiki" + ) == ( + "

Title

Bold •
" + f'
' + 'https://a.com/wiki' + ) + + def test_plaintext_is_literal_and_preserves_line_breaks(self) -> None: + assert description_to_html( + "Literal & text\n[img]https://a.com/image.png[/img]\n" + "[url=https://a.com]wiki[/url]", + "plaintext", + ) == ( + "<b>Literal & text</b>
" + "[img]https://a.com/image.png[/img]
[url=https://a.com]wiki[/url]" + ) + + class TestSecurity: def test_file_img_is_not_an_image(self) -> None: - assert " None: - html = render("[img]http://a.com/i.png[/img]") + html = description_to_html("[img]http://a.com/i.png[/img]") assert "' in html def test_javascript_url_tag_has_no_anchor(self) -> None: - assert " None: - html = render('x y') + html = description_to_html('x y') assert " None: + html = description_to_html( + f"[url={url}]link[/url] [url]{url}[/url] [img]{url}[/img] " + f"Unity link" + ) + assert " None: + html = description_to_html('') + assert html == "<img src="file:///etc/passwd">" + class TestNeeds: def test_states_and_alternatives(self) -> None: diff --git a/tests/test_ui/test_mod_tree_model.py b/tests/test_ui/test_mod_tree_model.py index b2b133a..5299f53 100644 --- a/tests/test_ui/test_mod_tree_model.py +++ b/tests/test_ui/test_mod_tree_model.py @@ -147,7 +147,7 @@ async def test_folder_toggle_applies_once_for_descendants( calls: list[tuple[list[str], list[str]]] = [] class Activation: - def dependents_of(self, uuids: list[str]) -> list[str]: + def dependents_of(self, uuids: list[str], *, enable: list[str]) -> list[str]: return [] def apply(self, enable: list[str], disable: list[str]) -> bool: diff --git a/tests/test_ui/test_navigation.py b/tests/test_ui/test_navigation.py new file mode 100644 index 0000000..be675b4 --- /dev/null +++ b/tests/test_ui/test_navigation.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import pytest + +from pxmodrim.ui.navigation import Route, build_route, parse_route + + +def test_route_round_trips_ids_with_reserved_characters() -> None: + url = build_route("workshop", "collection", "picked:a/b") + assert url == "modrim://workshop/collection/picked%3Aa%2Fb" + assert parse_route(url) == Route("workshop", ("collection", "picked:a/b")) + + +def test_view_only_route_has_empty_path() -> None: + assert parse_route("modrim://downloads") == Route("downloads") + assert parse_route("modrim://downloads/") == Route("downloads") + + +@pytest.mark.parametrize("url", ["downloads", "https://downloads", "modrim://", ""]) +def test_non_routes_are_rejected(url: str) -> None: + assert parse_route(url) is None diff --git a/tests/test_ui/test_settings_panel.py b/tests/test_ui/test_settings_panel.py new file mode 100644 index 0000000..00fa86e --- /dev/null +++ b/tests/test_ui/test_settings_panel.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from PySide6.QtCore import Qt +from PySide6.QtQuick import QQuickItem +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication, QWidget + +from pxmodrim.core.config import AppConfig +from pxmodrim.core.context import CoreContext +from pxmodrim.ui.components.svg_provider import create_qml_engine +from pxmodrim.ui.panels.settings_panel import SettingsPanel +from pxmodrim.ui.theme.qml_theme import Theme + + +def _button(root: QQuickItem, text: str) -> QQuickItem | None: + for child in root.findChildren(QQuickItem): + if child.metaObject().className().startswith("PxButton") and ( + child.property("text") == text + ): + return child + return None + + +async def test_cancel_click_closes_settings_and_unloads_without_errors( + qapp: QApplication, +) -> None: + host = QWidget() + engine = create_qml_engine(host) + engine.rootContext().setContextProperty("Theme", Theme(engine)) + warnings: list[Any] = [] + engine.warnings.connect(warnings.extend) + panel = SettingsPanel(CoreContext(AppConfig()), engine, host) + panel.resize(760, 640) + panel.show() + await asyncio.sleep(0.3) + root = panel._qml.rootObject() + assert isinstance(root, QQuickItem) + cancel = _button(root, "Cancel") + assert cancel is not None + center = cancel.mapToScene(cancel.boundingRect().center()).toPoint() + + QTest.mouseClick(panel._qml, Qt.MouseButton.LeftButton, pos=center) + QTest.qWait(20) + assert panel._qml.rootObject() is None + assert not panel.isVisible() + panel.deleteLater() + await asyncio.sleep(0.05) + assert [w.toString() for w in warnings] == [] + host.deleteLater() diff --git a/tests/test_ui/test_workshop_images.py b/tests/test_ui/test_workshop_images.py new file mode 100644 index 0000000..765a398 --- /dev/null +++ b/tests/test_ui/test_workshop_images.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from PySide6.QtCore import QUrl +from PySide6.QtGui import QImage +from PySide6.QtQml import QJSEngine, QJSValue, QQmlEngine +from PySide6.QtQuickWidgets import QQuickWidget +from PySide6.QtWidgets import QApplication, QWidget +from pytestqt.qtbot import QtBot + +from pxmodrim.ui.plugins.workshop import view + + +@pytest.fixture +def thumbnails(qapp: QApplication) -> QJSValue: + path = Path(view.__file__).with_name("thumbs.js") + engine = QJSEngine(qapp) + engine.evaluate(path.read_text().removeprefix(".pragma library"), str(path)) + return engine.globalObject().property("sized") + + +@pytest.mark.parametrize( + "url", + [ + "https://example.com/image.png", + "https://notsteamstatic.com/image.png", + "https://steamusercontent.com.example.com/image.png", + "https://user@steamstatic.com/image.png", + "", + ], +) +def test_thumbnail_leaves_other_urls_unchanged(thumbnails: QJSValue, url: str) -> None: + assert ( + thumbnails.call([QJSValue(url), QJSValue(256), QJSValue(128)]).toString() == url + ) + + +@pytest.mark.parametrize( + "host", ["images.steamusercontent.com", "steamstatic.com", "cdn.steamstatic.com"] +) +def test_thumbnail_replaces_resize_query_before_fragment( + thumbnails: QJSValue, host: str +) -> None: + url = f"https://{host}/image.png?token=abc&imw=720&imh=360&ima=fit&impolicy=Letterbox#preview" + expected = f"https://{host}/image.png?token=abc&imw=256&imh=128&ima=fit&impolicy=Letterbox#preview" + resized = thumbnails.call([QJSValue(url), QJSValue(256), QJSValue(128)]).toString() + assert resized == expected + assert ( + thumbnails.call([QJSValue(resized), QJSValue(256), QJSValue(128)]).toString() + == expected + ) + + +def test_hidden_thumbnail_unloads_and_can_load_again( + qapp: QApplication, tmp_path: Path, qtbot: QtBot +) -> None: + image = QImage(64, 32, QImage.Format.Format_RGB32) + image.fill(0xFF00FF00) + image_path = tmp_path / "preview.png" + assert image.save(str(image_path)) + directory = QUrl.fromLocalFile(str(Path(view.__file__).parent)).toString() + source = tmp_path / "Image.qml" + source.write_text( + f'import QtQuick\nimport "{directory}" as Workshop\n' + f"Workshop.CatalogImage {{ previewUrl: {json.dumps(image_path.as_uri())} }}", + ) + owner = QWidget() + engine = QQmlEngine(owner) + warnings: list[str] = [] + engine.warnings.connect( + lambda errors: warnings.extend(error.toString() for error in errors) + ) + widget = QQuickWidget(engine, owner) + widget.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) + widget.resize(200, 110) + widget.setSource(QUrl.fromLocalFile(str(source))) + owner.show() + widget.show() + root = widget.rootObject() + assert root is not None + handle = engine.newQObject(root) + + def wait_for_status(expected: int) -> None: + qtbot.waitUntil(lambda: handle.property("status").toInt() == expected) + + try: + wait_for_status(1) + root.setVisible(False) + wait_for_status(0) + root.setVisible(True) + widget.resize(300, 150) + wait_for_status(1) + assert widget.grabFramebuffer().pixelColor(150, 75).name() == "#00ff00" + assert not warnings, warnings + finally: + widget.setSource(QUrl()) + owner.close() + owner.deleteLater() diff --git a/tests/test_ui/test_workshop_installed.py b/tests/test_ui/test_workshop_installed.py new file mode 100644 index 0000000..f047cab --- /dev/null +++ b/tests/test_ui/test_workshop_installed.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import msgspec + +from pxmodrim.core.workshop import Author, CatalogMod +from pxmodrim.ui.plugins.workshop.installed import PAGE, InstalledList + + +def _mod( + id: str, title: str, author: str = "Someone", tags: list[str] | None = None +) -> CatalogMod: + return CatalogMod( + id=id, + source="steam", + title=title, + author=Author(None, author, None), + description="", + description_format="bbcode", + preview_url=None, + previews=[], + workshop_url="", + tags=tags or [], + supported_versions=[], + created_at=None, + updated_at=None, + file_size=None, + subscriptions=None, + votes=None, + dependencies=[], + package_id=None, + incompatible=False, + ) + + +def test_search_matches_title_author_id_and_tags_case_insensitively() -> None: + installed = InstalledList() + installed.replace( + [ + _mod("11", "Harmony", "Brrainz"), + _mod("12", "HugsLib", tags=["Library"]), + _mod("999", "Other"), + ] + ) + for query, expected in [ + ("harm", ["11"]), + ("BRRAINZ", ["11"]), + ("library", ["12"]), + ("999", ["999"]), + ("", ["11", "12", "999"]), + ("nothing", []), + ]: + installed.search(query) + assert [m.id for m in installed.visible()] == expected, query + + +def test_paging_reveals_one_page_at_a_time_and_search_restarts_it() -> None: + installed = InstalledList() + base = _mod("0", "Mod") + installed.replace( + [msgspec.structs.replace(base, id=str(i)) for i in range(PAGE * 2 + 5)] + ) + assert len(installed.visible()) == PAGE and installed.has_more + second = installed.next_page() + assert len(second) == PAGE and second[0].id == str(PAGE) + assert len(installed.next_page()) == 5 and not installed.has_more + assert installed.next_page() == [] + installed.search("") + assert len(installed.visible()) == PAGE and installed.total == PAGE * 2 + 5 diff --git a/tests/test_ui/test_workshop_models.py b/tests/test_ui/test_workshop_models.py new file mode 100644 index 0000000..1a6592d --- /dev/null +++ b/tests/test_ui/test_workshop_models.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import msgspec +import pytest +from PySide6.QtCore import QModelIndex, QObject, Qt + +from pxmodrim.core.workshop import Author, CatalogCollection, CatalogMod, Preview, Votes +from pxmodrim.ui.panels.mod_info_data import description_to_html +from pxmodrim.ui.plugins.workshop.models import ( + CatalogListModel, + item_row, + safe_url, + summarize, +) + + +@pytest.mark.parametrize( + "url", + [ + "javascript:alert(1)", + "file:///tmp/test", + "data:text/plain,hi", + "//example.com", + "https:///path", + "https://example.com/\nsecret", + "https://[bad", + ], +) +def test_link_scheme_filter_rejects_unsafe_urls(url: str) -> None: + assert safe_url(url) == "" + + +@pytest.mark.parametrize( + "url", ["https://example.com/path", "http://example.com", "HTTPS://example.com"] +) +def test_link_scheme_filter_accepts_web_urls(url: str) -> None: + assert safe_url(url) == url + + +def _mod(pid: str) -> CatalogMod: + return CatalogMod( + id=pid, + source="steam", + title=f"Mod {pid}", + author=Author("1", "Author", None), + description="[b]Description[/b]", + description_format="bbcode", + preview_url=None, + previews=[], + workshop_url=f"https://steamcommunity.com/sharedfiles/filedetails/?id={pid}", + tags=["Utility"], + supported_versions=["1.5"], + created_at=None, + updated_at=100, + file_size="1048576", + subscriptions=None, + votes=None, + dependencies=[], + package_id=None, + incompatible=False, + ) + + +def test_roles_paging_append_and_reset(qapp: object) -> None: + owner = QObject() + model = CatalogListModel(owner) + rows = [item_row(_mod(pid), lambda _: "outdated", "1.6") for pid in ["1", "2", "3"]] + model.set_page(rows[:2], 10, "page2") + assert model.hasMore and model.count == 2 and model.total == 10 + names = {bytes(name.data()).decode() for name in model.roleNames().values()} + assert names == set(rows[0]) + role = next( + role + for role, name in model.roleNames().items() + if bytes(name.data()) == b"itemId" + ) + assert model.data(model.index(0), role) == "1" + model.set_page(rows[1:], 10, None, append=True) + assert model.count == 3 and not model.hasMore + assert model.data(model.index(2), role) == "3" + assert model.data(QModelIndex(), role) is None + assert model.data(model.index(0), Qt.ItemDataRole.DisplayRole) is None + assert model.rowCount(model.index(0)) == 0 + model.set_page([], 0, None) + assert model.count == 0 and model.total == 0 + + +def test_refreshing_the_same_page_updates_changed_rows_without_reset( + qapp: object, +) -> None: + owner = QObject() + model = CatalogListModel(owner) + mods = [_mod(pid) for pid in ["1", "2", "3"]] + + def show(items: list[CatalogMod]) -> None: + rows = [item_row(m, lambda _: "missing", "1.5") for m in items] + model.set_page(rows, 3, None) + + show(mods) + resets: list[bool] = [] + changed: list[int] = [] + model.modelReset.connect(lambda: resets.append(True)) + model.dataChanged.connect(lambda first, _last: changed.append(first.row())) + + mods[0] = msgspec.structs.replace(mods[0], subscriptions=500) + mods[1] = msgspec.structs.replace(mods[1], title="Renamed") + show(mods) + assert resets == [] and changed == [1] + title = next(r for r, n in model.roleNames().items() if bytes(n.data()) == b"title") + assert model.data(model.index(1), title) == "Renamed" + + model.set_page( + [item_row(m, lambda _: "missing", "1.5") for m in mods[::-1]], 3, None + ) + assert resets == [True] + + +def test_row_shows_update_and_game_incompatibility() -> None: + row = item_row(_mod("1"), lambda _: "outdated", "1.6") + assert row["actionLabel"] == "Update" and row["incompatible"] + assert row["fileSize"] == "1.0 MB" + + +@pytest.mark.parametrize( + ("votes", "approval", "up", "down"), + [ + (None, "Unrated", "0", "0"), + (Votes(0, 0, 100), "Unrated", "0", "0"), + (Votes(0, 1, None), "0%", "0", "1"), + (Votes(1, 0, None), "100%", "1", "0"), + (Votes(1, 2, None), "33%", "1", "2"), + (Votes(2, 1, None), "67%", "2", "1"), + (Votes(1_000, 3_000, None), "25%", "1,000", "3,000"), + ], +) +def test_vote_approval_and_counts( + votes: Votes | None, approval: str, up: str, down: str +) -> None: + mod = msgspec.structs.replace(_mod("1"), votes=votes) + row = item_row(mod, lambda _: "installed", "1.5") + assert (row["votes"], row["votesUp"], row["votesDown"]) == (approval, up, down) + + +@pytest.mark.parametrize("description_format", ["bbcode", "text"]) +def test_descriptions_use_shared_mod_info_renderer(description_format: str) -> None: + mod = msgspec.structs.replace(_mod("1"), description_format=description_format) + row = item_row(mod, lambda _: "missing", "1.5", detail=True) + assert row["description"] == description_to_html( + mod.description, description_format + ) + + +def test_versions_are_unique_numeric_sorted_and_not_repeated_in_tags() -> None: + mod = _mod("1") + mod = msgspec.structs.replace( + mod, + supported_versions=["1.5", "1.10", "1.2", "1.5", "1.2.1"], + tags=["Mod", "1.5", "Utility", "1.10", "1.2", "1.2.1"], + ) + row = item_row(mod, lambda _: "missing", "1.5") + assert row["versions"] == "1.2, 1.2.1, 1.5, 1.10" + assert row["tags"] == "Mod · Utility" + + +def test_collection_image_prefers_own_then_first_image_then_member_collage() -> None: + bare = CatalogCollection( + id="steam:1", + source="steam", + steam_id="1", + title="C", + author=Author("1", "A", None), + description="", + description_format="bbcode", + preview_url=None, + previews=[ + Preview("video", "https://youtube.test/v"), + Preview("image", "https://images.test/own.png"), + ], + workshop_url=None, + tags=[], + supported_versions=[], + created_at=None, + updated_at=None, + member_ids=["10"], + member_count=1, + member_previews=["https://images.test/m.png", "javascript:alert(1)"], + ) + row = item_row(bare, lambda _: "missing", "1.6") + assert row["previewUrl"] == "https://images.test/own.png" + assert row["collage"] == ["https://images.test/m.png"] + assert (row["votes"], row["votesUp"], row["votesDown"]) == ("Unrated", "0", "0") + assert row["active"] is False + + +def test_summary_is_plain_text_without_bbcode_or_media() -> None: + text = ( + "[h1]Title[/h1] [img]https://x.test/a.png[/img] " + "Adds [url=https://x.test]a thing[/url]" + ) + assert summarize(text) == "Title Adds a thing" + assert summarize("[b]x[/b] kept", "text") == "[b]x[/b] kept" + + +def test_summary_truncates_on_a_word_boundary() -> None: + summary = summarize("word " * 100) + assert summary.endswith("word…") + assert len(summary) <= 141 + + +@pytest.mark.parametrize( + ("supported", "game", "incompatible", "label"), + [ + (["1.5"], "1.5", False, "Works with 1.5"), + (["1.4"], "1.5", True, "Not for 1.5"), + ([], "1.5", False, "Version not stated"), + (["1.5"], "", False, ""), + ], +) +def test_compat_label( + supported: list[str], game: str, incompatible: bool, label: str +) -> None: + mod = msgspec.structs.replace( + _mod("1"), supported_versions=supported, incompatible=incompatible + ) + assert item_row(mod, lambda _: "missing", game)["compatLabel"] == label + + +def test_collection_size_comes_from_the_server_and_is_hidden_when_unknown() -> None: + collection = CatalogCollection( + id="picked:x", + source="picked", + steam_id=None, + title="C", + author=Author(None, "Curator", None), + description="", + description_format="text", + preview_url=None, + previews=[], + workshop_url=None, + tags=[], + supported_versions=[], + created_at=None, + updated_at=None, + member_ids=["1"], + member_count=1, + ) + unknown = item_row(collection, lambda _: "missing", "1.6") + sized = item_row( + msgspec.structs.replace(collection, total_size=str(3 * 1048576)), + lambda _: "missing", + "1.6", + ) + assert unknown["fileSize"] == "Unknown" + assert sized["fileSize"] == "3.0 MB" + + +def test_collection_without_a_common_version_is_incompatible_not_unstated() -> None: + collection = CatalogCollection( + id="steam:1", + source="steam", + steam_id="1", + title="C", + author=Author(None, "Curator", None), + description="", + description_format="bbcode", + preview_url=None, + previews=[], + workshop_url=None, + tags=[], + supported_versions=[], + created_at=None, + updated_at=None, + member_ids=["1", "2"], + member_count=2, + no_common_version=True, + ) + row = item_row(collection, lambda _: "missing", "1.6") + assert row["compatLabel"] == "No common version" + assert row["incompatible"] is True + unknown = item_row( + msgspec.structs.replace(collection, no_common_version=False), + lambda _: "missing", + "1.6", + ) + assert unknown["compatLabel"] == "Version not stated" + assert unknown["incompatible"] is False + + +@pytest.mark.parametrize( + ("supported_versions", "tags"), + [ + ([], ["Mod", "1.6"]), + (["1.5"], ["Mod", "1.6"]), + (["1.6"], ["Mod"]), + ], +) +def test_collection_declarations_override_conflicting_member_summary( + supported_versions: list[str], tags: list[str] +) -> None: + collection = CatalogCollection( + id="steam:3814118422", + source="steam", + steam_id="3814118422", + title="12", + author=Author(None, "Milka", None), + description="", + description_format="bbcode", + preview_url=None, + previews=[], + workshop_url=None, + tags=tags, + supported_versions=supported_versions, + created_at=None, + updated_at=None, + member_ids=["1", "2"], + member_count=2, + no_common_version=True, + ) + row = item_row(collection, lambda _: "missing", "1.6") + assert row["incompatible"] is False + assert row["compatLabel"] == "Works with 1.6" + assert "1.6" in row["versions"] + assert row["tags"] == "Mod" diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py new file mode 100644 index 0000000..bdbed93 --- /dev/null +++ b/tests/test_ui/test_workshop_view.py @@ -0,0 +1,1443 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import msgspec +import pytest +from PySide6.QtCore import QObject, QPoint, Qt, QUrl +from PySide6.QtGui import QAccessible, QKeySequence +from PySide6.QtQuick import QQuickItem +from PySide6.QtQuickWidgets import QQuickWidget +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QWidget +from pytestqt.qtbot import QtBot +from shiboken6 import delete, isValid + +from pxmodrim.core.config import AppConfig, ConfigService +from pxmodrim.core.context import CoreContext +from pxmodrim.core.downloads import DownloadManager +from pxmodrim.core.models.metadata.structures import ( + AboutXmlMod, + BaseRules, + CaseInsensitiveStr, + DependencyMod, + ListedMod, +) +from pxmodrim.core.workshop import ( + Author, + CatalogCollection, + CatalogError, + CatalogMod, + CatalogPage, + CatalogQuery, + CollectionDetail, + Discover, + DownloadPlan, + InstallState, + Preview, + WorkshopCatalog, +) +from pxmodrim.ui.components import create_qml_engine +from pxmodrim.ui.context import AppContext +from pxmodrim.ui.navigation import parse_route +from pxmodrim.ui.panels.mod_info_data import description_to_html +from pxmodrim.ui.plugins.workshop.plugin import CatalogSettingsSection +from pxmodrim.ui.plugins.workshop.view import WorkshopViewPanel +from pxmodrim.ui.theme.qml_theme import Theme + + +def _mod(pid: str, dependencies: list[str] | None = None) -> CatalogMod: + return CatalogMod( + id=pid, + source="steam", + title="Untrusted title", + author=Author("1", "Author", None), + description="[b]Safe description[/b]", + description_format="bbcode", + preview_url=None, + previews=[], + workshop_url="https://steamcommunity.com/sharedfiles/filedetails/?id=" + pid, + tags=["Utility"], + supported_versions=["1.5"], + created_at=None, + updated_at=100, + file_size="1024", + subscriptions=None, + votes=None, + dependencies=dependencies or [], + package_id=None, + incompatible=False, + ) + + +class FakeCatalog(WorkshopCatalog): + def __init__(self, ctx: CoreContext) -> None: + super().__init__() + self.mod_item = _mod("1", ["2"]) + self.dependency = _mod("2") + self.collection_item = CatalogCollection( + id="picked:test", + source="picked", + steam_id=None, + title="Test collection", + author=Author(None, "Curator", None), + description="Plain text", + description_format="text", + preview_url=None, + previews=[], + workshop_url=None, + tags=[], + supported_versions=["1.6"], + created_at=None, + updated_at=None, + member_ids=["1", "missing"], + member_count=2, + ) + self.failure = False + self.collection_complete = False + self.downloaded: list[str] = [] + self.queries: list[CatalogQuery] = [] + + async def discover(self) -> Discover: + return Discover( + CatalogPage([self.mod_item], 1, None), + CatalogPage([self.collection_item], 1, None), + ) + + async def mods(self, q: CatalogQuery) -> CatalogPage[CatalogMod]: + self.queries.append(q) + if self.failure: + raise CatalogError("Catalog unavailable", 503) + return CatalogPage( + [self.dependency] if q.cursor else [self.mod_item], + 2, + None if q.cursor else "second", + ) + + async def collections(self, q: CatalogQuery) -> CatalogPage[CatalogCollection]: + return CatalogPage([self.collection_item], 1, None) + + async def mod(self, id: str) -> CatalogMod | None: + return {"1": self.mod_item, "2": self.dependency}.get(id) + + async def collection(self, id: str) -> CollectionDetail | None: + if self.collection_complete: + collection = msgspec.structs.replace( + self.collection_item, member_ids=["1"], member_count=1 + ) + return CollectionDetail(collection, [self.mod_item], [], True) + return CollectionDetail( + self.collection_item, [self.mod_item], ["missing"], False + ) + + async def installed_with_updates(self) -> list[CatalogMod]: + return [self.mod_item] + + def install_state(self, mod: CatalogMod) -> InstallState: + return "outdated" if mod.id == "1" else "missing" + + async def plan(self, mod_ids: list[str], collection_ids: list[str]) -> DownloadPlan: + incomplete = bool(collection_ids) and not self.collection_complete + return DownloadPlan( + ["2", "1"], + [], + ["missing"] if incomplete else [], + collection_ids if incomplete else [], + {"1": "Mod", "2": "Dependency"}, + not incomplete, + ) + + def enqueue(self, plan: DownloadPlan) -> None: + self.downloaded.extend(plan.to_download) + self._queued.update(plan.to_download) + self.queue_changed.emit(None) + + +@pytest.fixture +async def panel( + qapp: object, config_service: ConfigService +) -> AsyncIterator[tuple[WorkshopViewPanel, FakeCatalog, list[Any]]]: + owner = QWidget() + ctx = CoreContext.create(AppConfig(), config_service) + manager = DownloadManager() + ctx.plugins.register(manager) + catalog = FakeCatalog(ctx) + engine = create_qml_engine() + engine.setParent(owner) + theme = Theme(engine) + engine.rootContext().setContextProperty("Theme", theme) + warnings: list[Any] = [] + + def _on_warning(qml_warnings: list[Any]) -> None: + for w in qml_warnings: + msg = w.toString() if hasattr(w, "toString") else str(w) + if "The current style does not support customization" in msg: + continue + warnings.append(w) + + engine.warnings.connect(_on_warning) + view = WorkshopViewPanel(ctx, engine, owner, catalog=catalog) + view.resize(1100, 800) + yield view, catalog, warnings + if isValid(view): + view.teardown() + view._qml.setSource(QUrl()) + owner.deleteLater() + + +@pytest.fixture +def installed_mods(tmp_path: Path) -> dict[str, ListedMod]: + mods: dict[str, ListedMod] = {} + for provider, pid in [("steamcmd", "1"), ("steam", "1"), ("steam", "2")]: + mod = AboutXmlMod( + name=f"Installed {pid}", + package_id=CaseInsensitiveStr(f"test.mod{pid}"), + provider_id=provider, + _mod_path=tmp_path / provider / pid, + ) + mods[mod.uuid] = mod + return mods + + +async def test_view_loads_qml_and_discovery_without_errors( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, _, warnings = panel + await asyncio.sleep(0) + assert view._qml.status() == QQuickWidget.Status.Ready + assert view.mods_model.count == 1 and view.collections_model.count == 1 + assert not warnings, [warning.toString() for warning in warnings] + + +async def test_destroyed_view_does_not_receive_core_shutdown_events( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, _ = panel + await view.load() + model = view.mods_model + assert model.count == 1 + notifications: list[None] = [] + catalog.queue_changed.connect(notifications.append) + downloads = view._downloads + ctx = view._ctx + view._qml.setSource(QUrl()) + delete(view) + await catalog.shutdown() + downloads.busy_changed.emit(False) + catalog.installed_changed.emit(None) + catalog.catalog_url_changed.emit("") + ctx.active_state_changed.emit(()) + assert not isValid(model) + assert notifications == [None] + + +async def test_teardown_discards_load_finishing_after_view_destruction( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + view, catalog, _ = panel + await view.load() + started, release = asyncio.Event(), asyncio.Event() + discover = catalog.discover + + async def delayed_discover() -> Discover: + started.set() + await release.wait() + return await discover() + + monkeypatch.setattr(catalog, "discover", delayed_discover) + catalog._responses.clear() + task = asyncio.ensure_future(view.refresh()) + async with asyncio.timeout(3): + await started.wait() + view.teardown() + view.teardown() + model = view.mods_model + view._qml.setSource(QUrl()) + delete(view) + release.set() + await task + assert not isValid(model) + + +async def test_view_paging_filters_and_catalog_errors( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, _ = panel + await asyncio.sleep(0) + await view.select_tab("Mods") + await view.filter("", "1.6", "all", "popular", "") + await view.loadMore() + assert view.mods_model.count == 2 and not view.mods_model.hasMore + assert ( + catalog.queries[-1].cursor == "second" and catalog.queries[-1].version == "1.6" + ) + catalog.failure = True + await view.filter("uncached", "1.6", "all", "popular", "") + assert view.error == "Catalog unavailable" + catalog.failure = False + await view.retry() + assert view.error == "" and view.mods_model.count == 1 + + +async def test_mod_detail_safe_description_dependencies_and_download( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, warnings = panel + await asyncio.sleep(0) + await view.open_item("1", "mod") + detail = cast("dict[str, Any]", view.detail) + assert detail["description"] == description_to_html( + catalog.mod_item.description, catalog.mod_item.description_format + ) + assert detail["actionLabel"] == "Update" + assert view.members_model.count == 1 + await view.downloadItem("1", "mod") + assert catalog.downloaded == ["2", "1"] + assert view._ctx.active_uuids == [] + assert not warnings, [warning.toString() for warning in warnings] + + +async def test_incomplete_collection_requires_explicit_partial_download( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, warnings = panel + await asyncio.sleep(0) + await view.open_item("picked:test", "collection") + detail = cast("dict[str, Any]", view.detail) + assert not detail["complete"] and "missing" in detail["warning"] + assert detail["description"] == description_to_html( + catalog.collection_item.description, catalog.collection_item.description_format + ) + await view.downloadItem("picked:test", "collection") + assert view.pendingPlan and not catalog.downloaded + assert view.canDownloadAvailable + await view.downloadAvailable() + assert catalog.downloaded == ["2", "1"] and not view.pendingPlan + assert not warnings, [warning.toString() for warning in warnings] + + +async def test_collection_detail_uses_member_images_when_preview_metadata_is_absent( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, _ = panel + catalog.mod_item = msgspec.structs.replace( + catalog.mod_item, + preview_url=None, + previews=[Preview("image", "https://images.test/member.png")], + ) + await view.open_item("picked:test", "collection") + detail = cast("dict[str, Any]", view.detail) + assert detail["collage"] == ["https://images.test/member.png"] + + +@pytest.mark.parametrize("tab", ["Discover", "Collections", "Favourites"]) +@pytest.mark.parametrize( + ("installed_count", "state", "action_label"), + [ + (0, "missing", "Download all"), + (1, "missing", "Complete download"), + (2, "installed", "Installed"), + ], +) +async def test_collection_listing_detects_installed_members_without_opening_detail( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], + tab: str, + installed_count: int, + state: str, + action_label: str, +) -> None: + view, catalog, _ = panel + catalog.collection_item = msgspec.structs.replace( + catalog.collection_item, member_ids=["1", "2"] + ) + catalog._responses.clear() + installed_ids = catalog.collection_item.member_ids[:installed_count] + view._ctx.load( + { + uuid: mod + for uuid, mod in installed_mods.items() + if mod.published_file_id in installed_ids + }, + [], + ) + await view.load() + if tab == "Favourites": + view.toggleFavourite(catalog.collection_item.id) + await view.select_tab(tab) + + row = view.collections_model._rows[0] + assert row["state"] == state + assert row["actionLabel"] == action_label + assert not view.hasDetail + + +async def test_collection_listing_refreshes_when_installed_members_change( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], +) -> None: + view, catalog, _ = panel + catalog.collection_item = msgspec.structs.replace( + catalog.collection_item, member_ids=["1", "2"] + ) + catalog._responses.clear() + await view.select_tab("Collections") + assert view.collections_model._rows[0]["actionLabel"] == "Download all" + + view._ctx.load(installed_mods, []) + await view._on_installed_changed(None) + row = view.collections_model._rows[0] + assert row["state"] == "installed" + assert row["actionLabel"] == "Installed" + + steamcmd, _, _ = installed_mods + view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) + await view._on_installed_changed(None) + row = view.collections_model._rows[0] + assert row["state"] == "missing" + assert row["actionLabel"] == "Complete download" + + view._ctx.load({}, []) + await view._on_installed_changed(None) + assert view.collections_model._rows[0]["actionLabel"] == "Download all" + assert not view.hasDetail + + +@pytest.mark.parametrize("fully_installed", [False, True]) +async def test_collection_listing_progress_overrides_installed_member_labels( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], + monkeypatch: pytest.MonkeyPatch, + fully_installed: bool, +) -> None: + view, catalog, _ = panel + catalog.collection_complete = True + catalog.collection_item = msgspec.structs.replace( + catalog.collection_item, member_ids=["1", "2"] + ) + catalog._responses.clear() + steamcmd, _, _ = installed_mods + view._ctx.load( + installed_mods if fully_installed else {steamcmd: installed_mods[steamcmd]}, + [], + ) + await view.load() + release = asyncio.Event() + plan = catalog.plan + + async def gated_plan(mod_ids: list[str], collection_ids: list[str]) -> DownloadPlan: + await release.wait() + return await plan(mod_ids, collection_ids) + + monkeypatch.setattr(catalog, "plan", gated_plan) + planning = asyncio.ensure_future(view._download([], [catalog.collection_item.id])) + try: + await asyncio.sleep(0) + row = view.collections_model._rows[0] + assert row["queued"] and row["actionLabel"] == "Preparing…" + finally: + release.set() + await planning + row = view.collections_model._rows[0] + assert row["queued"] and row["actionLabel"] == "Queued" + catalog.cancel_queue() + assert view.collections_model._rows[0]["actionLabel"] == ( + "Installed" if fully_installed else "Complete download" + ) + + +async def test_installed_collection_listing_and_resolved_detail_agree( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], +) -> None: + view, catalog, _ = panel + catalog.collection_complete = True + catalog.collection_item = msgspec.structs.replace( + catalog.collection_item, member_ids=["1"], member_count=1 + ) + catalog._responses.clear() + view._ctx.load(installed_mods, []) + await view.load() + listing = view.collections_model._rows[0] + assert listing["state"] == "installed" + assert listing["actionLabel"] == "Installed" + + await view.open_item(catalog.collection_item.id, "collection") + detail = cast("dict[str, Any]", view.detail) + assert detail["state"] == listing["state"] + assert detail["actionLabel"] == listing["actionLabel"] + + +async def test_collection_download_button_tracks_its_queued_mods( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + view, catalog, _ = panel + catalog.collection_complete = True + await view.load() + await view.open_item("picked:test", "collection") + detail = cast("dict[str, Any]", view.detail) + assert not detail["queued"] + release = asyncio.Event() + plan = catalog.plan + + async def gated_plan(mod_ids: list[str], collection_ids: list[str]) -> DownloadPlan: + await release.wait() + return await plan(mod_ids, collection_ids) + + monkeypatch.setattr(catalog, "plan", gated_plan) + planning = asyncio.ensure_future(view._download([], ["picked:test"])) + await asyncio.sleep(0) + detail = cast("dict[str, Any]", view.detail) + assert detail["queued"] and detail["actionLabel"] == "Preparing…" + release.set() + await planning + detail = cast("dict[str, Any]", view.detail) + assert detail["queued"] and detail["actionLabel"] == "Queued" + model = view.collections_model + queued = next( + role + for role, name in model.roleNames().items() + if bytes(name.data()) == b"queued" + ) + assert model.data(model.index(0), queued) is True + catalog.cancel_queue() + detail = cast("dict[str, Any]", view.detail) + assert not detail["queued"] and detail["actionLabel"] == "Complete download" + + +async def test_unset_catalog_never_fetches( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, _ = panel + await asyncio.sleep(0) + catalog.set_base_url("") + await view.load() + assert not view.configured and not catalog.queries + + +async def test_settings_saves_and_applies_catalog_url( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + from pxmodrim.ui.panels.settings_panel import SettingsPanel + + view, catalog, warnings = panel + await asyncio.sleep(0) + settings = SettingsPanel( + view._ctx, + view._qml.engine(), + view, + [lambda parent: CatalogSettingsSection(catalog.settings, parent)], + ) + settings.show() + await asyncio.sleep(0.2) + root = settings._qml.rootObject() + assert root is not None + field = _find_item(cast("QQuickItem", root), "catalogUrlField") + assert field is not None + assert field.property("text") == "https://api.modrim.pyxiion.dev" + field.forceActiveFocus() + QTest.keySequence(settings._qml, QKeySequence.StandardKey.SelectAll) + QTest.keyClicks(settings._qml, " https://catalog.example.test/ ") + assert catalog.configured and catalog._base_url != "https://catalog.example.test" + + settings._save(dict(cast("dict[str, Any]", settings._backend.initial))) + assert catalog.settings.value.url == "https://catalog.example.test/" + assert catalog._base_url == "https://catalog.example.test" + QTest.qWait(10) + delete(settings) + assert warnings == [] + + +async def test_installed_search_filters_loaded_mods_without_refetching( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, _ = panel + await asyncio.sleep(0) + fetches = 0 + base = catalog.mod_item + mods = [ + msgspec.structs.replace(base, id=str(i), title=title) + for i, title in enumerate(["Harmony", "HugsLib", "Rim HUD"]) + ] + + async def installed() -> list[CatalogMod]: + nonlocal fetches + fetches += 1 + return mods + + catalog.installed_with_updates = installed # type: ignore[method-assign] + await view.select_tab("Installed") + assert view.mods_model.count == 3 and fetches == 1 + + await view.filter("hugs", "", "all", "popular", "") + assert view.mods_model.count == 1 and view.tab == "Installed" and fetches == 1 + await view.filter("", "", "all", "popular", "") + assert view.mods_model.count == 3 and fetches == 1 + assert view.searchQuery == "" + + +async def test_installed_set_changes_refresh_the_cached_list( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], + tmp_path: Path, +) -> None: + view, catalog, _ = panel + catalog._ctx = view._ctx + catalog._cache_dir = tmp_path / "cache" + steamcmd, _, dependency = installed_mods + view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) + fetches = 0 + + async def installed() -> list[CatalogMod]: + nonlocal fetches + fetches += 1 + items = {"1": catalog.mod_item, "2": catalog.dependency} + return [items[pid] for pid in catalog._installed()] + + catalog.installed_with_updates = installed # type: ignore[method-assign] + await view.select_tab("Installed") + await view.select_tab("Mods") + await view.select_tab("Installed") + assert fetches == 1 and view.mods_model.count == 1 + + catalog.installed_changed.disconnect(view._on_installed_changed) + view._ctx.load(installed_mods, []) + catalog._on_installed_changed(None) + catalog.installed_changed.connect(view._on_installed_changed) + await view._on_installed_changed(None) + assert fetches == 2 and view.mods_model.count == 2 + assert {row["itemId"] for row in view.mods_model._rows} == {"1", "2"} + + catalog.installed_changed.disconnect(view._on_installed_changed) + view._ctx.load({dependency: installed_mods[dependency]}, []) + catalog._on_installed_changed(None) + catalog.installed_changed.connect(view._on_installed_changed) + await view._on_installed_changed(None) + assert fetches == 3 and view.mods_model.count == 1 + assert view.mods_model._rows[0]["itemId"] == "2" + + catalog._responses.clear() + await view.select_tab("Mods") + await view.select_tab("Installed") + assert fetches == 3 and view.mods_model._rows[0]["itemId"] == "2" + + +@pytest.mark.parametrize("state", ["installed", "outdated"]) +@pytest.mark.parametrize("duplicate_active", [False, True]) +async def test_activation_toggles_one_installed_copy_and_prefers_active_steam_copy( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], + monkeypatch: pytest.MonkeyPatch, + state: InstallState, + duplicate_active: bool, +) -> None: + view, catalog, _ = panel + steamcmd, steam, unrelated = installed_mods + view._ctx.load(installed_mods, []) + view._ctx.set_active( + [steamcmd, steam, unrelated] if duplicate_active else [steam, unrelated] + ) + monkeypatch.setattr(catalog, "install_state", lambda _: state) + await view.load() + await view.open_item("1", "mod") + role = next( + role + for role, name in view.mods_model.roleNames().items() + if bytes(name.data()) == b"active" + ) + assert view.mods_model.data(view.mods_model.index(0), role) is True + assert cast("dict[str, Any]", view.detail)["active"] is True + expected_action = "Update" if state == "outdated" else "Installed" + assert cast("dict[str, Any]", view.detail)["actionLabel"] == expected_action + + await view.toggleActivation("1") + assert view._ctx.active_uuids == [unrelated] + assert view.mods_model.data(view.mods_model.index(0), role) is False + assert cast("dict[str, Any]", view.detail)["active"] is False + + await view.toggleActivation("1") + assert set(view._ctx.active_uuids) == {steamcmd, unrelated} + assert steam not in view._ctx.active_uuids + assert view.mods_model.data(view.mods_model.index(0), role) is True + assert cast("dict[str, Any]", view.detail)["active"] is True + + +async def test_external_activation_refreshes_listing_detail_and_members( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], +) -> None: + view, _, _ = panel + _, steam, dependency = installed_mods + view._ctx.load(installed_mods, []) + await view.load() + await view.open_item("1", "mod") + role = next( + role + for role, name in view.mods_model.roleNames().items() + if bytes(name.data()) == b"active" + ) + + for active, expected in [([steam, dependency], True), ([], False)]: + view._ctx.set_active(active) + assert view.mods_model.data(view.mods_model.index(0), role) is expected + assert view.members_model.data(view.members_model.index(0), role) is expected + assert cast("dict[str, Any]", view.detail)["active"] is expected + + +async def test_download_never_activates_installed_copies( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], +) -> None: + view, catalog, _ = panel + view._ctx.load(installed_mods, []) + await view.downloadItem("1", "mod") + assert catalog.downloaded == ["2", "1"] + assert view._ctx.active_uuids == [] + + +def _find_item(item: QQuickItem, name: str) -> QQuickItem | None: + if item.objectName() == name: + return item + for child in item.childItems(): + found = _find_item(child, name) + if found is not None: + return found + return None + + +async def test_installed_checkbox_click_toggles_activation_without_opening_details( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], +) -> None: + view, catalog, _ = panel + catalog._ctx = view._ctx + steamcmd, _, _ = installed_mods + view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) + view.window().resize(1100, 800) + view.window().show() + await view.select_tab("Installed") + await asyncio.sleep(0.5) + view._qml.grabFramebuffer() + await asyncio.sleep(0.2) + + root = view._qml.rootObject() + assert root is not None + box = _find_item(cast("QQuickItem", root), "activeToggle") + assert box is not None + center = box.mapToScene(box.boundingRect().center()).toPoint() + QTest.mouseClick(view._qml, Qt.MouseButton.LeftButton, pos=center) + async with asyncio.timeout(3): + while not view._ctx.active_uuids: + await asyncio.sleep(0.02) + assert view._ctx.active_uuids == [steamcmd] + assert not view.hasDetail + + +async def test_clicking_an_installed_row_navigates_by_link( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], +) -> None: + view, catalog, _ = panel + catalog._ctx = view._ctx + steamcmd, _, _ = installed_mods + view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) + links: list[str] = [] + + def navigate(url: str) -> None: + links.append(url) + route = parse_route(url) + assert route is not None + view.open_route(route.path) + + view._app_ctx = cast("AppContext", SimpleNamespace(navigate=navigate)) + view.window().resize(1100, 800) + view.window().show() + await view.select_tab("Installed") + await asyncio.sleep(0.5) + view._qml.grabFramebuffer() + await asyncio.sleep(0.2) + + root = view._qml.rootObject() + assert root is not None + box = _find_item(cast("QQuickItem", root), "activeToggle") + assert box is not None + beside = box.mapToScene(box.boundingRect().center()).toPoint() + beside.setX(beside.x() + 150) + QTest.mouseClick(view._qml, Qt.MouseButton.LeftButton, pos=beside) + async with asyncio.timeout(3): + while not view.hasDetail: + await asyncio.sleep(0.02) + assert links == ["modrim://workshop/mod/1"] + assert view.url == "modrim://workshop/mod/1" + assert not view._ctx.active_uuids + + +async def test_activation_does_nothing_without_an_installed_copy( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, _ = panel + await view.toggleActivation("1") + assert view._ctx.active_uuids == [] + assert catalog.downloaded == [] + + +async def test_teardown_disconnects_activation_and_blocks_toggles( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], +) -> None: + view, _, _ = panel + _, steam, _ = installed_mods + view._ctx.load(installed_mods, []) + await view.load() + await view.open_item("1", "mod") + notifications: list[bool] = [] + view.changed.connect(lambda: notifications.append(True)) + view.teardown() + view.teardown() + view._ctx.set_active([steam]) + await view.toggleActivation("1") + assert view._ctx.active_uuids == [steam] + assert not notifications + assert cast("dict[str, Any]", view.detail)["active"] is False + + +async def test_known_tag_options_accumulate_loaded_tags_but_not_version_tags( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, _ = panel + await asyncio.sleep(0) + catalog.mod_item = msgspec.structs.replace( + catalog.mod_item, tags=["Utility", "1.5"] + ) + catalog.collection_item = msgspec.structs.replace( + catalog.collection_item, tags=["Curated", "1.6"] + ) + catalog.dependency = msgspec.structs.replace( + catalog.dependency, tags=["Dependency", "1.5"] + ) + catalog._responses.clear() + await view.load() + assert view.tagOptions == ["All tags", "Curated", "Utility"] + await view.filter("utility", "1.5", "all", "popular", "Utility") + assert view.searchQuery == "utility" + assert view.tagOptions == ["All tags", "Curated", "Utility"] + await view.open_item("1", "mod") + assert view.tagOptions == ["All tags", "Curated", "Dependency", "Utility"] + await view.select_tab("Installed") + assert view.searchQuery == "utility" + + +async def test_back_walks_detail_history_before_returning_to_listing( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, _ = panel + await view.select_tab("Mods") + await view.open_item("1", "mod") + await view.open_item("2", "mod") + assert view.url == "modrim://workshop/mod/2" + assert view.backLabel == f"Back to {catalog.mod_item.title}" + + await view.back() + assert cast("dict[str, Any]", view.detail)["itemId"] == "1" + assert view.url == "modrim://workshop/mod/1" + assert view.backLabel == "Back to Mods" + + await view.back() + assert not view.hasDetail + assert view.url == "modrim://workshop/mods" + + +async def test_failed_back_keeps_the_previous_page_for_retry( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + view, catalog, _ = panel + await view.open_item("1", "mod") + await view.open_item("2", "mod") + catalog._responses.clear() + working = catalog.mod + + async def unavailable(id: str) -> CatalogMod | None: + raise CatalogError("Catalog unavailable", 503) + + monkeypatch.setattr(catalog, "mod", unavailable) + await view.back() + assert view.url == "modrim://workshop/mod/2" + assert view.backLabel == f"Back to {catalog.mod_item.title}" + + monkeypatch.setattr(catalog, "mod", working) + await view.back() + assert view.url == "modrim://workshop/mod/1" + + +async def test_routes_open_tabs_and_items_like_links( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, _, _ = panel + await view.open_item("1", "mod") + await view.open_item("2", "mod") + + await view.follow_route(("collection", "picked:test")) + assert view.url == "modrim://workshop/collection/picked%3Atest" + await view.back() + assert view.url == "modrim://workshop/mod/2" + + await view.follow_route(("installed",)) + assert view.tab == "Installed" + assert view.url == "modrim://workshop/installed" + + await view.follow_route(()) + assert view.url == "modrim://workshop/installed" + + +async def test_starred_collection_is_listed_in_favourites_until_unstarred( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, _, warnings = panel + view.window().resize(1100, 800) + view.window().show() + await view.follow_route(("collections",)) + await asyncio.sleep(0.5) + view._qml.grabFramebuffer() + await asyncio.sleep(0.2) + root = view._qml.rootObject() + assert root is not None + star = _find_item(cast("QQuickItem", root), "favouriteToggle") + assert star is not None + center = star.mapToScene(star.boundingRect().center()).toPoint() + QTest.mouseClick(view._qml, Qt.MouseButton.LeftButton, pos=center) + assert not view.hasDetail + + await view.follow_route(("favourites",)) + model = view.collections_model + assert view.url == "modrim://workshop/favourites" + assert model.count == 1 + favourite = next( + role + for role, name in model.roleNames().items() + if bytes(name.data()) == b"favourite" + ) + assert model.data(model.index(0), favourite) is True + + await view.open_item("picked:test", "collection") + assert cast("dict[str, Any]", view.detail)["favourite"] is True + view.toggleFavourite("picked:test") + assert cast("dict[str, Any]", view.detail)["favourite"] is False + await view.back() + assert view.tab == "Favourites" and model.count == 0 + assert not warnings, [warning.toString() for warning in warnings] + + +async def test_back_preserves_scroll_position_and_active_filters( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, _ = panel + many = [ + msgspec.structs.replace(catalog.mod_item, id=str(number)) + for number in range(40) + ] + + async def long_listing(query: CatalogQuery) -> CatalogPage[CatalogMod]: + return CatalogPage(many, len(many), None) + + catalog.mods = long_listing # type: ignore[method-assign] + view.window().resize(800, 600) + view.window().show() + await view.load() + await asyncio.sleep(0.2) + view._qml.grab() + await view.filter("test search", "1.5", "all", "popular", "Utility") + await asyncio.sleep(0.2) + view._qml.grab() + assert view.searchQuery == "test search" + + root = view._qml.rootObject() + assert root is not None + listing_scroll = root.findChild(QObject, "listingScroll") + assert listing_scroll is not None + listing_scroll.setProperty("width", 800) + listing_scroll.setProperty("height", 600) + listing_scroll.setProperty("contentY", 184) + view._qml.grab() + assert listing_scroll.property("contentY") == 184 + + await view.open_item("1", "mod") + assert view.hasDetail + + await view.back() + await asyncio.sleep(0.2) + view._qml.grab() + assert not view.hasDetail + assert view.tab == "Mods" + assert view.searchQuery == "test search" + assert listing_scroll.property("contentY") == 184 + + +async def test_downloaded_collection_activates_all_or_only_its_pack( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], + tmp_path: Path, +) -> None: + view, catalog, _ = panel + catalog.collection_complete = True + steamcmd, _, dependency = installed_mods + outside = AboutXmlMod( + name="Outside", + package_id=CaseInsensitiveStr("test.outside"), + provider_id="steam", + _mod_path=tmp_path / "steam" / "9", + ) + local = AboutXmlMod( + name="Local", + package_id=CaseInsensitiveStr("test.local"), + provider_id="local", + _mod_path=tmp_path / "local" / "Mine", + ) + view._ctx.load({**installed_mods, outside.uuid: outside, local.uuid: local}, []) + view._ctx.set_active([outside.uuid, local.uuid]) + await view.open_item("picked:test", "collection") + detail = cast("dict[str, Any]", view.detail) + assert detail["state"] == "installed" and detail["active"] is False + + await view.toggleCollection() + assert set(view._ctx.active_uuids) == { + steamcmd, + dependency, + outside.uuid, + local.uuid, + } + assert cast("dict[str, Any]", view.detail)["active"] is True + + await view.toggleCollection() + assert set(view._ctx.active_uuids) == {outside.uuid, local.uuid} + + await view.activateOnlyCollection() + assert set(view._ctx.active_uuids) == {steamcmd, dependency, local.uuid} + + +async def test_collection_deactivation_removes_every_active_copy( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], +) -> None: + view, catalog, _ = panel + catalog.collection_complete = True + view._ctx.load(installed_mods, list(installed_mods)) + await view.open_item("picked:test", "collection") + assert cast("dict[str, Any]", view.detail)["active"] is True + + await view.toggleCollection() + + assert view._ctx.active_uuids == [] + assert cast("dict[str, Any]", view.detail)["active"] is False + + +async def test_activate_only_collection_keeps_dependents_of_retained_duplicate( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], + monkeypatch: pytest.MonkeyPatch, +) -> None: + from PySide6.QtWidgets import QMessageBox + + view, catalog, _ = panel + catalog.collection_complete = True + steamcmd, steam, dependent = installed_mods + mod = cast("AboutXmlMod", installed_mods[dependent]) + mod.about_rules = BaseRules( + dependencies={ + CaseInsensitiveStr("test.mod1"): DependencyMod( + name="Dependency", package_id=CaseInsensitiveStr("test.mod1") + ) + } + ) + view._ctx.load(installed_mods, [steamcmd, steam, dependent]) + view._ctx.diagnostics_service.rebuild() + prompts: list[object] = [] + + async def accept_dependents(*args: object) -> tuple[int, None]: + prompts.append(args) + return QMessageBox.StandardButton.Yes, None + + monkeypatch.setattr( + "pxmodrim.ui.components.mod_activation.await_dialog", accept_dependents + ) + await view.open_item("picked:test", "collection") + await view.activateOnlyCollection() + + assert not prompts + assert set(view._ctx.active_uuids) == {steamcmd, dependent} + assert cast("dict[str, Any]", view.detail)["active"] is True + + +async def test_partly_downloaded_collection_has_no_activation( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], +) -> None: + view, catalog, _ = panel + catalog.collection_complete = True + steamcmd, _, _ = installed_mods + view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) + await view.open_item("picked:test", "collection") + assert cast("dict[str, Any]", view.detail)["state"] == "missing" + await view.toggleCollection() + await view.activateOnlyCollection() + assert view._ctx.active_uuids == [] + + +async def test_incomplete_collection_cannot_activate_only_its_available_members( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], + monkeypatch: pytest.MonkeyPatch, +) -> None: + view, catalog, _ = panel + view._ctx.load(installed_mods, list(installed_mods)) + active_before = view._ctx.active_uuids + + async def incomplete_plan( + mod_ids: list[str], collection_ids: list[str] + ) -> DownloadPlan: + return DownloadPlan([], ["1"], ["2"], [], {}, False) + + monkeypatch.setattr(catalog, "plan", incomplete_plan) + await view.open_item("picked:test", "collection") + assert cast("dict[str, Any]", view.detail)["state"] == "missing" + await view.toggleCollection() + await view.activateOnlyCollection() + assert view._ctx.active_uuids == active_before + + +async def test_installed_changes_during_fetch_are_loaded_after_it_finishes( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], + monkeypatch: pytest.MonkeyPatch, +) -> None: + view, catalog, _ = panel + await view.load() + catalog._ctx = view._ctx + steamcmd, _, _ = installed_mods + view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) + started, release = asyncio.Event(), asyncio.Event() + requested: list[list[str]] = [] + + async def installed() -> list[CatalogMod]: + ids = list(catalog._installed()) + requested.append(ids) + if len(requested) == 1: + started.set() + await release.wait() + items = {"1": catalog.mod_item, "2": catalog.dependency} + return [items[pid] for pid in ids] + + monkeypatch.setattr(catalog, "installed_with_updates", installed) + view._tab = "Installed" + task = asyncio.create_task(view.load()) + async with asyncio.timeout(3): + await started.wait() + view._ctx.load(installed_mods, []) + catalog._installed_index = None + await view._on_installed_changed(None) + release.set() + await task + assert requested == [["1"], ["1", "2"]] + assert {row["itemId"] for row in view.mods_model._rows} == {"1", "2"} + + +async def test_installed_changes_preserve_details_and_reload_when_going_back( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], + monkeypatch: pytest.MonkeyPatch, +) -> None: + view, catalog, _ = panel + catalog._ctx = view._ctx + steamcmd, _, _ = installed_mods + view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) + + async def installed() -> list[CatalogMod]: + items = {"1": catalog.mod_item, "2": catalog.dependency} + return [items[pid] for pid in catalog._installed()] + + monkeypatch.setattr(catalog, "installed_with_updates", installed) + await view.select_tab("Installed") + await view.open_item("1", "mod") + view._ctx.load(installed_mods, []) + catalog._installed_index = None + await view._on_installed_changed(None) + assert view.hasDetail and cast("dict[str, Any]", view.detail)["itemId"] == "1" + await view.back() + assert not view.hasDetail + assert {row["itemId"] for row in view.mods_model._rows} == {"1", "2"} + + +@pytest.mark.parametrize("failure", [False, True]) +async def test_download_plan_finishing_after_destruction_is_discarded( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + monkeypatch: pytest.MonkeyPatch, + failure: bool, +) -> None: + view, catalog, _ = panel + await view.load() + started, release = asyncio.Event(), asyncio.Event() + plan = catalog.plan + + async def delayed_plan( + mod_ids: list[str], collection_ids: list[str] + ) -> DownloadPlan: + started.set() + await release.wait() + if failure: + raise CatalogError("Catalog unavailable", 503) + return await plan(mod_ids, collection_ids) + + monkeypatch.setattr(catalog, "plan", delayed_plan) + task = asyncio.create_task(view._download(["1"], [])) + async with asyncio.timeout(3): + await started.wait() + view.teardown() + view._qml.setSource(QUrl()) + delete(view) + release.set() + await task + assert not catalog.downloaded + + +async def test_revisiting_installed_within_freshness_does_not_refetch( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, _ = panel + await asyncio.sleep(0) + fetches = 0 + mods = [catalog.mod_item] + + async def installed() -> list[CatalogMod]: + nonlocal fetches + fetches += 1 + return mods + + catalog.installed_with_updates = installed # type: ignore[method-assign] + for tab in ("Installed", "Mods", "Installed"): + await view.select_tab(tab) + assert fetches == 1 and view.mods_model.count == 1 + + +@pytest.mark.parametrize( + ("control", "tooltip_binding"), + [("PxButton", "ToolTip.text"), ("PxBadge", "tooltip")], +) +def test_card_controls_show_tooltips_lazily_on_hover( + qapp: object, + qtbot: QtBot, + tmp_path: Path, + control: str, + tooltip_binding: str, +) -> None: + owner = QWidget() + owner.resize(300, 160) + engine = create_qml_engine(owner) + theme = Theme(engine) + engine.rootContext().setContextProperty("Theme", theme) + controls = QUrl.fromLocalFile( + str(Path(__file__).parents[2] / "src/pxmodrim/ui/components/controls") + ).toString() + source = tmp_path / "Tooltips.qml" + source.write_text( + f'import QtQuick\nimport QtQuick.Controls\nimport "{controls}"\n' + "Rectangle {\n" + ' property string tipText: ""\n' + f" {control} {{\n" + ' objectName: "target"; x: 20; y: 20; text: "Test action"\n' + f" {tooltip_binding}: parent.tipText\n" + " }\n" + "}\n" + ) + widget = QQuickWidget(engine, owner) + widget.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) + widget.resize(300, 160) + widget.setSource(QUrl.fromLocalFile(str(source))) + owner.show() + widget.show() + root = widget.rootObject() + assert root is not None + target = root.findChild(QObject, "target") + assert target is not None + + def tooltip() -> QObject | None: + return next( + ( + obj + for obj in root.findChildren(QObject) + if obj.inherits("QQuickToolTip") + ), + None, + ) + + try: + qtbot.mouseMove(widget, QPoint(280, 140)) + assert tooltip() is None + root.setProperty("tipText", "Help for this action") + assert tooltip() is None + qtbot.mouseMove(widget, QPoint(30, 25)) + qtbot.waitUntil( + lambda: (tip := tooltip()) is not None and bool(tip.property("opened")), + timeout=3000, + ) + tip = tooltip() + assert tip is not None + assert tip.property("text") == "Help for this action" + assert tip.property("delay") == theme.tooltipDelay + assert tip.property("parent") == target + accessible = QAccessible.queryAccessibleInterface(target) + assert accessible is not None + assert accessible.text(QAccessible.Text.Name) == "Test action" + qtbot.mouseMove(widget, QPoint(280, 140)) + qtbot.waitUntil(lambda: tooltip() is None) + qtbot.mouseMove(widget, QPoint(30, 25)) + qtbot.waitUntil( + lambda: (tip := tooltip()) is not None and bool(tip.property("opened")), + timeout=3000, + ) + root.setProperty("tipText", "") + qtbot.waitUntil(lambda: tooltip() is None) + finally: + widget.setSource(QUrl()) + owner.close() + owner.deleteLater() + + +@pytest.mark.parametrize("kind", ["collection", "mod"]) +@pytest.mark.parametrize("long_description", [False, True]) +async def test_detail_section_layout_and_jump_navigation( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + monkeypatch: pytest.MonkeyPatch, + kind: str, + long_description: bool, +) -> None: + view, catalog, warnings = panel + members: list[CatalogMod | CatalogCollection] = [ + _mod(str(index)) for index in range(2, 14) + ] + description = "\n\n".join( + f"Paragraph {index}: a detailed explanation of this Workshop item." + for index in range(70 if long_description else 1) + ) + collection = msgspec.structs.replace( + catalog.collection_item, + description=description, + member_ids=[member.id for member in members], + member_count=len(members), + ) + + async def collection_detail(_: str) -> CollectionDetail: + return CollectionDetail(collection, members, [], True) + + catalog.mod_item = msgspec.structs.replace( + catalog.mod_item, + description=description, + description_format="text", + dependencies=[member.id for member in members], + ) + + async def mod_detail(item_id: str) -> CatalogMod | None: + return next( + (mod for mod in [catalog.mod_item, *members] if mod.id == item_id), None + ) + + monkeypatch.setattr(catalog, "collection", collection_detail) + monkeypatch.setattr(catalog, "mod", mod_detail) + view.setParent(None) + view.resize(1300, 1000) + view.show() + try: + await asyncio.sleep(0) + await view.load() + await view.open_item(collection.id if kind == "collection" else "1", kind) + await asyncio.sleep(0.05) + view._qml.grabFramebuffer() + root = cast("QQuickItem", view._qml.rootObject()) + desc = _find_item(root, "detailDescription") + members_section = _find_item(root, "detailMembers") + heading = _find_item(root, "membersHeading") + grid = _find_item(root, "membersGrid") + scroll = _find_item(root, "detailScroll") + jump = _find_item(root, "jumpToMembers") + back = _find_item(root, "detailBackToTop") + assert all( + item is not None + for item in (desc, members_section, heading, grid, scroll, jump, back) + ) + assert desc is not None and members_section is not None + assert heading is not None and scroll is not None and grid is not None + assert jump is not None and back is not None + if kind == "collection": + assert members_section.y() >= desc.y() + desc.height() + assert grid.property("columns") == 3 + else: + assert members_section.y() == pytest.approx(desc.y()) + assert members_section.x() >= desc.x() + desc.width() + assert grid.property("columns") == 1 + assert jump.isVisible() is long_description + assert not back.isVisible() + flickable = cast("QQuickItem", scroll.property("contentItem")) + if long_description: + content = cast("QQuickItem", flickable.property("contentItem")) + target = heading.mapToItem(content, heading.boundingRect().topLeft()).y() + maximum = max(0, flickable.property("contentHeight") - flickable.height()) + QTest.mouseClick( + view._qml, + Qt.MouseButton.LeftButton, + pos=jump.mapToScene(jump.boundingRect().center()).toPoint(), + ) + await asyncio.sleep(0.05) + view._qml.grabFramebuffer() + assert flickable.property("contentY") == pytest.approx( + max(0, min(target, maximum)) + ) + assert flickable.property("contentY") > 0 + assert back.isVisible() + QTest.mouseClick( + view._qml, + Qt.MouseButton.LeftButton, + pos=back.mapToScene(back.boundingRect().center()).toPoint(), + ) + await asyncio.sleep(0.05) + assert flickable.property("contentY") == 0 + assert not back.isVisible() + assert not warnings, [warning.toString() for warning in warnings] + finally: + view.close() + + +@pytest.mark.parametrize( + ("tags", "versions", "visible"), + [ + (["Mod"], ["1.6"], True), + ([], ["1.6"], True), + (["Mod"], [], True), + ([], [], False), + ], +) +async def test_detail_metadata_preserves_tags_and_versions( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + tags: list[str], + versions: list[str], + visible: bool, +) -> None: + view, catalog, warnings = panel + catalog.collection_item = msgspec.structs.replace( + catalog.collection_item, tags=tags, supported_versions=versions + ) + await asyncio.sleep(0) + await view.load() + await view.open_item(catalog.collection_item.id, "collection") + root = cast("QQuickItem", view._qml.rootObject()) + metadata = _find_item(root, "detailTags") + assert metadata is not None + assert metadata.property("visible") is visible + displayed = metadata.property("text") + for value in tags + versions: + assert value in displayed + assert not warnings, [warning.toString() for warning in warnings] diff --git a/tests/test_workshop/conftest.py b/tests/test_workshop/conftest.py new file mode 100644 index 0000000..525171e --- /dev/null +++ b/tests/test_workshop/conftest.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Any + +import pytest + + +@pytest.fixture +def mod_payload() -> dict[str, Any]: + return { + "id": "2009463077", + "kind": "mod", + "source": "steam", + "title": "Harmony", + "author": { + "id": "76561198000000000", + "name": "Author", + "profile_url": "https://steamcommunity.com/profiles/76561198000000000", + }, + "description": "[b]Untrusted[/b] ", + "description_format": "bbcode", + "preview_url": "https://images.example/harmony.jpg", + "previews": [ + {"type": "image", "url": "https://images.example/harmony.jpg"}, + {"type": "video", "url": "https://www.youtube.com/watch?v=example"}, + ], + "workshop_url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2009463077", + "tags": ["1.6", "Libraries"], + "supported_versions": ["1.5", "1.6"], + "created_at": 1600000000, + "updated_at": 1700000000, + "file_size": "18446744073709551615", + "subscriptions": 12345, + "votes": {"up": 9, "down": 1, "positive_percent": 90}, + "dependencies": ["818773962"], + "package_id": "brrainz.harmony", + "incompatible": False, + } + + +@pytest.fixture +def collection_payload() -> dict[str, Any]: + return { + "id": "picked:starter", + "kind": "collection", + "source": "picked", + "steam_id": None, + "title": "Starter pack", + "author": {"id": None, "name": "PxModRim", "profile_url": None}, + "description": "Plain text", + "description_format": "text", + "preview_url": None, + "previews": [], + "workshop_url": None, + "tags": ["Quality of Life"], + "supported_versions": ["1.6"], + "created_at": 1600000000, + "updated_at": 1700000000, + "member_ids": ["2009463077"], + "member_count": 1, + "member_previews": [], + "total_size": None, + "no_common_version": False, + } diff --git a/tests/test_workshop/test_catalog.py b/tests/test_workshop/test_catalog.py new file mode 100644 index 0000000..e28d4f6 --- /dev/null +++ b/tests/test_workshop/test_catalog.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +import httpx +import msgspec +import pytest + +from pxmodrim.core.config import AppConfig, ConfigService +from pxmodrim.core.context import CoreContext +from pxmodrim.core.downloads.manager import download_manager +from pxmodrim.core.downloads.steam import SteamDownloader, WorkshopSyncState +from pxmodrim.core.downloads.types import DownloadResult +from pxmodrim.core.mod_service import ModService +from pxmodrim.core.models.metadata.structures import ListedMod +from pxmodrim.core.workshop import ( + CatalogClient, + CatalogError, + CatalogMod, + CatalogSettings, + DownloadPlan, + WorkshopCatalog, +) + + +def make_context(tmp_path: Path) -> tuple[CoreContext, SteamDownloader]: + config = AppConfig() + config.paths.local = str(tmp_path / "Mods") + config.paths.workshop = str(tmp_path / "Workshop") + ctx = CoreContext(config, ConfigService(tmp_path)) + ctx._mod_service = ModService(ctx, []) + downloader = SteamDownloader() + downloader.setup(ctx) + downloader._sync = WorkshopSyncState(synced={"1": 200, "2": 50}) + mods = [ + ListedMod(_mod_path=tmp_path / "Mods" / "1"), + ListedMod(_mod_path=tmp_path / "Mods" / "2"), + ListedMod(_mod_path=tmp_path / "Workshop" / "3", provider_id="workshop"), + ListedMod(_mod_path=tmp_path / "Mods" / "4"), + ListedMod(_mod_path=tmp_path / "Mods" / "NotWorkshop"), + ] + ctx.load({mod.uuid: mod for mod in mods}, [mods[0].uuid]) + return ctx, downloader + + +def decode_mod( + payload: dict[str, Any], id: str, updated: int | None = 100 +) -> CatalogMod: + return msgspec.json.decode( + msgspec.json.encode({**payload, "id": id, "updated_at": updated}), + type=CatalogMod, + ) + + +async def test_install_state_uses_sync_not_directory_mtime( + tmp_path: Path, mod_payload: dict[str, Any] +) -> None: + ctx, _ = make_context(tmp_path) + catalog = WorkshopCatalog() + catalog.setup(ctx) + assert catalog.install_state(decode_mod(mod_payload, "1")) == "installed" + assert catalog.install_state(decode_mod(mod_payload, "2")) == "outdated" + assert catalog.install_state(decode_mod(mod_payload, "3")) == "installed" + assert catalog.install_state(decode_mod(mod_payload, "4")) == "installed" + assert catalog.install_state(decode_mod(mod_payload, "5")) == "missing" + assert catalog.install_state(decode_mod(mod_payload, "2", None)) == "installed" + assert catalog.install_state(decode_mod(mod_payload, "2", 50)) == "installed" + await catalog.shutdown() + + +async def test_plan_keeps_dependency_order_and_incomplete_flags( + tmp_path: Path, mod_payload: dict[str, Any], collection_payload: dict[str, Any] +) -> None: + ctx, _ = make_context(tmp_path) + active_before = ctx.active_uuids + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "roots": ["5", "picked:starter"], + "items": { + **{ + id: { + **mod_payload, + "id": id, + "updated_at": 100, + "title": f"Title {id}", + } + for id in ["1", "2", "3", "4", "5"] + }, + "picked:starter": collection_payload, + }, + "mod_ids": ["1", "2", "3", "4", "5"], + "unavailable_ids": ["99"], + "incomplete_collection_ids": ["picked:starter"], + "is_complete": False, + }, + ) + + catalog = WorkshopCatalog( + lambda url: CatalogClient(url, httpx.MockTransport(handler)) + ) + catalog.setup(ctx) + try: + plan = await catalog.plan(["5"], ["picked:starter"]) + assert plan.to_download == ["2", "5"] + assert plan.already_current == ["1", "3", "4"] + assert plan.unavailable_ids == ["99"] + assert plan.incomplete_collection_ids == ["picked:starter"] + assert not plan.is_complete + assert plan.titles["2"] == "Title 2" + assert ctx.active_uuids == active_before + assert msgspec.json.decode(requests[0].content) == { + "ids": ["5"], + "collection_ids": ["picked:starter"], + } + finally: + await catalog.shutdown() + + +async def test_installed_metadata_uses_batch( + tmp_path: Path, mod_payload: dict[str, Any] +) -> None: + ctx, _ = make_context(tmp_path) + requested: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requested.extend( + msgspec.json.decode(request.content, type=dict[str, list[str]])["ids"] + ) + return httpx.Response( + 200, + json={ + "items": [{**mod_payload, "id": "2", "updated_at": 100}], + "unavailable_ids": ["1", "3", "4"], + }, + ) + + catalog = WorkshopCatalog( + lambda url: CatalogClient(url, httpx.MockTransport(handler)) + ) + catalog.setup(ctx) + try: + installed = await catalog.installed_with_updates() + assert requested == ["1", "2", "3", "4"] + assert catalog.install_state(installed[0]) == "outdated" + finally: + await catalog.shutdown() + + +async def test_download_delegates_without_activation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + ctx, _ = make_context(tmp_path) + catalog = WorkshopCatalog() + catalog.setup(ctx) + calls: list[list[str]] = [] + result = DownloadResult(succeeded=["2"], failed=[]) + + async def download(ids: list[str]) -> DownloadResult: + calls.append(ids) + return result + + monkeypatch.setattr(download_manager(ctx), "download_mods", download) + active_before = ctx.active_uuids + try: + plan = DownloadPlan(["2"], ["1"], [], [], {"2": "Mod"}, True) + assert await catalog.download(plan) is result + assert calls == [["2"]] + assert ctx.active_uuids == active_before + finally: + await catalog.shutdown() + + +async def test_empty_url_disabled_without_constructing_client(tmp_path: Path) -> None: + ctx, _ = make_context(tmp_path) + clients: list[str] = [] + + def factory(url: str) -> CatalogClient: + clients.append(url) + raise AssertionError("Disabled catalog must not create a client") + + catalog = WorkshopCatalog(factory) + catalog.setup(ctx) + catalog.set_base_url("") + try: + assert not catalog.configured + for operation in [ + catalog.discover(), + catalog.installed_with_updates(), + catalog.plan(["1"], []), + ]: + with pytest.raises(CatalogError, match="disabled"): + await operation + assert clients == [] + finally: + await catalog.shutdown() + + +async def test_configuration_and_installed_events( + tmp_path: Path, mod_payload: dict[str, Any] +) -> None: + ctx, _ = make_context(tmp_path) + transports: list[httpx.AsyncClient] = [] + urls: list[str] = [] + + def factory(url: str) -> CatalogClient: + urls.append(url) + client = CatalogClient( + url, httpx.MockTransport(lambda _: httpx.Response(200, json=mod_payload)) + ) + transports.append(client._http) + return client + + catalog = WorkshopCatalog(factory) + catalog.setup(ctx) + changed: list[str] = [] + installed: list[None] = [] + catalog.catalog_url_changed.connect(changed.append) + catalog.installed_changed.connect(installed.append) + await catalog.mod("2009463077") + catalog.set_base_url(" https://other.example/ ") + assert catalog.settings.value.url == "https://other.example" + stored = ConfigService(tmp_path).load_existing( + "plugins/workshop_catalog.json", CatalogSettings + ) + assert stored == CatalogSettings("https://other.example") + await catalog.mod("2009463077") + catalog.set_base_url("") + assert not catalog.configured + with pytest.raises(CatalogError, match="disabled"): + await catalog.discover() + ctx.mod_service.mods_changed.emit(None) + download_manager(ctx).download_finished.emit(DownloadResult([], [])) + assert installed == [None, None] + assert changed == ["https://other.example", ""] + assert urls == ["https://api.modrim.pyxiion.dev", "https://other.example"] + await catalog.shutdown() + assert all(transport.is_closed for transport in transports) + ctx.mod_service.mods_changed.emit(None) + assert installed == [None, None] + + +async def test_cached_serves_remembered_value_before_refreshing( + tmp_path: Path, +) -> None: + ctx, _ = make_context(tmp_path) + catalog = WorkshopCatalog() + catalog.setup(ctx) + values = iter(["first", "second", "second"]) + calls = 0 + + async def produce() -> str: + nonlocal calls + calls += 1 + return next(values) + + async def seen(fresh_for: float) -> list[str]: + return [value async for value in catalog.cached("k", produce, fresh_for)] + + try: + assert await seen(300) == ["first"] + assert await seen(300) == ["first"] and calls == 1 + assert await seen(0) == ["first", "second"] and calls == 2 + assert await seen(0) == ["second"] and calls == 3 + finally: + await catalog.shutdown() + + +async def test_enqueue_runs_batches_in_order_after_the_running_download( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + ctx, downloader = make_context(tmp_path) + catalog = WorkshopCatalog() + catalog.setup(ctx) + manager = download_manager(ctx) + manager.register(downloader) + calls: list[list[str]] = [] + busy = True + + async def download(ids: list[str]) -> DownloadResult: + calls.append(ids) + return DownloadResult(succeeded=ids, failed=[]) + + monkeypatch.setattr(manager, "download_mods", download) + monkeypatch.setattr(type(manager), "is_downloading", property(lambda _self: busy)) + try: + catalog.enqueue(DownloadPlan(["5", "6"], [], [], [], {}, True)) + catalog.enqueue(DownloadPlan(["6", "7"], [], [], [], {}, True)) + assert catalog.queued_ids == {"5", "6", "7"} + await asyncio.sleep(0.05) + assert calls == [] + busy = False + manager.busy_changed.emit(False) + await asyncio.wait_for(catalog._runner, 2) # type: ignore[arg-type] + assert calls == [["5", "6"], ["7"]] and not catalog.queued_ids + finally: + await catalog.shutdown() + + +async def test_cached_values_survive_a_restart_and_corrupt_files_are_ignored( + tmp_path: Path, +) -> None: + ctx, _ = make_context(tmp_path) + + async def produce() -> list[int]: + return [1, 2] + + async def must_not_run() -> list[int]: + raise AssertionError("A fresh value on disk must not hit the catalog") + + first = WorkshopCatalog() + first.setup(ctx) + assert [v async for v in first.cached("k", produce, value_type=list[int])] == [ + [1, 2] + ] + + second = WorkshopCatalog() + second.setup(ctx) + assert [ + v async for v in second.cached("k", must_not_run, value_type=list[int]) + ] == [[1, 2]] + + for file in (tmp_path / "workshop-cache").glob("*.json"): + file.write_text("{not json") + third = WorkshopCatalog() + third.setup(ctx) + assert [v async for v in third.cached("k", produce, value_type=list[int])] == [ + [1, 2] + ] + + +async def test_equal_cache_refresh_keeps_the_displayed_mods_and_refreshes_age( + tmp_path: Path, mod_payload: dict[str, Any] +) -> None: + ctx, _ = make_context(tmp_path) + catalog = WorkshopCatalog() + catalog.setup(ctx) + calls = 0 + + async def produce() -> list[CatalogMod]: + nonlocal calls + calls += 1 + return [decode_mod(mod_payload, "1")] + + try: + initial = await anext(catalog.cached("installed", produce)) + refreshed = [ + value async for value in catalog.cached("installed", produce, fresh_for=0) + ] + assert len(refreshed) == 1 and refreshed[0] is initial + current = await anext(catalog.cached("installed", produce)) + assert current is initial and calls == 2 + finally: + await catalog.shutdown() + + +async def test_disk_cache_is_reused_in_memory_after_first_paint( + tmp_path: Path, mod_payload: dict[str, Any] +) -> None: + ctx, _ = make_context(tmp_path) + + async def produce() -> list[CatalogMod]: + return [decode_mod(mod_payload, "1")] + + async def must_not_run() -> list[CatalogMod]: + raise AssertionError("A fresh cached response must not hit the catalog") + + first = WorkshopCatalog() + first.setup(ctx) + second = WorkshopCatalog() + second.setup(ctx) + try: + await anext(first.cached("installed", produce, value_type=list[CatalogMod])) + displayed = await anext( + second.cached("installed", must_not_run, value_type=list[CatalogMod]) + ) + for file in (tmp_path / "workshop-cache").glob("*.json"): + file.write_text("{not json") + current = await anext( + second.cached("installed", must_not_run, value_type=list[CatalogMod]) + ) + assert current is displayed + finally: + await first.shutdown() + await second.shutdown() + + +def test_catalog_url_moves_out_of_the_old_shared_config(tmp_path: Path) -> None: + ctx, _ = make_context(tmp_path) + (tmp_path / "config.json").write_bytes( + b'{"schema_version":1,"workshop_catalog_url":"http://localhost:8787"}' + ) + catalog = WorkshopCatalog() + catalog.setup(ctx) + assert catalog.settings.value.url == "http://localhost:8787" + stored = ConfigService(tmp_path).load_existing( + "plugins/workshop_catalog.json", CatalogSettings + ) + assert stored == CatalogSettings("http://localhost:8787") diff --git a/tests/test_workshop/test_client.py b/tests/test_workshop/test_client.py new file mode 100644 index 0000000..dca6154 --- /dev/null +++ b/tests/test_workshop/test_client.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +from typing import Any + +import httpx +import msgspec +import pytest + +from pxmodrim.core.workshop import ( + CatalogClient, + CatalogCollection, + CatalogError, + CatalogMod, + CatalogQuery, + WorkshopCatalog, +) +from pxmodrim.ui.plugins.workshop.details import fetch_detail + + +async def test_decodes_every_endpoint( + mod_payload: dict[str, Any], collection_payload: dict[str, Any] +) -> None: + mod_page = {"items": [mod_payload], "total": 1, "next_cursor": "next-page"} + collection_page = {"items": [collection_payload], "total": 1, "next_cursor": None} + detail = { + "collection": collection_payload, + "members": [mod_payload], + "unavailable_ids": ["99"], + "is_complete": False, + } + steam_collection = { + **collection_payload, + "id": "steam:123", + "source": "steam", + "steam_id": "123", + "description_format": "bbcode", + "workshop_url": "https://steamcommunity.com/sharedfiles/filedetails/?id=123", + "member_ids": ["2009463077", "456", "99"], + "member_count": 3, + "member_previews": ["https://images.example.test/a.png"], + } + nested_collection = { + **steam_collection, + "id": "steam:456", + "steam_id": "456", + "member_ids": ["2009463077"], + "member_count": 1, + "member_previews": [], + } + steam_detail = { + **detail, + "collection": steam_collection, + "members": [mod_payload, nested_collection], + } + resolution = { + "roots": ["picked:starter"], + "items": {"2009463077": mod_payload, "picked:starter": collection_payload}, + "mod_ids": ["2009463077"], + "unavailable_ids": ["99"], + "incomplete_collection_ids": ["picked:starter"], + "is_complete": False, + } + responses = { + "/catalog/discover": {"mods": mod_page, "collections": collection_page}, + "/catalog/mods": mod_page, + "/catalog/collections": collection_page, + "/catalog/mods/2009463077": mod_payload, + "/catalog/collections/picked/starter": detail, + "/catalog/collections/steam/123": steam_detail, + "/catalog/resolve": resolution, + } + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=responses[request.url.path]) + + client = CatalogClient("https://catalog.example/", httpx.MockTransport(handler)) + try: + discover = await client.discover() + assert discover.mods.items[0].file_size == "18446744073709551615" + assert discover.mods.items[0].votes is not None + assert discover.mods.items[0].votes.positive_percent == 90 + assert discover.mods.items[0].previews[1].type == "video" + assert discover.collections.items[0].description_format == "text" + q = CatalogQuery("quality", "Libraries", "1.6", "updated", 50, "next", "picked") + page = await client.mods(q) + assert page.next_cursor == "next-page" + assert dict(requests[-1].url.params) == { + "q": "quality", + "tag": "Libraries", + "version": "1.6", + "sort": "updated", + "limit": "50", + "cursor": "next", + "source": "picked", + } + assert (await client.collections(q)).items[0].id == "picked:starter" + mod = await client.mod("2009463077") + assert mod is not None and mod.description == mod_payload["description"] + assert mod.kind == "mod" + for id in ["picked:starter", "steam:123"]: + collection = await client.collection(id) + assert collection is not None + assert isinstance(collection.members[0], CatalogMod) + if id == "steam:123": + assert isinstance(collection.members[1], CatalogCollection) + assert collection.unavailable_ids == ["99"] + assert not collection.is_complete + resolved = await client.resolve([], ["picked:starter"]) + assert resolved.roots == ["picked:starter"] + assert resolved.mod_ids == ["2009463077"] + assert isinstance(resolved.items["picked:starter"], CatalogCollection) + assert resolved.incomplete_collection_ids == ["picked:starter"] + assert not resolved.is_complete + assert msgspec.json.decode(requests[-1].content) == { + "ids": [], + "collection_ids": ["picked:starter"], + } + finally: + await client.shutdown() + + +@pytest.mark.parametrize("status", [400, 401, 500, 502, 503]) +@pytest.mark.parametrize("json_body", [True, False]) +async def test_error_statuses_are_never_empty_results( + status: int, json_body: bool +) -> None: + def handler(_: httpx.Request) -> httpx.Response: + if json_body: + return httpx.Response( + status, json={"error": {"message": "Catalog unavailable"}} + ) + return httpx.Response(status, text="Upstream failure") + + client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) + try: + with pytest.raises(CatalogError) as exc: + await client.mod("123") + assert exc.value.status == status + assert exc.value.message == str(exc.value) + if json_body: + assert exc.value.message == "Catalog unavailable" + else: + assert f"HTTP {status}" in exc.value.message + assert "" not in exc.value.message + finally: + await client.shutdown() + + +@pytest.mark.parametrize("kind", ["mod", "collection"]) +@pytest.mark.parametrize("json_body", [True, False]) +async def test_optional_details_return_none_only_for_404( + kind: str, json_body: bool +) -> None: + def handler(_: httpx.Request) -> httpx.Response: + if json_body: + return httpx.Response(404, json={"error": {"message": "Item unavailable"}}) + return httpx.Response(404, text="Not found") + + client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) + try: + if kind == "mod": + assert await client.mod("123") is None + else: + assert await client.collection("steam:123") is None + finally: + await client.shutdown() + + +@pytest.mark.parametrize("kind", ["mod", "collection"]) +async def test_optional_details_keep_server_errors(kind: str) -> None: + client = CatalogClient( + "https://catalog.example", + httpx.MockTransport(lambda _: httpx.Response(500, text="Server error")), + ) + try: + with pytest.raises(CatalogError) as exc: + if kind == "mod": + await client.mod("123") + else: + await client.collection("steam:123") + assert exc.value.status == 500 + finally: + await client.shutdown() + + +@pytest.mark.parametrize("kind", ["mod", "collection"]) +@pytest.mark.parametrize("failure", ["network", "decoding"]) +async def test_optional_details_keep_network_and_decoding_errors( + kind: str, failure: str +) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if failure == "network": + raise httpx.ConnectError("Disconnected", request=request) + return httpx.Response(200, content=b"not-json") + + client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) + try: + with pytest.raises(CatalogError) as exc: + if kind == "mod": + await client.mod("123") + else: + await client.collection("steam:123") + assert exc.value.status == (None if failure == "network" else 200) + finally: + await client.shutdown() + + +@pytest.mark.parametrize("endpoint", ["mods", "collections", "batch", "resolve"]) +async def test_non_optional_endpoints_keep_404_errors(endpoint: str) -> None: + client = CatalogClient( + "https://catalog.example", + httpx.MockTransport(lambda _: httpx.Response(404)), + ) + try: + with pytest.raises(CatalogError) as exc: + if endpoint == "mods": + await client.mods(CatalogQuery()) + elif endpoint == "collections": + await client.collections(CatalogQuery()) + elif endpoint == "batch": + await client.mods_batch(["123"]) + else: + await client.resolve(["123"], []) + assert exc.value.status == 404 + finally: + await client.shutdown() + + +async def test_fetch_detail_preserves_parent_with_unavailable_dependency( + mod_payload: dict[str, Any], +) -> None: + requested: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requested.append(request.url.path) + if request.url.path == f"/catalog/mods/{mod_payload['id']}": + return httpx.Response(200, json=mod_payload) + assert request.url.path == "/catalog/mods/818773962" + return httpx.Response(404, json={"error": {"message": "Item unavailable"}}) + + catalog = WorkshopCatalog( + lambda url: CatalogClient(url, httpx.MockTransport(handler)), + ) + try: + detail = await fetch_detail(catalog, mod_payload["id"], "mod") + assert detail.item.id == mod_payload["id"] + assert detail.item.title == mod_payload["title"] + assert detail.members == [] + assert detail.warning == "Unavailable dependencies: 818773962" + assert not detail.complete + assert requested == [ + f"/catalog/mods/{mod_payload['id']}", + "/catalog/mods/818773962", + ] + finally: + await catalog.shutdown() + + +@pytest.mark.parametrize( + "invalid", ["numeric_id", "bad_kind", "missing_field", "not_json"] +) +async def test_strict_wire_validation( + mod_payload: dict[str, Any], invalid: str +) -> None: + if invalid == "numeric_id": + mod_payload["id"] = 123 + elif invalid == "bad_kind": + mod_payload["kind"] = "collection" + elif invalid == "missing_field": + del mod_payload["title"] + + def handler(_: httpx.Request) -> httpx.Response: + return ( + httpx.Response(200, content=b"not-json") + if invalid == "not_json" + else httpx.Response(200, json=mod_payload) + ) + + client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) + try: + with pytest.raises(CatalogError, match="invalid response"): + await client.mod("123") + finally: + await client.shutdown() + + +async def test_chunks_large_batch(mod_payload: dict[str, Any]) -> None: + batches: list[list[str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + ids = msgspec.json.decode(request.content, type=dict[str, list[str]])["ids"] + batches.append(ids) + return httpx.Response( + 200, + json={ + "items": [{**mod_payload, "id": id} for id in ids if id != "105"], + "unavailable_ids": ["105"] if "105" in ids else [], + }, + ) + + client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) + ids = [str(id) for id in range(1, 206)] + try: + items, unavailable = await client.mods_batch(ids) + assert [len(batch) for batch in batches] == [100, 100, 5] + assert [item.id for item in items] == [id for id in ids if id != "105"] + assert unavailable == ["105"] + assert await client.mods_batch([]) == ([], []) + finally: + await client.shutdown() + + +async def test_transport_failure_is_catalog_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("offline", request=request) + + client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) + try: + with pytest.raises(CatalogError, match="Cannot reach") as exc: + await client.discover() + assert exc.value.status is None + finally: + await client.shutdown() + + +async def test_empty_url_does_not_use_transport() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200) + + client = CatalogClient("", httpx.MockTransport(handler)) + try: + with pytest.raises(CatalogError, match="disabled"): + await client.discover() + assert requests == [] + finally: + await client.shutdown() + + +async def test_resolve_chunks_update_all_and_deduplicates_dependencies( + mod_payload: dict[str, Any], +) -> None: + requests: list[dict[str, list[str]]] = [] + ids = [str(id) for id in range(1, 104)] + collections = [f"picked:pack-{index}" for index in range(11)] + + def handler(request: httpx.Request) -> httpx.Response: + payload = msgspec.json.decode(request.content, type=dict[str, list[str]]) + requests.append(payload) + mod_ids = ["999", *payload["ids"]] + return httpx.Response( + 200, + json={ + "roots": [*payload["ids"], *payload["collection_ids"]], + "items": { + id: { + **mod_payload, + "id": id, + "dependencies": [] if id == "999" else ["999"], + } + for id in mod_ids + }, + "mod_ids": mod_ids, + "unavailable_ids": ["404"] if len(requests) == 2 else [], + "incomplete_collection_ids": ( + ["picked:pack-10"] if len(requests) == 2 else [] + ), + "is_complete": len(requests) != 2, + }, + ) + + client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) + try: + resolved = await client.resolve(ids, collections) + assert [len(request["ids"]) for request in requests] == [50, 50, 3] + assert [len(request["collection_ids"]) for request in requests] == [10, 1, 0] + assert set(resolved.roots) == set(ids + collections) + assert resolved.mod_ids == ["999", *ids] + assert resolved.unavailable_ids == ["404"] + assert resolved.incomplete_collection_ids == ["picked:pack-10"] + assert not resolved.is_complete + finally: + await client.shutdown() diff --git a/tests/test_workshop/test_favourites.py b/tests/test_workshop/test_favourites.py new file mode 100644 index 0000000..2e290c6 --- /dev/null +++ b/tests/test_workshop/test_favourites.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import msgspec + +from pxmodrim.core.config import ConfigService +from pxmodrim.core.workshop import Author, CatalogCollection +from pxmodrim.core.workshop.favourites import FavouriteCollections + + +def _collection(cid: str, title: str = "Pack") -> CatalogCollection: + return CatalogCollection( + id=cid, + source="steam", + steam_id=cid.partition(":")[2], + title=title, + author=Author(None, "Curator", None), + description="", + description_format="text", + preview_url=None, + previews=[], + workshop_url=None, + tags=[], + supported_versions=["1.6"], + created_at=None, + updated_at=None, + member_ids=["1"], + member_count=1, + ) + + +def test_favourites_persist_newest_first_and_survive_restart( + config_service: ConfigService, +) -> None: + favourites = FavouriteCollections(config_service) + first, second = _collection("steam:1"), _collection("steam:2") + favourites.set(first, True) + favourites.set(second, True) + favourites.set(second, True) + + reloaded = FavouriteCollections(config_service) + assert [c.id for c in reloaded.items] == ["steam:2", "steam:1"] + assert "steam:1" in reloaded + + reloaded.set(first, False) + assert [c.id for c in FavouriteCollections(config_service).items] == ["steam:2"] + assert "steam:1" not in reloaded + + +def test_remember_refreshes_only_starred_snapshots( + config_service: ConfigService, +) -> None: + favourites = FavouriteCollections(config_service) + favourites.set(_collection("steam:1", "Old title"), True) + saves: list[object] = [] + favourites.changed.connect(saves.append) + + favourites.remember(_collection("steam:1", "Old title")) + favourites.remember(_collection("steam:2", "Not starred")) + assert saves == [] + + favourites.remember(_collection("steam:1", "New title")) + reloaded = FavouriteCollections(config_service) + assert [c.title for c in reloaded.items] == ["New title"] + assert ( + msgspec.json.decode( + ( + config_service.config_dir / "plugins/workshop_favourites.json" + ).read_bytes() + )["collections"][0]["id"] + == "steam:1" + ) diff --git a/tests/test_workshop/test_types.py b/tests/test_workshop/test_types.py new file mode 100644 index 0000000..5104d39 --- /dev/null +++ b/tests/test_workshop/test_types.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import msgspec +import pytest + +from pxmodrim.core.config import AppConfig, ConfigService +from pxmodrim.core.workshop import ( + CatalogCollection, + CatalogMod, + CatalogPage, + CatalogQuery, +) + + +def test_mod_null_fields_are_not_invented(mod_payload: dict[str, Any]) -> None: + for key in [ + "preview_url", + "created_at", + "updated_at", + "file_size", + "subscriptions", + "votes", + "package_id", + ]: + mod_payload[key] = None + mod_payload["author"] = { + "id": "76561198000000000", + "name": None, + "profile_url": None, + } + mod = msgspec.json.decode(msgspec.json.encode(mod_payload), type=CatalogMod) + assert mod.author.name is None + assert mod.votes is None + assert mod.updated_at is None + assert mod.file_size is None + assert msgspec.json.decode(msgspec.json.encode(mod)) == mod_payload + with pytest.raises(AttributeError): + cast(Any, mod).title = "Changed" + + +def test_steam_collection_and_empty_page(collection_payload: dict[str, Any]) -> None: + collection_payload.update( + { + "id": "steam:123", + "source": "steam", + "steam_id": "123", + "description_format": "bbcode", + "workshop_url": "https://steamcommunity.com/sharedfiles/filedetails/?id=123", + "member_ids": ["2009463077", "steam:456"], + "member_count": 2, + "member_previews": ["https://images.example.test/a.png"], + } + ) + collection = msgspec.json.decode( + msgspec.json.encode(collection_payload), type=CatalogCollection + ) + assert collection.kind == "collection" + assert collection.member_ids == ["2009463077", "steam:456"] + assert msgspec.json.decode(msgspec.json.encode(collection)) == collection_payload + page = msgspec.json.decode( + b'{"items":[],"total":0,"next_cursor":null}', type=CatalogPage[CatalogMod] + ) + assert page.items == [] and page.total == 0 and page.next_cursor is None + + +def test_config_default_loads_existing_files_and_persists_override( + tmp_path: Path, +) -> None: + service = ConfigService(tmp_path) + (tmp_path / "config.json").write_bytes(b'{"schema_version":1,"max_snapshots":3}') + config = service.load("config.json", AppConfig) + assert config.log_upload_endpoint == "https://paste.rs/" + assert config.max_snapshots == 3 + config.log_upload_endpoint = "" + service.save("config.json", config) + assert service.load("config.json", AppConfig).log_upload_endpoint == "" + + +def test_query_defaults_and_frozen_contract() -> None: + query = CatalogQuery() + assert ( + query.query == "" + and query.sort == "popular" + and query.limit == 24 + and query.source == "all" + ) + assert query.tag is None and query.version is None and query.cursor is None + with pytest.raises(AttributeError): + cast(Any, query).limit = 50 diff --git a/uv.lock b/uv.lock index 32cdad9..5d1ee00 100644 --- a/uv.lock +++ b/uv.lock @@ -224,7 +224,7 @@ wheels = [ [[package]] name = "pxmodrim" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" },