From 9d514cf87056e59e8956837b7897c1b5231e2405 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Mon, 5 Oct 2026 21:26:51 +0300 Subject: [PATCH 01/69] design(workshop): draft native browser UX mockup --- docs/mockups/native-mod-catalog.html | 388 +++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 docs/mockups/native-mod-catalog.html diff --git a/docs/mockups/native-mod-catalog.html b/docs/mockups/native-mod-catalog.html new file mode 100644 index 0000000..71e1db9 --- /dev/null +++ b/docs/mockups/native-mod-catalog.html @@ -0,0 +1,388 @@ + + + + + +PxModRim — Native Workshop browser · UX prototype + + + + +
+
PPxModRim/ WorkshopInteractive UX prototype
+
+ + +
+
+ + Downloading to Local mods +
+
+ + + + +
+
+
+

Browse mods

+
RimWorld 1.6
+
    +
    +
    +
    +
    + + +
    +
    +
    +
    Demo catalog · no Steam connectionSimulated downloads only · active mod list unchanged
    +
    +
    +
    + + + From e0b073df79bc8367a083bb209394073eff4281c9 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Mon, 5 Oct 2026 21:47:14 +0300 Subject: [PATCH 02/69] design(workshop): compare discovery navigation for mods and collections --- docs/mockups/native-mod-catalog.html | 700 +++++++++++++++------------ 1 file changed, 384 insertions(+), 316 deletions(-) diff --git a/docs/mockups/native-mod-catalog.html b/docs/mockups/native-mod-catalog.html index 71e1db9..b2e3c3d 100644 --- a/docs/mockups/native-mod-catalog.html +++ b/docs/mockups/native-mod-catalog.html @@ -3,218 +3,207 @@ -PxModRim — Native Workshop browser · UX prototype +PxModRim — Workshop discovery · layout study - - + +
    -
    PPxModRim/ WorkshopInteractive UX prototype
    +
    PPxModRim/ Workshop
    Mockup · compare navigation
    - - + +
    -
    - - Downloading to Local mods -
    -
    - - - - -
    -
    -
    -

    Browse mods

    -
    RimWorld 1.6
    -
      -
      -
      -
      -
      - - -
      +

      Workshop

      Mods & collections for RimWorld

      Your game: RimWorld 1.6
      + +
      +
      -
      Demo catalog · no Steam connectionSimulated downloads only · active mod list unchanged
      +
      Interactive mockup · illustrative catalogNo backend connection · simulated downloads · no mods enabled
      -
      -
      + From 1214d05d3ada2abcc9c5f80674d91bff4e9c7706 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Mon, 5 Oct 2026 23:18:49 +0300 Subject: [PATCH 03/69] feat(workshop-backend): add catalog API on TypeScript Worker --- .github/workflows/ci.yml | 19 + cf-workers/.gitignore | 4 + cf-workers/README.md | 284 ++- cf-workers/catalog.ts | 305 +++ cf-workers/migrations/0001_dependencies.sql | 17 + cf-workers/migrations/0002_catalog.sql | 23 + cf-workers/package-lock.json | 2500 +++++++++++++++++++ cf-workers/package.json | 28 + cf-workers/picks.ts | 231 ++ cf-workers/steam.ts | 270 ++ cf-workers/tests/catalog.test.ts | 318 +++ cf-workers/tsconfig.json | 13 + cf-workers/types.ts | 127 + cf-workers/{worker.js => worker.ts} | 227 +- cf-workers/wrangler.local.jsonc | 14 + 15 files changed, 4189 insertions(+), 191 deletions(-) create mode 100644 cf-workers/.gitignore create mode 100644 cf-workers/catalog.ts create mode 100644 cf-workers/migrations/0001_dependencies.sql create mode 100644 cf-workers/migrations/0002_catalog.sql create mode 100644 cf-workers/package-lock.json create mode 100644 cf-workers/package.json create mode 100644 cf-workers/picks.ts create mode 100644 cf-workers/steam.ts create mode 100644 cf-workers/tests/catalog.test.ts create mode 100644 cf-workers/tsconfig.json create mode 100644 cf-workers/types.ts rename cf-workers/{worker.js => worker.ts} (78%) create mode 100644 cf-workers/wrangler.local.jsonc 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/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..7671b95 100644 --- a/cf-workers/README.md +++ b/cf-workers/README.md @@ -1,150 +1,192 @@ -# 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 +``` + +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. + +## Deployment + +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 +``` + +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 | -## Status codes +Exports remain `fetch`, `scheduled`, and `queue`. Catalog routes use Steam directly; they do not scrape Steam HTML or use ScrapingAnt. -| 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 | +## Catalog API -## Data flow +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`. Internally, Steam query pages are cached at the edge for five minutes, and rich metadata is cached in D1 for fifteen minutes. Curated listings are read from D1 on each request, so writes and deletes are not hidden by a public response cache. + +### 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 uses the fifteen-minute cache; this endpoint does not promise an uncached Steam refresh. The client can compare each mod's `updated_at` against its installed copy. + +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. 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. + +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"]} ``` -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 } + +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. -- **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. -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..dec1b02 --- /dev/null +++ b/cf-workers/catalog.ts @@ -0,0 +1,305 @@ +import { deletePick, getPick, listPicks, parsePickInput, putPick, validatePickSlug } from './picks'; +import { 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 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, url.origin, '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: [...pickedItems, ...steam.items], + total: (source === 'steam' ? 0 : picked.total) + steam.total, + next_cursor: next.steam === null && next.picked === null ? null : encodeCursor(next), + }; +} + +async function collectionDetails(env: Env, collection: CatalogCollection): Promise { + const { items, unavailable_ids } = await getSteamItems(env, collection.member_ids); + const members: CatalogItem[] = []; + for (const id of collection.member_ids) { + const item = items.get(id); + if (item) members.push(item); + } + return json({ collection, members, unavailable_ids, is_complete: unavailable_ids.length === 0 && collection.member_count === collection.member_ids.length }); +} + +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([ + querySteam(env, ctx, url.origin, 'mod', { ...query, limit: 4 }), + collectionPage(env, ctx, url, { ...query, limit: 3 }), + ]); + return json({ mods, collections }); + } + if (url.pathname === '/catalog/mods') { + requireMethod(request, ['GET']); + return json(await querySteam(env, ctx, url.origin, 'mod', queryOptions(url))); + } + 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]); + 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]); + const item = result.items.get(id); + if (item?.kind !== 'collection') throw new HttpError(404, 'Public RimWorld collection not found'); + return await collectionDetails(env, 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, 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'); + return json({ error: { message: 'Internal catalog error' } }, 500); + } +} 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/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..fcaf422 --- /dev/null +++ b/cf-workers/picks.ts @@ -0,0 +1,231 @@ +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[], + created_at: row.created_at, + updated_at: row.updated_at, + member_ids: memberIds, + member_count: memberIds.length, + }; +} + +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/steam.ts b/cf-workers/steam.ts new file mode 100644 index 0000000..edb3f75 --- /dev/null +++ b/cf-workers/steam.ts @@ -0,0 +1,270 @@ +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; +} + +async function steamJson(url: URL): Promise { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + 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) throw new HttpError(502, 'Steam API request failed'); + return await response.json(); + } catch (error) { + 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 !== 1 && file.visibility !== 2 && (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, + }; + } + 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))); + } +} + +export async function getSteamItems(env: Env, requestedIds: string[]): Promise<{ items: Map; unavailable_ids: string[] }> { + const ids = [...new Set(requestedIds)]; + const items = new Map(); + 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 FROM catalog_items WHERE id IN (${chunk.map(() => '?').join(',')}) AND updated_at > ?`) + .bind(...chunk, Date.now() - DETAIL_TTL_MS).all<{ id: string; data: string }>(); + for (const row of rows.results) items.set(row.id, JSON.parse(row.data) as CatalogItem); + } + const missing = ids.filter(id => !items.has(id)); + for (let offset = 0; offset < missing.length; offset += API_BATCH_SIZE) { + const chunk = missing.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 files = fileDetails(responseObject(await steamJson(url))).filter(file => chunk.includes(file.publishedfileid) && publicFile(file)); + const authors = await authorsFor(env, files); + const fetched = files.map(file => normalized(file, authors)); + await saveItems(env.DB, fetched); + for (const item of fetched) items.set(item.kind === 'collection' ? item.steam_id! : item.id, item); + } + return { items, unavailable_ids: ids.filter(id => !items.has(id)) }; +} + +export async function querySteam(env: Env, ctx: ExecutionContext, origin: string, kind: K, query: SteamQuery): Promise> { + const cacheUrl = new URL(`/__catalog_cache/${kind}`, origin); + cacheUrl.searchParams.set('query', JSON.stringify(query)); + const cacheKey = new Request(cacheUrl); + const cached = await caches.default.match(cacheKey); + if (cached) return await cached.json(); + + 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 : ''; + const page = { + items, + total: response.total, + next_cursor: details.length && cursor && cursor !== query.cursor ? cursor : null, + }; + ctx.waitUntil(caches.default.put(cacheKey, Response.json(page, { headers: { 'Cache-Control': `public, max-age=${QUERY_TTL_SECONDS}` } }))); + return page as CatalogPage; +} diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts new file mode 100644 index 0000000..340dc0a --- /dev/null +++ b/cf-workers/tests/catalog.test.ts @@ -0,0 +1,318 @@ +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 }, + '18446744073709551615': { ...mod('18446744073709551615', 'Maximum ID'), file_size: '18446744073709551615', vote_data: { votes_up: 0, votes_down: 0 } }, +}; + +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')) { + if (input.publishedfileids?.includes('999')) { + return Response.json({ response: { publishedfiledetails: [{ ...mod('999', 'Malformed upstream'), tags: 'not-an-array' }] } }); + } + return Response.json({ response: { publishedfiledetails: (input.publishedfileids ?? []).flatMap(id => files[id] ? [files[id]] : []) } }); + } + if (url.pathname.includes('QueryFiles')) { + if (input.search_text === 'upstream-rate-limit') return new Response('Rate limited', { status: 429 }); + const candidateIds = input.filetype === 1 ? ['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; + +async function migrate(database: D1Database): Promise { + for (const file of ['0001_dependencies.sql', '0002_catalog.sql']) { + const sql = await readFile(resolvePath('migrations', file), 'utf8'); + await database.batch(sql.split(';').map(statement => statement.trim()).filter(Boolean).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.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((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('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.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('idempotent migrations and the 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', ''), + ]); + await migrate(db); + 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'); + } +}); 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..88bc37d --- /dev/null +++ b/cf-workers/types.ts @@ -0,0 +1,127 @@ +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[]; + created_at: number | null; + updated_at: number | null; + member_ids: string[]; + member_count: number; +} + +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..a37e91b 100644 --- a/cf-workers/worker.js +++ b/cf-workers/worker.ts @@ -1,13 +1,77 @@ +import { handleCatalogRequest } from './catalog'; +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 +79,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 +103,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 +116,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 +138,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 +166,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 +196,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 +218,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 +255,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 +294,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 +319,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 +328,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 +349,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 +367,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 +395,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 +404,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 +413,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 +433,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 +452,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 +463,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 +517,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 +548,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 +560,7 @@ async function scrapeViaScrapingAnt(env, modId) { } let resultStatus = 'OK'; - let resultData = null; + let resultData: DependencyItem; if (!html) { resultStatus = `SCRAPE_ERROR_HTTP_${httpStatus}`; @@ -530,16 +612,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 +700,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 +712,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 +729,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 +770,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 +794,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 +834,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 +844,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 +855,11 @@ export default { } }, - async scheduled(event, env, ctx) { + async scheduled(_event: ScheduledController, env: Env, ctx: ExecutionContext): Promise { ctx.waitUntil(crawlTick(env)); }, - 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 +889,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" + } + ] +} From 4af5e529e2bd1360fa5c77ab0b13c4af53f412f8 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Mon, 5 Oct 2026 23:54:03 +0300 Subject: [PATCH 04/69] fix: mod list hang when toggling thousands of mods Delegates converted the whole highlighted-uuid list from Python on every binding evaluation. Look up membership through a slot instead. --- src/pxmodrim/ui/panels/ModList.qml | 3 ++- src/pxmodrim/ui/panels/mod_list_panel.py | 12 ++++++++++++ tests/test_ui/test_mod_list_panel.py | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/pxmodrim/ui/panels/ModList.qml b/src/pxmodrim/ui/panels/ModList.qml index 18874c8..726db49 100644 --- a/src/pxmodrim/ui/panels/ModList.qml +++ b/src/pxmodrim/ui/panels/ModList.qml @@ -206,7 +206,8 @@ Rectangle { color: { var highlighted = modListPanel - && modListPanel.highlightedUuids.indexOf(model.uuid) >= 0 + && modListPanel.highlightRevision >= 0 + && modListPanel.isHighlighted(model.uuid) if (listView.selectedIndices.indexOf(index) >= 0) return highlighted ? Qt.tint(Theme.elevate4, Qt.alpha(Theme.primary, 0.25)) diff --git a/src/pxmodrim/ui/panels/mod_list_panel.py b/src/pxmodrim/ui/panels/mod_list_panel.py index 5bd9c43..1f850f2 100644 --- a/src/pxmodrim/ui/panels/mod_list_panel.py +++ b/src/pxmodrim/ui/panels/mod_list_panel.py @@ -70,6 +70,14 @@ def setCompactMode(self, enabled: bool) -> None: def highlightedUuids(self) -> list[str]: return self._highlighted_uuids + @Property(int, notify=highlighted_uuids_changed) + def highlightRevision(self) -> int: + return self._highlight_revision + + @Slot(str, result=bool) + def isHighlighted(self, uuid: str) -> bool: + return uuid in self._highlighted_set + def __init__( self, ctx: CoreContext, @@ -83,6 +91,8 @@ def __init__( getattr(cfg, "compact_mod_list", False) if cfg else False ) self._highlighted_uuids: list[str] = [] + self._highlighted_set: frozenset[str] = frozenset() + self._highlight_revision = 0 self._highlight_generation = 0 layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -271,6 +281,8 @@ def _highlight_rows(self, uuids: list[str]) -> None: def _set_highlighted(self, uuids: list[str]) -> None: self._highlighted_uuids = uuids + self._highlighted_set = frozenset(uuids) + self._highlight_revision += 1 self.highlighted_uuids_changed.emit() async def _clear_highlight(self, generation: int) -> None: diff --git a/tests/test_ui/test_mod_list_panel.py b/tests/test_ui/test_mod_list_panel.py index f085959..67ccea9 100644 --- a/tests/test_ui/test_mod_list_panel.py +++ b/tests/test_ui/test_mod_list_panel.py @@ -135,6 +135,7 @@ def _panel(qapp: QApplication) -> ModListPanel: panel = ModListPanel.__new__(ModListPanel) panel._highlighted_uuids = [] + panel._highlight_revision = 0 panel._highlight_generation = 0 panel._qml = cast(QQuickWidget, SimpleNamespace(rootObject=lambda: None)) panel._ctx = cast(CoreContext, ctx) From f77b031efa4055164d47cfdaf89f3222306fe619 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Mon, 5 Oct 2026 23:55:21 +0300 Subject: [PATCH 05/69] refactor: drop unused highlightedUuids list, null-safe highlight binding --- src/pxmodrim/ui/panels/ModList.qml | 8 +++++--- src/pxmodrim/ui/panels/mod_list_panel.py | 18 ++++++------------ tests/test_ui/test_mod_list_panel.py | 5 +++-- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/pxmodrim/ui/panels/ModList.qml b/src/pxmodrim/ui/panels/ModList.qml index 726db49..c60af00 100644 --- a/src/pxmodrim/ui/panels/ModList.qml +++ b/src/pxmodrim/ui/panels/ModList.qml @@ -205,9 +205,11 @@ Rectangle { radius: Theme.radiusMd color: { - var highlighted = modListPanel - && modListPanel.highlightRevision >= 0 - && modListPanel.isHighlighted(model.uuid) + var highlighted = false + if (modListPanel) { + modListPanel.highlightRevision // re-evaluate when highlights change + highlighted = modListPanel.isHighlighted(model.uuid) + } if (listView.selectedIndices.indexOf(index) >= 0) return highlighted ? Qt.tint(Theme.elevate4, Qt.alpha(Theme.primary, 0.25)) diff --git a/src/pxmodrim/ui/panels/mod_list_panel.py b/src/pxmodrim/ui/panels/mod_list_panel.py index 1f850f2..026b3c5 100644 --- a/src/pxmodrim/ui/panels/mod_list_panel.py +++ b/src/pxmodrim/ui/panels/mod_list_panel.py @@ -54,7 +54,7 @@ class ModListPanel(QWidget): order_changed = Signal() selection_changed = Signal(list) compact_mode_changed = Signal(bool) - highlighted_uuids_changed = Signal() + highlighted_changed = Signal() @Property(bool, notify=compact_mode_changed) def compactMode(self) -> bool: @@ -66,17 +66,13 @@ def setCompactMode(self, enabled: bool) -> None: self._compact_mode = enabled self.compact_mode_changed.emit(enabled) - @Property(list, notify=highlighted_uuids_changed) - def highlightedUuids(self) -> list[str]: - return self._highlighted_uuids - - @Property(int, notify=highlighted_uuids_changed) + @Property(int, notify=highlighted_changed) def highlightRevision(self) -> int: return self._highlight_revision @Slot(str, result=bool) def isHighlighted(self, uuid: str) -> bool: - return uuid in self._highlighted_set + return uuid in self._highlighted def __init__( self, @@ -90,8 +86,7 @@ def __init__( self._compact_mode: bool = ( getattr(cfg, "compact_mod_list", False) if cfg else False ) - self._highlighted_uuids: list[str] = [] - self._highlighted_set: frozenset[str] = frozenset() + self._highlighted: frozenset[str] = frozenset() self._highlight_revision = 0 self._highlight_generation = 0 layout = QVBoxLayout(self) @@ -280,10 +275,9 @@ def _highlight_rows(self, uuids: list[str]) -> None: asyncio.create_task(self._clear_highlight(self._highlight_generation)) def _set_highlighted(self, uuids: list[str]) -> None: - self._highlighted_uuids = uuids - self._highlighted_set = frozenset(uuids) + self._highlighted = frozenset(uuids) self._highlight_revision += 1 - self.highlighted_uuids_changed.emit() + self.highlighted_changed.emit() async def _clear_highlight(self, generation: int) -> None: await asyncio.sleep(1.2) diff --git a/tests/test_ui/test_mod_list_panel.py b/tests/test_ui/test_mod_list_panel.py index 67ccea9..d69fce3 100644 --- a/tests/test_ui/test_mod_list_panel.py +++ b/tests/test_ui/test_mod_list_panel.py @@ -134,7 +134,7 @@ def _panel(qapp: QApplication) -> ModListPanel: ) panel = ModListPanel.__new__(ModListPanel) - panel._highlighted_uuids = [] + panel._highlighted = frozenset() panel._highlight_revision = 0 panel._highlight_generation = 0 panel._qml = cast(QQuickWidget, SimpleNamespace(rootObject=lambda: None)) @@ -314,7 +314,8 @@ async def test_highlighting_enabled_mod_keeps_user_selection( panel.set_search_filter("Mod") panel.set_search_filter("") - assert panel.property("highlightedUuids") == ["uuid-c"] + assert panel.isHighlighted("uuid-c") is True + assert panel.isHighlighted("uuid-a") is False assert _qml_list_property(list_view, "selectedUuids") == ["uuid-a"] assert list_view.property("currentUuid") == "uuid-a" From 24bdb244c0d3cf6543831aaa42570e33e8bb10ab Mon Sep 17 00:00:00 2001 From: PyXiion Date: Mon, 5 Oct 2026 23:58:06 +0300 Subject: [PATCH 06/69] fix: sidebar filter lagged one change behind after toggling mods Diagnostics emitted the summary (which re-applies the current sidebar filter) before the new sidebar entries, so the filter used stale uuids. --- .../core/services/diagnostics_service.py | 4 ++- .../test_services/test_activation_service.py | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/pxmodrim/core/services/diagnostics_service.py b/src/pxmodrim/core/services/diagnostics_service.py index 0943720..eecb813 100644 --- a/src/pxmodrim/core/services/diagnostics_service.py +++ b/src/pxmodrim/core/services/diagnostics_service.py @@ -276,9 +276,11 @@ def _on_checker_diagnostics_changed( summary[uuid] = self._to_view(diag) self._last_summary = summary self._last_summary_sources = diagnostics + # Sidebar first: the summary handler re-applies the current sidebar + # filter and must see the entries for this state, not the previous one. + self.sidebar_entries_changed.emit(self._build_sidebar_entries()) self.diagnostics_summary_changed.emit(self._last_summary) self.status_message_changed.emit(self._format_status()) - self.sidebar_entries_changed.emit(self._build_sidebar_entries()) def summary_for(self, uuid: str) -> ModDiagnosticsView | None: return self._last_summary.get(uuid) diff --git a/tests/test_services/test_activation_service.py b/tests/test_services/test_activation_service.py index 158d8cd..103011c 100644 --- a/tests/test_services/test_activation_service.py +++ b/tests/test_services/test_activation_service.py @@ -339,3 +339,28 @@ 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_sidebar_entries_are_published_before_summary( + ctx: CoreContext, activation: ActivationService +) -> None: + """The summary handler re-applies the sidebar filter, so entries must be fresh.""" + diagnostics = ctx.diagnostics_service + inactive: set[str] = set() + seen_at_summary: list[set[str]] = [] + + def on_entries(entries: list) -> None: + inactive.clear() + inactive.update(next(e for e in entries if e.label == "Inactive").visible_uuids) + + diagnostics.sidebar_entries_changed.connect(on_entries) + diagnostics.diagnostics_summary_changed.connect( + lambda _: seen_at_summary.append(set(inactive)) + ) + + diagnostics.rebuild(ctx.active_uuids) + seen_at_summary.clear() + activation.apply(enable=["uuid-f"]) + + assert seen_at_summary + assert "uuid-f" not in seen_at_summary[-1] From 98195fd798018c94a2cc27c167a3f2b1c2318388 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Mon, 5 Oct 2026 23:59:56 +0300 Subject: [PATCH 07/69] fix: toasts rendered with a transparent background --- src/pxmodrim/ui/components/toast.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pxmodrim/ui/components/toast.py b/src/pxmodrim/ui/components/toast.py index bfe6d10..9222fdf 100644 --- a/src/pxmodrim/ui/components/toast.py +++ b/src/pxmodrim/ui/components/toast.py @@ -37,6 +37,7 @@ def __init__( super().__init__(parent) self.setObjectName("toast") self.setFixedWidth(320) + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) icon_name, accent = _TOAST_LEVELS[level] From 37a01f23c8ecab05c6e18041668aeddfee12c6e0 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 00:01:05 +0300 Subject: [PATCH 08/69] feat(workshop): add catalog client and WorkshopCatalog plugin --- src/pxmodrim/core/config.py | 1 + src/pxmodrim/core/workshop/__init__.py | 36 ++++ src/pxmodrim/core/workshop/catalog.py | 180 ++++++++++++++++ src/pxmodrim/core/workshop/client.py | 173 ++++++++++++++++ src/pxmodrim/core/workshop/types.py | 126 ++++++++++++ tests/test_workshop/conftest.py | 61 ++++++ tests/test_workshop/test_catalog.py | 244 ++++++++++++++++++++++ tests/test_workshop/test_client.py | 273 +++++++++++++++++++++++++ tests/test_workshop/test_types.py | 93 +++++++++ 9 files changed, 1187 insertions(+) create mode 100644 src/pxmodrim/core/workshop/__init__.py create mode 100644 src/pxmodrim/core/workshop/catalog.py create mode 100644 src/pxmodrim/core/workshop/client.py create mode 100644 src/pxmodrim/core/workshop/types.py create mode 100644 tests/test_workshop/conftest.py create mode 100644 tests/test_workshop/test_catalog.py create mode 100644 tests/test_workshop/test_client.py create mode 100644 tests/test_workshop/test_types.py diff --git a/src/pxmodrim/core/config.py b/src/pxmodrim/core/config.py index 018a83d..89a3cff 100644 --- a/src/pxmodrim/core/config.py +++ b/src/pxmodrim/core/config.py @@ -72,6 +72,7 @@ class AppConfig(msgspec.Struct): workshop_proxy: str = "" workshop_connect_timeout: int = 10 workshop_stall_timeout: int = 30 + workshop_catalog_url: str = "https://api.modrim.pyxiion.dev" log_upload_endpoint: str = "https://paste.rs/" launch_args: str = "" launch_wrapper: str = "" diff --git a/src/pxmodrim/core/workshop/__init__.py b/src/pxmodrim/core/workshop/__init__.py new file mode 100644 index 0000000..9644a3d --- /dev/null +++ b/src/pxmodrim/core/workshop/__init__.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from pxmodrim.core.workshop.catalog import 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", + "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..90ebbe2 --- /dev/null +++ b/src/pxmodrim/core/workshop/catalog.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, ClassVar + +from pxmodrim.core.downloads.manager import DownloadManager, download_manager +from pxmodrim.core.events import Event +from pxmodrim.core.plugin import Plugin +from pxmodrim.core.workshop.client import CatalogClient, CatalogError +from pxmodrim.core.workshop.types import ( + CatalogCollection, + CatalogMod, + CatalogPage, + CatalogQuery, + CollectionDetail, + Discover, + DownloadPlan, + InstallState, +) + +if TYPE_CHECKING: + 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 + + +class WorkshopCatalog(Plugin): + name = "workshop_catalog" + dependencies: ClassVar[list[str]] = ["downloads"] + + def __init__( + self, + config_accessor: Callable[[], AppConfig], + client_factory: Callable[[str], CatalogClient] = CatalogClient, + ) -> None: + self.installed_changed: Event[None] = Event() + self.catalog_url_changed: Event[str] = Event() + self._config = config_accessor + self._client_factory = client_factory + self._base_url = config_accessor().workshop_catalog_url.strip().rstrip("/") + self._client: CatalogClient | None = None + self._clients: list[CatalogClient] = [] + self._ctx: CoreContext | None = None + self._manager: DownloadManager | None = None + + def setup(self, ctx: CoreContext) -> None: + self._ctx = ctx + self._manager = download_manager(ctx) + ctx.mod_service.mods_changed.connect(self._on_installed_changed) + self._manager.download_finished.connect(self._on_download_finished) + ctx.config_changed.connect(self._on_config_changed) + + def _on_installed_changed(self, _: None) -> None: + self.installed_changed.emit(None) + + def _on_download_finished(self, _: DownloadResult) -> None: + self.installed_changed.emit(None) + + def _on_config_changed(self, config: AppConfig) -> None: + self._change_url(config.workshop_catalog_url) + self.installed_changed.emit(None) + + 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._config().workshop_catalog_url = url.strip().rstrip("/") + self._change_url(url) + if self._ctx is not None and self._ctx.has_config_service: + self._ctx.config_service.save("config.json", self._config()) + + @property + def configured(self) -> bool: + return bool(self._config().workshop_catalog_url.strip()) + + def _catalog_client(self) -> CatalogClient: + self._change_url(self._config().workshop_catalog_url) + 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: + 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) + 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") + 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) + return installed + + 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) diff --git a/src/pxmodrim/core/workshop/client.py b/src/pxmodrim/core/workshop/client.py new file mode 100644 index 0000000..c00f3d2 --- /dev/null +++ b/src/pxmodrim/core/workshop/client.py @@ -0,0 +1,173 @@ +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: + return await self._request("GET", f"mods/{quote(id, safe='')}", CatalogMod) + + 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.") + return await self._request( + "GET", f"collections/{source}/{quote(key, safe='')}", CollectionDetail + ) + + 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/types.py b/src/pxmodrim/core/workshop/types.py new file mode 100644 index 0000000..3d8a88b --- /dev/null +++ b/src/pxmodrim/core/workshop/types.py @@ -0,0 +1,126 @@ +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 + + @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/tests/test_workshop/conftest.py b/tests/test_workshop/conftest.py new file mode 100644 index 0000000..b5e84bb --- /dev/null +++ b/tests/test_workshop/conftest.py @@ -0,0 +1,61 @@ +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, + } diff --git a/tests/test_workshop/test_catalog.py b/tests/test_workshop/test_catalog.py new file mode 100644 index 0000000..53c44b2 --- /dev/null +++ b/tests/test_workshop/test_catalog.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +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, + 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(lambda: ctx.config) + 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: ctx.config, 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: ctx.config, 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(lambda: ctx.config) + 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) + ctx.config.workshop_catalog_url = "" + clients: list[str] = [] + + def factory(url: str) -> CatalogClient: + clients.append(url) + raise AssertionError("Disabled catalog must not create a client") + + catalog = WorkshopCatalog(lambda: ctx.config, factory) + catalog.setup(ctx) + 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(lambda: ctx.config, 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 ctx.config.workshop_catalog_url == "https://other.example" + assert ( + ConfigService(tmp_path).load("config.json", AppConfig).workshop_catalog_url + == "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] diff --git a/tests/test_workshop/test_client.py b/tests/test_workshop/test_client.py new file mode 100644 index 0000000..dd66232 --- /dev/null +++ b/tests/test_workshop/test_client.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from typing import Any + +import httpx +import msgspec +import pytest + +from pxmodrim.core.workshop import ( + CatalogClient, + CatalogCollection, + CatalogError, + CatalogMod, + CatalogQuery, +) + + +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, + } + nested_collection = { + **steam_collection, + "id": "steam:456", + "steam_id": "456", + "member_ids": ["2009463077"], + "member_count": 1, + } + 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, 404, 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( + "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_types.py b/tests/test_workshop/test_types.py new file mode 100644 index 0000000..f982625 --- /dev/null +++ b/tests/test_workshop/test_types.py @@ -0,0 +1,93 @@ +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, + } + ) + 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,"workshop_parallel_items":3}' + ) + config = service.load("config.json", AppConfig) + assert config.workshop_catalog_url == "https://api.modrim.pyxiion.dev" + assert config.workshop_parallel_items == 3 + config.workshop_catalog_url = "" + service.save("config.json", config) + assert service.load("config.json", AppConfig).workshop_catalog_url == "" + + +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 From 28304d610e786327fb5e12675010d8c0aa509bc0 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 00:01:05 +0300 Subject: [PATCH 09/69] feat(workshop): add native Workshop browser UI and wiring --- AGENTS.md | 9 +- CHANGELOG.md | 1 + scripts/check-deps.py | 9 + src/pxmodrim/_app.py | 9 + src/pxmodrim/ui/panels/Settings.qml | 30 + src/pxmodrim/ui/panels/settings_panel.py | 6 + .../ui/plugins/workshop/CatalogCard.qml | 105 ++++ src/pxmodrim/ui/plugins/workshop/Workshop.qml | 347 ++++++++++++ src/pxmodrim/ui/plugins/workshop/__init__.py | 6 + src/pxmodrim/ui/plugins/workshop/models.py | 243 +++++++++ src/pxmodrim/ui/plugins/workshop/plugin.py | 17 + src/pxmodrim/ui/plugins/workshop/view.py | 514 ++++++++++++++++++ tests/test_ui/test_workshop_models.py | 137 +++++ tests/test_ui/test_workshop_view.py | 243 +++++++++ 14 files changed, 1673 insertions(+), 3 deletions(-) create mode 100644 src/pxmodrim/ui/plugins/workshop/CatalogCard.qml create mode 100644 src/pxmodrim/ui/plugins/workshop/Workshop.qml create mode 100644 src/pxmodrim/ui/plugins/workshop/__init__.py create mode 100644 src/pxmodrim/ui/plugins/workshop/models.py create mode 100644 src/pxmodrim/ui/plugins/workshop/plugin.py create mode 100644 src/pxmodrim/ui/plugins/workshop/view.py create mode 100644 tests/test_ui/test_workshop_models.py create mode 100644 tests/test_ui/test_workshop_view.py diff --git a/AGENTS.md b/AGENTS.md index b6b2522..a4a9e5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,11 @@ 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. ## Key conventions @@ -58,7 +61,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 ce187ae..dc63844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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). ### Added +- Native QML Workshop browser: Discover, Mods, Collections and Installed views, catalog filters and detail pages, dependency-aware mod/collection downloads into Local mods, download queue progress, compatibility warnings and updates for downloader-managed copies. 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. - 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. diff --git a/scripts/check-deps.py b/scripts/check-deps.py index 0120329..6170e82 100644 --- a/scripts/check-deps.py +++ b/scripts/check-deps.py @@ -43,6 +43,7 @@ "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"}, @@ -104,6 +105,14 @@ "core.config", "core.context", }, + "core.workshop": { + "core.foundation", + "core.models", + "core.config", + "core.context", + "core.downloads", + "core.mod_service", + }, "core.organizer": { "core.foundation", "core.models", diff --git a/src/pxmodrim/_app.py b/src/pxmodrim/_app.py index 3a897f1..8cacf66 100644 --- a/src/pxmodrim/_app.py +++ b/src/pxmodrim/_app.py @@ -210,6 +210,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(lambda: ctx.config)) + 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 diff --git a/src/pxmodrim/ui/panels/Settings.qml b/src/pxmodrim/ui/panels/Settings.qml index c6c3884..6739623 100644 --- a/src/pxmodrim/ui/panels/Settings.qml +++ b/src/pxmodrim/ui/panels/Settings.qml @@ -23,6 +23,7 @@ Rectangle { parallelItems: parallelItems.value, threadsPerItem: threadsPerItem.value, proxy: proxy.text.trim(), + catalogUrl: catalogUrl.text.trim(), connectTimeout: connectTimeout.value, stallTimeout: stallTimeout.value, launchArgs: launchArgs.text.trim(), @@ -306,6 +307,35 @@ Rectangle { } } + Group { + title: "Workshop catalog" + RowLayout { + Layout.fillWidth: true + Text { + Layout.preferredWidth: 150 + text: "Workshop catalog URL" + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + PxTextField { + id: catalogUrl + Layout.fillWidth: true + text: root.initial.catalogUrl + placeholderText: "https://api.modrim.pyxiion.dev" + Accessible.name: "Workshop catalog URL" + } + } + 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 + } + } + Group { title: "Steam Workshop" visible: root.initial.steamAvailable diff --git a/src/pxmodrim/ui/panels/settings_panel.py b/src/pxmodrim/ui/panels/settings_panel.py index f46ba89..739c840 100644 --- a/src/pxmodrim/ui/panels/settings_panel.py +++ b/src/pxmodrim/ui/panels/settings_panel.py @@ -21,6 +21,7 @@ from pxmodrim.core.context import CoreContext from pxmodrim.core.loading import LoadingState from pxmodrim.core.sort.community_service import CommunityRulesService +from pxmodrim.core.workshop import WorkshopCatalog from pxmodrim.ui.components.dialogs import await_dialog from pxmodrim.ui.components.progress_dialog import ProgressDialog from pxmodrim.ui.theme.palette import PALETTE @@ -66,6 +67,7 @@ def __init__(self, ctx: CoreContext, dialog: QDialog) -> None: "parallelItems": cfg.workshop_parallel_items, "threadsPerItem": cfg.workshop_threads_per_item, "proxy": cfg.workshop_proxy, + "catalogUrl": cfg.workshop_catalog_url, "connectTimeout": cfg.workshop_connect_timeout, "stallTimeout": cfg.workshop_stall_timeout, "launchArgs": cfg.launch_args, @@ -238,6 +240,7 @@ def _save(self, values: dict[str, Any]) -> None: workshop_parallel_items=int(values["parallelItems"]), workshop_threads_per_item=int(values["threadsPerItem"]), workshop_proxy=values["proxy"].strip(), + workshop_catalog_url=values["catalogUrl"].strip(), workshop_connect_timeout=int(values["connectTimeout"]), workshop_stall_timeout=int(values["stallTimeout"]), launch_args=values["launchArgs"].strip(), @@ -247,6 +250,9 @@ def _save(self, values: dict[str, Any]) -> None: launch_confirm_unsaved=bool(values["confirmUnsaved"]), launch_confirm_running=bool(values["confirmRunning"]), ) + catalog = self._ctx.plugins.get("workshop_catalog") + if isinstance(catalog, WorkshopCatalog): + catalog.set_base_url(self._config.workshop_catalog_url) self.accept() def get_config(self) -> AppConfig: diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml new file mode 100644 index 0000000..c83367f --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml @@ -0,0 +1,105 @@ +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 string sourceLabel + required property string versions + required property string stateLabel + required property string state + required property string actionLabel + required property bool incompatible + required property int memberCount + + color: Theme.elevate2 + radius: Theme.radiusMd + border.color: Theme.border + clip: true + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 110 + color: Theme.elevate3 + radius: Theme.radiusMd + clip: true + Image { + id: preview + anchors.fill: parent + source: card.previewUrl + asynchronous: true + fillMode: Image.PreserveAspectCrop + sourceSize.width: 480 + sourceSize.height: 240 + } + Image { + anchors.centerIn: parent + width: 32 + height: 32 + visible: preview.status !== Image.Ready + source: "image://icons/grid?color=" + encodeURIComponent(Theme.textDim) + } + TapHandler { onTapped: workshopPanel.openItem(card.itemId, card.kind) } + } + Text { + Layout.fillWidth: true + text: card.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: card.author + textFormat: Text.PlainText + color: Theme.textMuted + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: card.kind === "collection" ? card.sourceLabel + " · " + card.memberCount + " members" : card.stateLabel + textFormat: Text.PlainText + color: card.state === "outdated" ? Theme.primary : Theme.textDim + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: card.incompatible ? "⚠ Incompatible with your game" : "Versions: " + card.versions + textFormat: Text.PlainText + color: card.incompatible ? Theme.warning : Theme.textDim + font.pixelSize: Theme.fontSizeXs + elide: Text.ElideRight + } + Item { Layout.fillHeight: true } + RowLayout { + Layout.fillWidth: true + PxButton { + text: "Details" + variant: "ghost" + onClicked: workshopPanel.openItem(card.itemId, card.kind) + } + Item { Layout.fillWidth: true } + PxButton { + text: card.actionLabel + variant: "primary" + enabled: !workshopPanel.busy && !workshopQueue.busy && card.state !== "installed" + onClicked: workshopPanel.downloadItem(card.itemId, card.kind) + } + } + } +} diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml new file mode 100644 index 0000000..cff4bc5 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -0,0 +1,347 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import "../../components/controls" + +Rectangle { + id: root + color: Theme.elevate0 + property bool queueExpanded: false + readonly property var detail: workshopPanel.detail + + function applyFilters() { + workshopPanel.filter(search.text.trim(), version.currentIndex === 0 ? "" : version.currentText, + source.currentValue, sort.currentValue, tag.text.trim()) + } + + 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 / 250)) + Layout.fillWidth: true + Layout.preferredHeight: Math.ceil(count / columns) * cellHeight + cellWidth: width / columns + cellHeight: 298 + interactive: false + clip: true + delegate: CatalogCard { + width: GridView.view.cellWidth - 12 + height: GridView.view.cellHeight - 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"; font.pixelSize: Theme.fontSizeSm } + } + PxTextField { + id: search + Layout.fillWidth: true + Layout.maximumWidth: 540 + placeholderText: "Search mods or collections…" + Accessible.name: "Search Workshop" + maximumLength: 200 + enabled: workshopPanel.configured && !workshopPanel.busy + onAccepted: root.applyFilters() + } + PxButton { text: "Search"; enabled: workshopPanel.configured && !workshopPanel.busy; onClicked: root.applyFilters() } + Copy { + Layout.fillWidth: false + visible: root.width > 1000 + text: workshopPanel.gameVersion ? "Your game: " + workshopPanel.gameVersion : "Game version unknown" + font.pixelSize: Theme.fontSizeSm + } + } + RowLayout { + Layout.fillWidth: true + Repeater { + model: ["Discover", "Mods", "Collections", "Installed"] + PxButton { + required property string modelData + text: modelData + variant: workshopPanel.tab === modelData ? "primary" : "ghost" + enabled: !workshopPanel.busy + onClicked: workshopPanel.selectTab(modelData) + } + } + Item { Layout.fillWidth: true } + PxButton { + visible: workshopPanel.tab === "Installed" + text: "Update all (" + workshopPanel.updateCount + ")" + enabled: !workshopPanel.busy && !workshopQueue.busy && workshopPanel.updateCount > 0 + onClicked: workshopPanel.updateAll() + } + } + RowLayout { + Layout.fillWidth: true + visible: workshopPanel.configured && !workshopPanel.hasDetail && workshopPanel.tab !== "Installed" + 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)) + enabled: !workshopPanel.busy + 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" + enabled: !workshopPanel.busy + onActivated: root.applyFilters() + } + PxTextField { + id: tag + Layout.fillWidth: true + Layout.maximumWidth: 230 + placeholderText: "Tag (exact match)" + maximumLength: 100 + Accessible.name: "Tag filter" + enabled: !workshopPanel.busy + onAccepted: 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" + enabled: !workshopPanel.busy + onActivated: root.applyFilters() + } + } + PxProgressBar { Layout.fillWidth: true; visible: workshopPanel.busy; indeterminate: true } + 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 && !workshopPanel.busy && !workshopQueue.busy; onClicked: workshopPanel.downloadAvailable() } + PxButton { text: "Cancel"; variant: "ghost"; onClicked: workshopPanel.dismissPlan() } + } + } + } + ScrollView { + id: contentScroll + 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 && !workshopPanel.hasDetail + Heading { text: workshopPanel.tab === "Discover" ? "Discover your next colony" : workshopPanel.tab } + Copy { visible: workshopPanel.tab === "Discover"; text: "Explore Steam mods and collections alongside PxModRim picks. Downloads include required dependencies, without activating mods." } + RowLayout { + visible: workshopPanel.tab === "Discover" + Heading { text: "Collections" } + PxButton { text: "Browse all"; variant: "ghost"; onClicked: workshopPanel.selectTab("Collections") } + } + CatalogGrid { + visible: workshopPanel.tab === "Discover" || workshopPanel.tab === "Collections" + model: catalogCollections + } + RowLayout { + visible: workshopPanel.tab === "Discover" + Heading { text: "Popular mods" } + PxButton { text: "Browse all"; variant: "ghost"; onClicked: workshopPanel.selectTab("Mods") } + } + CatalogGrid { visible: workshopPanel.tab !== "Collections"; model: catalogMods } + Copy { + visible: !workshopPanel.busy && !workshopPanel.error && (workshopPanel.tab === "Collections" ? catalogCollections.count === 0 : catalogMods.count === 0) + text: workshopPanel.tab === "Installed" ? "No installed Workshop mods were found." : "No matching items. Try another search or filter." + } + PxButton { + text: "Load more" + visible: workshopPanel.tab === "Collections" ? catalogCollections.hasMore : workshopPanel.tab === "Mods" && catalogMods.hasMore + enabled: !workshopPanel.busy + onClicked: workshopPanel.loadMore() + } + } + ColumnLayout { + Layout.fillWidth: true + visible: workshopPanel.configured && workshopPanel.hasDetail + PxButton { text: "← Back"; variant: "ghost"; onClicked: workshopPanel.back() } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 240 + color: Theme.elevate2 + radius: Theme.radiusMd + clip: true + Image { + id: detailImage + anchors.fill: parent + source: root.detail.previewUrl || "" + asynchronous: true + sourceSize.width: 1000 + sourceSize.height: 480 + fillMode: Image.PreserveAspectCrop + } + Image { + anchors.centerIn: parent + width: 48 + height: 48 + visible: detailImage.status !== Image.Ready + source: "image://icons/grid?color=" + encodeURIComponent(Theme.textDim) + } + } + Copy { text: root.detail.sourceLabel || ""; color: Theme.primary; font.pixelSize: Theme.fontSizeSm } + Heading { text: root.detail.title || ""; font.pixelSize: 26 } + Copy { text: "By " + (root.detail.author || "") } + Copy { text: "Versions: " + (root.detail.versions || "") } + Copy { text: root.detail.tags || "" } + Copy { visible: !!root.detail.incompatible; text: "⚠ This item does not support your running game version. Downloading does not make it compatible."; color: Theme.warning } + Copy { visible: !!root.detail.warning; text: root.detail.warning || ""; color: Theme.warning } + RowLayout { + PxButton { + text: root.detail.actionLabel || "Download" + variant: "primary" + enabled: !workshopPanel.busy && !workshopQueue.busy && root.detail.state !== "installed" + onClicked: workshopPanel.downloadItem(root.detail.itemId, root.detail.kind) + } + PxButton { visible: !!root.detail.workshopUrl; text: "View on Steam"; onClicked: workshopPanel.openLink(root.detail.workshopUrl) } + } + Copy { text: "Downloads go to Local mods. Nothing is enabled or subscribed automatically."; font.pixelSize: Theme.fontSizeSm } + Copy { visible: root.detail.kind === "mod"; text: "Size: " + (root.detail.fileSize || "") + " · Votes: " + (root.detail.votes || "") } + Heading { text: "Description" } + Text { + Layout.fillWidth: true + text: root.detail.description || "" + textFormat: Text.RichText + wrapMode: Text.Wrap + color: Theme.textMuted + linkColor: Theme.primary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + onLinkActivated: link => workshopPanel.openLink(link) + } + Heading { text: root.detail.kind === "collection" ? "Collection members (" + catalogMembers.count + ")" : "Required dependencies" } + Copy { visible: catalogMembers.count === 0; text: "None listed." } + Repeater { + model: catalogMembers + Rectangle { + id: member + required property string itemId + required property string kind + required property string title + required property string versions + required property string stateLabel + required property string state + required property string actionLabel + required property bool incompatible + Layout.fillWidth: true + implicitHeight: memberRow.implicitHeight + 24 + radius: Theme.radiusMd + color: Theme.elevate2 + RowLayout { + id: memberRow + anchors.fill: parent + anchors.margins: 12 + ColumnLayout { + Layout.fillWidth: true + Copy { text: member.title; color: Theme.textMain } + Copy { text: member.stateLabel + " · Versions: " + member.versions; font.pixelSize: Theme.fontSizeSm } + Copy { visible: member.incompatible; text: "⚠ Incompatible with your game"; color: Theme.warning; font.pixelSize: Theme.fontSizeSm } + } + PxButton { text: "Details"; variant: "ghost"; onClicked: workshopPanel.openItem(member.itemId, member.kind) } + PxButton { text: member.actionLabel; enabled: !workshopPanel.busy && !workshopQueue.busy && member.state !== "installed"; onClicked: workshopPanel.downloadItem(member.itemId, member.kind) } + } + } + } + } + } + } + Rectangle { + Layout.fillWidth: true + implicitHeight: queueColumn.implicitHeight + 24 + color: Theme.elevate2 + radius: Theme.radiusMd + ColumnLayout { + id: queueColumn + anchors.fill: parent + anchors.margins: 12 + RowLayout { + Layout.fillWidth: true + PxButton { text: root.queueExpanded ? "Hide queue" : "Download queue"; iconName: "download"; variant: "ghost"; onClicked: root.queueExpanded = !root.queueExpanded } + Copy { text: workshopPanel.queueSummary + (workshopQueue.phase ? " · " + workshopQueue.phase : ""); font.pixelSize: Theme.fontSizeSm } + PxButton { visible: workshopQueue.busy; text: "Stop"; variant: "danger"; onClicked: workshopPanel.stop() } + PxButton { text: "Open Downloads"; variant: "ghost"; onClicked: workshopPanel.openDownloads() } + } + PxProgressBar { Layout.fillWidth: true; visible: workshopQueue.busy; value: workshopPanel.queueProgress } + ListView { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(count * 46, 138) + visible: root.queueExpanded + model: workshopQueue + clip: true + delegate: ColumnLayout { + required property string title + required property string state + required property real progress + required property string error + width: ListView.view.width + height: 46 + Copy { text: parent.title + " · " + parent.state + (parent.error ? " · " + parent.error : ""); color: parent.state === "failed" ? Theme.danger : Theme.textMuted; font.pixelSize: Theme.fontSizeSm; maximumLineCount: 1; elide: Text.ElideRight } + PxProgressBar { Layout.fillWidth: true; value: parent.progress } + } + } + } + } + } + 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/models.py b/src/pxmodrim/ui/plugins/workshop/models.py new file mode 100644 index 0000000..51c9147 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/models.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import html +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 + +_TOKEN = re.compile( + r"\[(/?)(b|i|u|h[1-3]|list|\*|url|img)(?:=([^\]]*))?\]", re.IGNORECASE +) + + +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 "" + + +def description_rich_text(text: str, description_format: str = "bbcode") -> str: + """Convert untrusted descriptions to the small Qt rich-text subset we emit.""" + if description_format == "text": + return html.escape(text).replace("\n", "
      ") + result: list[str] = [] + stack: list[tuple[str, str]] = [] + position = 0 + while match := _TOKEN.search(text, position): + result.append(html.escape(text[position : match.start()]).replace("\n", "
      ")) + closing, tag, argument = match.groups() + tag = tag.lower() + position = match.end() + if not closing and tag in {"url", "img"}: + end = re.search(rf"\[/{tag}\]", text[position:], re.IGNORECASE) + if end is None: + result.append(html.escape(match.group())) + continue + body = text[position : position + end.start()] + url = safe_url(argument if argument is not None else body) + label = html.escape(body if tag == "url" else "Image") + result.append( + f'{label}' + if url + else label + ) + position += end.end() + elif closing: + if stack and stack[-1][0] == "*" and tag == "list": + result.append(stack.pop()[1]) + if stack and stack[-1][0] == tag: + result.append(stack.pop()[1]) + else: + result.append(html.escape(match.group())) + elif tag == "*": + if stack and stack[-1][0] == "*": + result.append(stack.pop()[1]) + if stack and stack[-1][0] == "list": + result.append("
    • ") + stack.append((tag, "
    • ")) + else: + result.append(html.escape(match.group())) + else: + output_tag = "ul" if tag == "list" else tag + result.append(f"<{output_tag}>") + stack.append((tag, f"")) + result.append(html.escape(text[position:]).replace("\n", "
      ")) + result.extend(close for _, close in reversed(stack)) + return "".join(result) + + +def item_row( + item: CatalogMod | CatalogCollection, + install_state: Callable[[CatalogMod], InstallState], + game_version: str, +) -> dict[str, Any]: + incompatible = bool( + game_version + and item.supported_versions + and game_version not in item.supported_versions + ) + file_size, votes, member_count = "Unknown", "Unrated", 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 = f"{item.votes.up} up / {item.votes.down} down" + else: + state = "missing" + source_label = ( + "PxModRim pick" if item.source == "picked" else "Steam collection" + ) + action_label = "Download all" + member_count = item.member_count + 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 ""), + "sourceLabel": source_label, + "versions": ", ".join(item.supported_versions) or "Not specified", + "tags": " · ".join(item.tags), + "state": state, + "stateLabel": { + "missing": "Not installed", + "installed": "Installed", + "outdated": "Update available", + }[state], + "actionLabel": action_label, + "incompatible": incompatible, + "memberCount": member_count, + "description": description_rich_text(item.description, item.description_format), + "workshopUrl": safe_url(item.workshop_url or ""), + "fileSize": file_size, + "votes": votes, + } + + +class CatalogListModel(QAbstractListModel): + changed = Signal() + _roles = ( + "itemId", + "kind", + "title", + "author", + "previewUrl", + "sourceLabel", + "versions", + "tags", + "state", + "stateLabel", + "actionLabel", + "incompatible", + "memberCount", + "description", + "workshopUrl", + "fileSize", + "votes", + ) + + 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() + 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..0c652de --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/plugin.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from pxmodrim.core.plugin import Plugin +from pxmodrim.ui.plugins.workshop.view import WorkshopViewPanel + +if TYPE_CHECKING: + from pxmodrim.ui.context import AppContext + + +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) diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py new file mode 100644 index 0000000..a865085 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -0,0 +1,514 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +from PySide6.QtCore import Property, QUrl, Signal, Slot +from PySide6.QtGui import QColor, QDesktopServices +from PySide6.QtQuickWidgets import QQuickWidget +from PySide6.QtWidgets import QDialog +from qasync import asyncSlot + +from pxmodrim.core.downloads import download_manager +from pxmodrim.core.workshop import ( + CatalogCollection, + CatalogError, + CatalogMod, + CatalogQuery, + DownloadPlan, + WorkshopCatalog, +) +from pxmodrim.ui.components.dialogs import await_dialog +from pxmodrim.ui.panels.settings_panel import SettingsPanel +from pxmodrim.ui.plugins.downloads.model import DownloadsModel +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.core.downloads import DownloadProgress, DownloadResult + from pxmodrim.ui.context import AppContext + + +class WorkshopViewPanel(BaseViewPanel): + view_id = "workshop" + icon_name = "steam" + label = "Workshop" + changed = Signal() + queueChanged = 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.queue_model = DownloadsModel(self) + self._tab = "Discover" + self._busy = False + self._error = "" + self._notice = "" + self._detail: dict[str, Any] = {} + self._query = "" + self._version = "" if ctx.target_version == "Unknown" else ctx.target_version + self._tag = "" + self._sort = "popular" + self._source = "all" + self._generation = 0 + self._retry: Callable[[], Awaitable[None]] | None = None + self._installed: list[CatalogMod] = [] + self._pending: DownloadPlan | None = None + self._titles: dict[str, str] = {} + self._items: dict[str, CatalogMod | CatalogCollection] = {} + self._queue_progress = 0.0 + self._queue_summary = "No downloads yet" + 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), + ("workshopQueue", self.queue_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.queue_model.set_busy(self._downloads.is_downloading) + self._downloads.batch_started.connect(self._on_batch_started) + self._downloads.download_progress.connect(self._on_progress) + self._downloads.download_item_status_changed.connect(self.queue_model.apply) + self._downloads.download_item_titled.connect(self.queue_model.set_title) + self._downloads.download_phase_changed.connect(self.queue_model.set_phase) + self._downloads.download_finished.connect(self._on_finished) + self._downloads.busy_changed.connect(self.queue_model.set_busy) + self._catalog.catalog_url_changed.connect(self._on_url_changed) + self._catalog.installed_changed.connect(self._on_installed_changed) + + def _row(self, item: CatalogMod | CatalogCollection) -> dict[str, Any]: + self._items[item.id] = item + version = ( + "" if self._ctx.target_version == "Unknown" else self._ctx.target_version + ) + return item_row(item, self._catalog.install_state, version) + + async def _run( + self, fetch: Callable[[], Awaitable[Any]], apply: Callable[[Any], None] + ) -> None: + self._generation += 1 + generation = self._generation + self._busy, self._error = True, "" + self.changed.emit() + + async def retry() -> None: + await self._run(fetch, apply) + + self._retry = retry + try: + result = await fetch() + if generation == self._generation: + apply(result) + except CatalogError as exc: + if generation == self._generation: + self._error = exc.message + except (RuntimeError, ValueError) as exc: + if generation == self._generation: + self._error = str(exc) + finally: + if generation == self._generation: + self._busy = False + self.changed.emit() + + async def load(self, *, append: bool = False) -> None: + if not self._catalog.configured: + self._generation += 1 + self._busy = False + self.changed.emit() + return + self._detail = {} + model = ( + self.collections_model if self._tab == "Collections" else self.mods_model + ) + query = CatalogQuery( + query=self._query, + tag=self._tag or None, + version=self._version or None, + sort=self._sort, + source=self._source, + cursor=model.cursor if append else None, + ) + if self._tab == "Discover": + + def apply_discover(result: Any) -> None: + self.mods_model.set_page( + [self._row(item) for item in result.mods.items], + result.mods.total, + None, + ) + self.collections_model.set_page( + [self._row(item) for item in result.collections.items], + result.collections.total, + None, + ) + + await self._run(self._catalog.discover, apply_discover) + elif self._tab == "Installed": + + def apply_installed(items: list[CatalogMod]) -> None: + self._installed = items + rows = [ + self._row(item) + for item in items + if not self._query + or self._query.casefold() in item.title.casefold() + ] + self.mods_model.set_page(rows, len(rows), None) + + await self._run(self._catalog.installed_with_updates, apply_installed) + else: + fetch = ( + self._catalog.collections + if self._tab == "Collections" + else self._catalog.mods + ) + + def apply_page(page: Any) -> None: + model.set_page( + [self._row(item) for item in page.items], + page.total, + page.next_cursor, + append=append, + ) + + await self._run(lambda: fetch(query), apply_page) + + @asyncSlot() + async def refresh(self) -> None: + await self.load() + + @asyncSlot(str) + async def selectTab(self, tab: str) -> None: + self._tab, self._notice, self._pending = tab, "", None + 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._query, self._version, self._source, self._sort, self._tag = ( + query, + version, + source, + sort, + tag, + ) + 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() + + @asyncSlot(str, str) + async def openItem(self, item_id: str, kind: str) -> None: + await self.open_item(item_id, kind) + + async def open_item(self, item_id: str, kind: str) -> None: + async def fetch() -> tuple[dict[str, Any], list[dict[str, Any]]]: + if kind == "collection": + detail = await self._catalog.collection(item_id) + if detail is None: + raise CatalogError("This collection is no longer available.", 404) + row = self._row(detail.collection) + row["complete"] = detail.is_complete + row["warning"] = ( + "" + if detail.is_complete + else "This collection is incomplete. " + "Some members are unavailable or not listed." + ) + if detail.unavailable_ids: + row["warning"] += " Unavailable: " + ", ".join( + detail.unavailable_ids + ) + members = [self._row(item) for item in detail.members] + row["actionLabel"] = ( + "Complete download" + if any(member["state"] != "missing" for member in members) + else "Download all" + ) + return row, members + mod = await self._catalog.mod(item_id) + if mod is None: + raise CatalogError("This mod is no longer available.", 404) + dependencies = await asyncio.gather( + *(self._catalog.mod(pid) for pid in mod.dependencies) + ) + row = self._row(mod) + missing = [ + pid + for pid, item in zip(mod.dependencies, dependencies, strict=True) + if item is None + ] + row["warning"] = ( + "Unavailable dependencies: " + ", ".join(missing) if missing else "" + ) + row["complete"] = not missing + return row, [self._row(item) for item in dependencies if item is not None] + + def apply(result: tuple[dict[str, Any], list[dict[str, Any]]]) -> None: + self._detail, members = result + self.members_model.set_page(members, len(members), None) + + await self._run(fetch, apply) + + @Slot() + def back(self) -> None: + self._generation += 1 + self._detail, self._error, self._busy = {}, "", False + self.changed.emit() + + async def _download(self, ids: list[str], collections: list[str]) -> None: + self._notice, self._pending = "", None + + async def fetch() -> DownloadPlan: + plan = await self._catalog.plan(ids, collections) + self._titles.update(plan.titles) + if plan.is_complete and plan.to_download: + await self._catalog.download(plan) + return plan + + def apply(plan: DownloadPlan) -> None: + if not plan.is_complete: + self._pending = plan + 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) + ) + self._notice = " ".join(parts) + elif not plan.to_download: + self._notice = ( + "All available mods are already installed and up to date." + ) + else: + self._notice = ( + "Download finished. See Downloads for item results. " + "Mods were not activated." + ) + + await self._run(fetch, apply) + + @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() + async def updateAll(self) -> None: + ids = [ + item.id + for item in self._installed + if self._catalog.install_state(item) == "outdated" + ] + if ids: + await self._download(ids, []) + + @asyncSlot() + async def downloadAvailable(self) -> None: + plan, self._pending = self._pending, None + if plan and plan.to_download: + await self._run( + lambda: self._catalog.download(plan), + lambda _: setattr( + self, + "_notice", + "Download finished. See Downloads for item results.", + ), + ) + + @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 openDownloads(self) -> None: + if self._app_ctx: + self._app_ctx.navigate("downloads") + + @asyncSlot() + async def openSettings(self) -> None: + if self._qml_engine is None: + return + result, dialog = await await_dialog( + SettingsPanel, self._ctx, self._qml_engine, self + ) + if result == QDialog.DialogCode.Accepted: + self._ctx.update_config(dialog.get_config()) + dialog.deleteLater() + + @Slot() + def stop(self) -> None: + self._downloads.cancel() + + def _on_batch_started(self, ids: list[str]) -> None: + titles = self._downloads.names_by_id(self._ctx.all_mods.values()) | self._titles + self.queue_model.begin(ids, titles) + self._queue_progress = 0.0 + self._queue_summary = f"Downloading {len(ids)} mods" + self.queueChanged.emit() + + def _on_progress(self, progress: DownloadProgress) -> None: + self._queue_progress = ( + progress.completed / progress.total if progress.total else 0.0 + ) + self._queue_summary = f"{progress.completed} / {progress.total} mods" + self.queueChanged.emit() + + def _on_finished(self, result: DownloadResult) -> None: + self.queue_model.finish(result) + self._queue_summary = ( + f"{len(result.succeeded)} completed · {len(result.failed)} failed" + ) + self.queueChanged.emit() + + @asyncSlot(str) + async def _on_url_changed(self, _url: str) -> None: + self._detail, self._error, self._notice, self._pending = {}, "", "", None + self._items.clear() + self.mods_model.set_page([], 0, None) + self.collections_model.set_page([], 0, None) + self.members_model.set_page([], 0, None) + await self.load() + + @asyncSlot(object) + async def _on_installed_changed(self, _value: None) -> None: + def convert(row: dict[str, Any]) -> dict[str, Any]: + return self._row(self._items[row["itemId"]]) + + for model in (self.mods_model, self.collections_model, self.members_model): + model.refresh_rows(convert) + if self._detail: + item = self._items[self._detail["itemId"]] + self._detail.update(self._row(item)) + if isinstance(item, CatalogCollection): + members = [self._items.get(pid) for pid in item.member_ids] + partially_installed = any( + isinstance(member, CatalogMod) + and self._catalog.install_state(member) != "missing" + for member in members + ) + self._detail["actionLabel"] = ( + "Complete download" if partially_installed else "Download all" + ) + self.changed.emit() + if self._tab == "Installed" and not self._busy: + await self.load() + + @Property(str, notify=changed) # type: ignore[arg-type] + def tab(self) -> str: + return 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, constant=True) # type: ignore[arg-type] + def gameVersion(self) -> str: + return "" if self._ctx.target_version == "Unknown" else self._ctx.target_version + + @Property(list, constant=True) # type: ignore[arg-type] + def versionOptions(self) -> list[str]: + return list( + dict.fromkeys( + [ + "All versions", + *([self._version] if self._version else []), + "1.6", + "1.5", + "1.4", + "1.3", + ] + ) + ) + + @Property(int, notify=changed) # type: ignore[arg-type] + def updateCount(self) -> int: + return sum( + self._catalog.install_state(item) == "outdated" for item in self._installed + ) + + @Property(float, notify=queueChanged) # type: ignore[arg-type] + def queueProgress(self) -> float: + return self._queue_progress + + @Property(str, notify=queueChanged) # type: ignore[arg-type] + def queueSummary(self) -> str: + return self._queue_summary diff --git a/tests/test_ui/test_workshop_models.py b/tests/test_ui/test_workshop_models.py new file mode 100644 index 0000000..b8733f6 --- /dev/null +++ b/tests/test_ui/test_workshop_models.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import pytest +from PySide6.QtCore import QModelIndex, QObject, Qt + +from pxmodrim.core.workshop import Author, CatalogMod +from pxmodrim.ui.plugins.workshop.models import ( + CatalogListModel, + description_rich_text, + item_row, + safe_url, +) + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + ( + "[b]Bold[/b] [i]italic[/i] [u]underlined[/u]", + "Bold italic underlined", + ), + ( + "[h1]Heading[/h1][h2]Two[/h2][h3]Three[/h3]", + "

      Heading

      Two

      Three

      ", + ), + ("[list][*]one[*]two[/list]", "
      • one
      • two
      "), + ("&", "<script>alert(1)</script>&"), + ( + '[b][/b]', + "<img src="file:///secret">", + ), + ("[url=javascript:alert(1)]click[/url]", "click"), + ("[url=file:///etc/passwd]secret[/url]", "secret"), + ("[url=data:text/html,test]data[/url]", "data"), + ( + '[url=https://example.com/?x="&y=1]safe[/url]', + 'safe', + ), + ( + "[url]https://example.com[/url]", + 'https://example.com', + ), + ( + "[img]https://example.com/picture.png[/img]", + 'Image', + ), + ("[img]file:///secret[/img]", "Image"), + ("[b]unfinished", "unfinished"), + ("[unknown]raw[/unknown]", "[unknown]<b>raw</b>[/unknown]"), + ], +) +def test_bbcode_emits_only_safe_markup(source: str, expected: str) -> None: + assert description_rich_text(source) == expected + + +def test_text_descriptions_never_parse_bbcode_or_html() -> None: + assert ( + description_rich_text("[b]Text[/b]\nnext", "text") + == "[b]<b>Text</b>[/b]
      next" + ) + + +@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_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" diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py new file mode 100644 index 0000000..533fa27 --- /dev/null +++ b/tests/test_ui/test_workshop_view.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any, cast + +import pytest +from PySide6.QtCore import QUrl +from PySide6.QtQuickWidgets import QQuickWidget +from PySide6.QtWidgets import QWidget + +from pxmodrim.core.config import AppConfig, ConfigService +from pxmodrim.core.context import CoreContext +from pxmodrim.core.downloads import DownloadManager, DownloadResult +from pxmodrim.core.workshop import ( + Author, + CatalogCollection, + CatalogError, + CatalogMod, + CatalogPage, + CatalogQuery, + CollectionDetail, + Discover, + DownloadPlan, + InstallState, + WorkshopCatalog, +) +from pxmodrim.ui.components import create_qml_engine +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__(lambda: ctx.config) + 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.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: + 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: + return DownloadPlan( + ["2", "1"], + [], + ["missing"] if collection_ids else [], + collection_ids, + {"1": "Mod", "2": "Dependency"}, + not collection_ids, + ) + + async def download(self, plan: DownloadPlan) -> DownloadResult: + self.downloaded.extend(plan.to_download) + return DownloadResult(plan.to_download, []) + + +@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] = [] + engine.warnings.connect(warnings.extend) + view = WorkshopViewPanel(ctx, engine, owner, catalog=catalog) + view.resize(1100, 800) + yield view, catalog, warnings + view._qml.setSource(QUrl()) + owner.deleteLater() + + +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_view_paging_filters_and_catalog_errors( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, _ = panel + await asyncio.sleep(0) + view._tab = "Mods" + view._version = "1.6" + await view.load() + await view.load(append=True) + 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.load() + assert view.error == "Catalog unavailable" + catalog.failure = False + assert view._retry is not None + 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"] == "Safe description" + assert detail["actionLabel"] == "Update" + assert view.members_model.count == 1 + await view._download(["1"], []) + assert catalog.downloaded == ["2", "1"] + 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"] == "<b>Plain text</b>" + await view._download([], ["picked:test"]) + 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_unset_catalog_never_fetches( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, catalog, _ = panel + await asyncio.sleep(0) + view._ctx.update_config(AppConfig(workshop_catalog_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, _ = panel + await asyncio.sleep(0) + view._ctx.plugins.register(catalog) + settings = SettingsPanel(view._ctx, view._qml.engine(), view) + values = dict(cast("dict[str, Any]", settings._backend.initial)) + values["catalogUrl"] = " https://catalog.example.test/ " + settings._save(values) + assert settings.get_config().workshop_catalog_url == "https://catalog.example.test/" + assert view._ctx.config.workshop_catalog_url == "https://catalog.example.test" + await asyncio.sleep(0) + settings.deleteLater() From cb065be25a02b77be6a5da0672429864ceda8eee Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 00:06:59 +0300 Subject: [PATCH 10/69] fix(workshop): show download progress bar only while downloading --- .../ui/plugins/workshop/CatalogCard.qml | 2 +- src/pxmodrim/ui/plugins/workshop/Workshop.qml | 39 ++++++------------- src/pxmodrim/ui/plugins/workshop/view.py | 36 ++++++----------- 3 files changed, 24 insertions(+), 53 deletions(-) diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml index c83367f..6f0077a 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml +++ b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml @@ -97,7 +97,7 @@ Rectangle { PxButton { text: card.actionLabel variant: "primary" - enabled: !workshopPanel.busy && !workshopQueue.busy && card.state !== "installed" + enabled: !workshopPanel.busy && !workshopPanel.downloading && card.state !== "installed" onClicked: workshopPanel.downloadItem(card.itemId, card.kind) } } diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index cff4bc5..f94fc61 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -6,7 +6,7 @@ import "../../components/controls" Rectangle { id: root color: Theme.elevate0 - property bool queueExpanded: false + readonly property var detail: workshopPanel.detail function applyFilters() { @@ -86,7 +86,7 @@ Rectangle { PxButton { visible: workshopPanel.tab === "Installed" text: "Update all (" + workshopPanel.updateCount + ")" - enabled: !workshopPanel.busy && !workshopQueue.busy && workshopPanel.updateCount > 0 + enabled: !workshopPanel.busy && !workshopPanel.downloading && workshopPanel.updateCount > 0 onClicked: workshopPanel.updateAll() } } @@ -161,7 +161,7 @@ Rectangle { Copy { text: workshopPanel.notice; color: workshopPanel.pendingPlan ? Theme.warning : Theme.textMuted } RowLayout { visible: workshopPanel.pendingPlan - PxButton { text: "Download available mods"; enabled: workshopPanel.canDownloadAvailable && !workshopPanel.busy && !workshopQueue.busy; onClicked: workshopPanel.downloadAvailable() } + PxButton { text: "Download available mods"; enabled: workshopPanel.canDownloadAvailable && !workshopPanel.busy && !workshopPanel.downloading; onClicked: workshopPanel.downloadAvailable() } PxButton { text: "Cancel"; variant: "ghost"; onClicked: workshopPanel.dismissPlan() } } } @@ -251,7 +251,7 @@ Rectangle { PxButton { text: root.detail.actionLabel || "Download" variant: "primary" - enabled: !workshopPanel.busy && !workshopQueue.busy && root.detail.state !== "installed" + enabled: !workshopPanel.busy && !workshopPanel.downloading && root.detail.state !== "installed" onClicked: workshopPanel.downloadItem(root.detail.itemId, root.detail.kind) } PxButton { visible: !!root.detail.workshopUrl; text: "View on Steam"; onClicked: workshopPanel.openLink(root.detail.workshopUrl) } @@ -299,7 +299,7 @@ Rectangle { Copy { visible: member.incompatible; text: "⚠ Incompatible with your game"; color: Theme.warning; font.pixelSize: Theme.fontSizeSm } } PxButton { text: "Details"; variant: "ghost"; onClicked: workshopPanel.openItem(member.itemId, member.kind) } - PxButton { text: member.actionLabel; enabled: !workshopPanel.busy && !workshopQueue.busy && member.state !== "installed"; onClicked: workshopPanel.downloadItem(member.itemId, member.kind) } + PxButton { text: member.actionLabel; enabled: !workshopPanel.busy && !workshopPanel.downloading && member.state !== "installed"; onClicked: workshopPanel.downloadItem(member.itemId, member.kind) } } } } @@ -308,38 +308,21 @@ Rectangle { } Rectangle { Layout.fillWidth: true - implicitHeight: queueColumn.implicitHeight + 24 + visible: workshopPanel.downloading + implicitHeight: downloadRow.implicitHeight + 24 color: Theme.elevate2 radius: Theme.radiusMd ColumnLayout { - id: queueColumn + id: downloadRow anchors.fill: parent anchors.margins: 12 RowLayout { Layout.fillWidth: true - PxButton { text: root.queueExpanded ? "Hide queue" : "Download queue"; iconName: "download"; variant: "ghost"; onClicked: root.queueExpanded = !root.queueExpanded } - Copy { text: workshopPanel.queueSummary + (workshopQueue.phase ? " · " + workshopQueue.phase : ""); font.pixelSize: Theme.fontSizeSm } - PxButton { visible: workshopQueue.busy; text: "Stop"; variant: "danger"; onClicked: workshopPanel.stop() } + Copy { Layout.fillWidth: true; text: "Downloading · " + workshopPanel.queueSummary; font.pixelSize: Theme.fontSizeSm } + PxButton { text: "Stop"; variant: "danger"; onClicked: workshopPanel.stop() } PxButton { text: "Open Downloads"; variant: "ghost"; onClicked: workshopPanel.openDownloads() } } - PxProgressBar { Layout.fillWidth: true; visible: workshopQueue.busy; value: workshopPanel.queueProgress } - ListView { - Layout.fillWidth: true - Layout.preferredHeight: Math.min(count * 46, 138) - visible: root.queueExpanded - model: workshopQueue - clip: true - delegate: ColumnLayout { - required property string title - required property string state - required property real progress - required property string error - width: ListView.view.width - height: 46 - Copy { text: parent.title + " · " + parent.state + (parent.error ? " · " + parent.error : ""); color: parent.state === "failed" ? Theme.danger : Theme.textMuted; font.pixelSize: Theme.fontSizeSm; maximumLineCount: 1; elide: Text.ElideRight } - PxProgressBar { Layout.fillWidth: true; value: parent.progress } - } - } + PxProgressBar { Layout.fillWidth: true; value: workshopPanel.queueProgress } } } } diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index a865085..13966ce 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -22,7 +22,6 @@ ) from pxmodrim.ui.components.dialogs import await_dialog from pxmodrim.ui.panels.settings_panel import SettingsPanel -from pxmodrim.ui.plugins.downloads.model import DownloadsModel 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 @@ -32,7 +31,7 @@ from PySide6.QtWidgets import QWidget from pxmodrim.core.context import CoreContext - from pxmodrim.core.downloads import DownloadProgress, DownloadResult + from pxmodrim.core.downloads import DownloadProgress from pxmodrim.ui.context import AppContext @@ -60,7 +59,6 @@ def __init__( self.mods_model = CatalogListModel(self) self.collections_model = CatalogListModel(self) self.members_model = CatalogListModel(self) - self.queue_model = DownloadsModel(self) self._tab = "Discover" self._busy = False self._error = "" @@ -77,8 +75,9 @@ def __init__( self._pending: DownloadPlan | None = None self._titles: dict[str, str] = {} self._items: dict[str, CatalogMod | CatalogCollection] = {} + self._downloading = self._downloads.is_downloading self._queue_progress = 0.0 - self._queue_summary = "No downloads yet" + self._queue_summary = "" self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] self._qml.setObjectName("workshopView") self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) @@ -89,21 +88,14 @@ def __init__( ("catalogMods", self.mods_model), ("catalogCollections", self.collections_model), ("catalogMembers", self.members_model), - ("workshopQueue", self.queue_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.queue_model.set_busy(self._downloads.is_downloading) - self._downloads.batch_started.connect(self._on_batch_started) + self._downloads.busy_changed.connect(self._on_busy) self._downloads.download_progress.connect(self._on_progress) - self._downloads.download_item_status_changed.connect(self.queue_model.apply) - self._downloads.download_item_titled.connect(self.queue_model.set_title) - self._downloads.download_phase_changed.connect(self.queue_model.set_phase) - self._downloads.download_finished.connect(self._on_finished) - self._downloads.busy_changed.connect(self.queue_model.set_busy) self._catalog.catalog_url_changed.connect(self._on_url_changed) self._catalog.installed_changed.connect(self._on_installed_changed) @@ -390,11 +382,10 @@ async def openSettings(self) -> None: def stop(self) -> None: self._downloads.cancel() - def _on_batch_started(self, ids: list[str]) -> None: - titles = self._downloads.names_by_id(self._ctx.all_mods.values()) | self._titles - self.queue_model.begin(ids, titles) - self._queue_progress = 0.0 - self._queue_summary = f"Downloading {len(ids)} mods" + def _on_busy(self, busy: bool) -> None: + self._downloading = busy + if busy: + self._queue_progress, self._queue_summary = 0.0, "Starting…" self.queueChanged.emit() def _on_progress(self, progress: DownloadProgress) -> None: @@ -404,13 +395,6 @@ def _on_progress(self, progress: DownloadProgress) -> None: self._queue_summary = f"{progress.completed} / {progress.total} mods" self.queueChanged.emit() - def _on_finished(self, result: DownloadResult) -> None: - self.queue_model.finish(result) - self._queue_summary = ( - f"{len(result.succeeded)} completed · {len(result.failed)} failed" - ) - self.queueChanged.emit() - @asyncSlot(str) async def _on_url_changed(self, _url: str) -> None: self._detail, self._error, self._notice, self._pending = {}, "", "", None @@ -505,6 +489,10 @@ def updateCount(self) -> int: self._catalog.install_state(item) == "outdated" for item in self._installed ) + @Property(bool, notify=queueChanged) # type: ignore[arg-type] + def downloading(self) -> bool: + return self._downloading + @Property(float, notify=queueChanged) # type: ignore[arg-type] def queueProgress(self) -> float: return self._queue_progress From 820ae416ff80c6b1c9a8f2ea83763bf5c7972226 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 00:11:56 +0300 Subject: [PATCH 11/69] style(workshop): match app tab, badge and button styling --- .../ui/plugins/workshop/CatalogCard.qml | 54 +++++++++++++---- src/pxmodrim/ui/plugins/workshop/Workshop.qml | 58 ++++++++++++++----- 2 files changed, 86 insertions(+), 26 deletions(-) diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml index 6f0077a..05095ef 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml +++ b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml @@ -18,10 +18,11 @@ Rectangle { required property bool incompatible required property int memberCount - color: Theme.elevate2 + color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 radius: Theme.radiusMd - border.color: Theme.border + border.color: hover.hovered ? Theme.elevate4 : Theme.border clip: true + HoverHandler { id: hover } ColumnLayout { anchors.fill: parent @@ -66,22 +67,53 @@ Rectangle { text: card.author textFormat: Text.PlainText color: Theme.textMuted + font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSm elide: Text.ElideRight } - Text { + Flow { Layout.fillWidth: true - text: card.kind === "collection" ? card.sourceLabel + " · " + card.memberCount + " members" : card.stateLabel - textFormat: Text.PlainText - color: card.state === "outdated" ? Theme.primary : Theme.textDim - font.pixelSize: Theme.fontSizeSm - elide: Text.ElideRight + spacing: 6 + PxBadge { + visible: card.kind === "collection" + compact: true + text: card.sourceLabel.toUpperCase() + textColor: card.sourceLabel.indexOf("pick") >= 0 ? Theme.warning : Theme.textMuted + fillColor: card.sourceLabel.indexOf("pick") >= 0 ? Theme.warningBg : Theme.elevate3 + } + PxBadge { + visible: card.kind === "collection" + compact: true + text: card.memberCount + " MODS" + textColor: Theme.textMuted + fillColor: Theme.elevate3 + } + PxBadge { + visible: card.kind === "mod" && card.state === "installed" + compact: true + text: "INSTALLED" + textColor: Theme.success + fillColor: Theme.successBg + } + PxBadge { + visible: card.kind === "mod" && card.state === "outdated" + compact: true + text: "UPDATE AVAILABLE" + } + PxBadge { + visible: card.incompatible + compact: true + text: "INCOMPATIBLE" + textColor: Theme.warning + fillColor: Theme.warningBg + } } Text { Layout.fillWidth: true - text: card.incompatible ? "⚠ Incompatible with your game" : "Versions: " + card.versions + text: "Versions: " + card.versions textFormat: Text.PlainText - color: card.incompatible ? Theme.warning : Theme.textDim + color: Theme.textDim + font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeXs elide: Text.ElideRight } @@ -96,7 +128,7 @@ Rectangle { Item { Layout.fillWidth: true } PxButton { text: card.actionLabel - variant: "primary" + variant: card.state === "outdated" ? "primary" : "secondary" enabled: !workshopPanel.busy && !workshopPanel.downloading && card.state !== "installed" onClicked: workshopPanel.downloadItem(card.itemId, card.kind) } diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index f94fc61..4f94f34 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -70,25 +70,53 @@ Rectangle { font.pixelSize: Theme.fontSizeSm } } - RowLayout { + ColumnLayout { Layout.fillWidth: true - Repeater { - model: ["Discover", "Mods", "Collections", "Installed"] + spacing: 0 + RowLayout { + Layout.fillWidth: true + spacing: 4 + Repeater { + model: ["Discover", "Mods", "Collections", "Installed"] + Item { + id: tabItem + required property string modelData + readonly property bool current: workshopPanel.tab === modelData + implicitWidth: tabLabel.implicitWidth + 32 + implicitHeight: 36 + opacity: workshopPanel.busy && !current ? Theme.disabledOpacity : 1 + 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 { enabled: !workshopPanel.busy; onTapped: workshopPanel.selectTab(tabItem.modelData) } + Accessible.role: Accessible.PageTab + Accessible.name: modelData + } + } + Item { Layout.fillWidth: true } PxButton { - required property string modelData - text: modelData - variant: workshopPanel.tab === modelData ? "primary" : "ghost" - enabled: !workshopPanel.busy - onClicked: workshopPanel.selectTab(modelData) + visible: workshopPanel.tab === "Installed" + text: "Update all (" + workshopPanel.updateCount + ")" + variant: "primary" + enabled: !workshopPanel.busy && !workshopPanel.downloading && workshopPanel.updateCount > 0 + onClicked: workshopPanel.updateAll() } } - Item { Layout.fillWidth: true } - PxButton { - visible: workshopPanel.tab === "Installed" - text: "Update all (" + workshopPanel.updateCount + ")" - enabled: !workshopPanel.busy && !workshopPanel.downloading && workshopPanel.updateCount > 0 - onClicked: workshopPanel.updateAll() - } + Rectangle { Layout.fillWidth: true; height: 1; color: Theme.border } } RowLayout { Layout.fillWidth: true From 9f7a099ff62a7d74be2b8c2e33f044b506e15d77 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 00:20:42 +0300 Subject: [PATCH 12/69] feat(workshop-backend): retry stalled Steam calls, log failures, add collection thumbnails --- cf-workers/catalog.ts | 4 +- cf-workers/picks.ts | 1 + cf-workers/steam.ts | 67 +++++++++++++++++++++++++++----- cf-workers/tests/catalog.test.ts | 20 +++++++++- cf-workers/types.ts | 1 + 5 files changed, 81 insertions(+), 12 deletions(-) diff --git a/cf-workers/catalog.ts b/cf-workers/catalog.ts index dec1b02..4fa131f 100644 --- a/cf-workers/catalog.ts +++ b/cf-workers/catalog.ts @@ -1,5 +1,5 @@ import { deletePick, getPick, listPicks, parsePickInput, putPick, validatePickSlug } from './picks'; -import { getSteamItems, querySteam, validateSteamId } from './steam'; +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'; @@ -140,7 +140,7 @@ async function collectionPage(env: Env, ctx: ExecutionContext, url: URL, query: key, steam: steam.next_cursor, picked: cursor.picked === null ? null : picked.next_offset, steam_total: steam.total, }; return { - items: [...pickedItems, ...steam.items], + items: 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), }; diff --git a/cf-workers/picks.ts b/cf-workers/picks.ts index fcaf422..ae8b205 100644 --- a/cf-workers/picks.ts +++ b/cf-workers/picks.ts @@ -145,6 +145,7 @@ function toCollection(row: PickRow): CatalogCollection { updated_at: row.updated_at, member_ids: memberIds, member_count: memberIds.length, + member_previews: [], }; } diff --git a/cf-workers/steam.ts b/cf-workers/steam.ts index edb3f75..57d4510 100644 --- a/cf-workers/steam.ts +++ b/cf-workers/steam.ts @@ -30,16 +30,38 @@ function steamUrl(env: Env, service: string, method: string, version = 1): URL { return url; } +const STEAM_ATTEMPTS = 3; +const STEAM_ATTEMPT_TIMEOUT_MS = 4_000; + async function steamJson(url: URL): Promise { - try { - const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); - 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) throw new HttpError(502, 'Steam API request failed'); - return await response.json(); - } catch (error) { - if (error instanceof HttpError) throw error; - throw new HttpError(502, 'Steam API is unavailable or returned invalid JSON'); + 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'); + } + } } } @@ -160,6 +182,7 @@ function normalized(file: SteamFile, authors: Map): CatalogItem steam_id: file.publishedfileid, member_ids: childIds, member_count: file.num_children ?? childIds.length, + member_previews: [], }; } const votes = file.vote_data; @@ -218,6 +241,32 @@ export async function getSteamItems(env: Env, requestedIds: string[]): Promise<{ 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[]): 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; + let members: Map; + 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 async function querySteam(env: Env, ctx: ExecutionContext, origin: string, kind: K, query: SteamQuery): Promise> { const cacheUrl = new URL(`/__catalog_cache/${kind}`, origin); cacheUrl.searchParams.set('query', JSON.stringify(query)); diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index 340dc0a..61f7be3 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -30,9 +30,12 @@ const files: Record = { '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; + 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 }); @@ -51,7 +54,8 @@ async function upstream(request: Request): Promise { } if (url.pathname.includes('QueryFiles')) { if (input.search_text === 'upstream-rate-limit') return new Response('Rate limited', { status: 429 }); - const candidateIds = input.filetype === 1 ? ['100', '101'] : ['10', '11', '12', '13', '14']; + 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))); @@ -263,6 +267,12 @@ test('invalid input and malformed/rate-limited upstreams have explicit non-succe 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('idempotent migrations and the existing /deps response preserve populated dependency data', async () => { const now = Date.now(); await db.batch([ @@ -316,3 +326,11 @@ test('newest picks use creation time while recently updated picks use modificati 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')); +}); diff --git a/cf-workers/types.ts b/cf-workers/types.ts index 88bc37d..53f0fac 100644 --- a/cf-workers/types.ts +++ b/cf-workers/types.ts @@ -96,6 +96,7 @@ export interface CatalogCollection { updated_at: number | null; member_ids: string[]; member_count: number; + member_previews: string[]; } export type CatalogItem = CatalogMod | CatalogCollection; From 703acacea21c7f3d6c04b56f71fd330cf707afa5 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 00:23:27 +0300 Subject: [PATCH 13/69] feat(workshop): show collection thumbnails and compact busy indicator --- src/pxmodrim/core/workshop/types.py | 1 + .../ui/plugins/workshop/CatalogCard.qml | 23 +++++++++++++- src/pxmodrim/ui/plugins/workshop/Workshop.qml | 9 +++++- src/pxmodrim/ui/plugins/workshop/models.py | 16 +++++++++- tests/test_ui/test_workshop_models.py | 30 ++++++++++++++++++- tests/test_workshop/conftest.py | 1 + tests/test_workshop/test_client.py | 2 ++ tests/test_workshop/test_types.py | 1 + 8 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/pxmodrim/core/workshop/types.py b/src/pxmodrim/core/workshop/types.py index 3d8a88b..10d5e4e 100644 --- a/src/pxmodrim/core/workshop/types.py +++ b/src/pxmodrim/core/workshop/types.py @@ -67,6 +67,7 @@ class CatalogCollection( updated_at: int | None member_ids: list[str] member_count: int + member_previews: list[str] = msgspec.field(default_factory=list) @property def kind(self) -> Literal["collection"]: diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml index 05095ef..fbc6e00 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml +++ b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml @@ -10,6 +10,7 @@ Rectangle { required property string title required property string author required property string previewUrl + required property var collage required property string sourceLabel required property string versions required property string stateLabel @@ -43,11 +44,31 @@ Rectangle { sourceSize.width: 480 sourceSize.height: 240 } + Grid { + id: collageGrid + anchors.fill: parent + visible: card.previewUrl === "" && card.collage.length > 0 + columns: card.collage.length === 1 ? 1 : 2 + readonly property int rowCount: card.collage.length > 2 ? 2 : 1 + Repeater { + model: card.collage + Image { + required property string modelData + width: collageGrid.width / collageGrid.columns + height: collageGrid.height / collageGrid.rowCount + source: modelData + asynchronous: true + fillMode: Image.PreserveAspectCrop + sourceSize.width: 240 + sourceSize.height: 120 + } + } + } Image { anchors.centerIn: parent width: 32 height: 32 - visible: preview.status !== Image.Ready + visible: preview.status !== Image.Ready && !collageGrid.visible source: "image://icons/grid?color=" + encodeURIComponent(Theme.textDim) } TapHandler { onTapped: workshopPanel.openItem(card.itemId, card.kind) } diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index 4f94f34..f66af0d 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -63,6 +63,14 @@ Rectangle { onAccepted: root.applyFilters() } PxButton { text: "Search"; enabled: workshopPanel.configured && !workshopPanel.busy; onClicked: root.applyFilters() } + PxProgressBar { + Layout.preferredWidth: 48 + Layout.alignment: Qt.AlignVCenter + thickness: 3 + indeterminate: true + opacity: workshopPanel.busy ? 1 : 0 + Accessible.name: "Loading" + } Copy { Layout.fillWidth: false visible: root.width > 1000 @@ -161,7 +169,6 @@ Rectangle { onActivated: root.applyFilters() } } - PxProgressBar { Layout.fillWidth: true; visible: workshopPanel.busy; indeterminate: true } Rectangle { Layout.fillWidth: true implicitHeight: errorRow.implicitHeight + 24 diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/src/pxmodrim/ui/plugins/workshop/models.py index 51c9147..9f0344a 100644 --- a/src/pxmodrim/ui/plugins/workshop/models.py +++ b/src/pxmodrim/ui/plugins/workshop/models.py @@ -120,7 +120,20 @@ def item_row( "kind": item.kind, "title": item.title, "author": item.author.name or item.author.id or "Unknown author", - "previewUrl": safe_url(item.preview_url or ""), + "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(item.supported_versions) or "Not specified", "tags": " · ".join(item.tags), @@ -148,6 +161,7 @@ class CatalogListModel(QAbstractListModel): "title", "author", "previewUrl", + "collage", "sourceLabel", "versions", "tags", diff --git a/tests/test_ui/test_workshop_models.py b/tests/test_ui/test_workshop_models.py index b8733f6..c4e4595 100644 --- a/tests/test_ui/test_workshop_models.py +++ b/tests/test_ui/test_workshop_models.py @@ -3,7 +3,7 @@ import pytest from PySide6.QtCore import QModelIndex, QObject, Qt -from pxmodrim.core.workshop import Author, CatalogMod +from pxmodrim.core.workshop import Author, CatalogCollection, CatalogMod, Preview from pxmodrim.ui.plugins.workshop.models import ( CatalogListModel, description_rich_text, @@ -135,3 +135,31 @@ 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" + + +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"] diff --git a/tests/test_workshop/conftest.py b/tests/test_workshop/conftest.py index b5e84bb..cb824c1 100644 --- a/tests/test_workshop/conftest.py +++ b/tests/test_workshop/conftest.py @@ -58,4 +58,5 @@ def collection_payload() -> dict[str, Any]: "updated_at": 1700000000, "member_ids": ["2009463077"], "member_count": 1, + "member_previews": [], } diff --git a/tests/test_workshop/test_client.py b/tests/test_workshop/test_client.py index dd66232..596fd08 100644 --- a/tests/test_workshop/test_client.py +++ b/tests/test_workshop/test_client.py @@ -35,6 +35,7 @@ async def test_decodes_every_endpoint( "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, @@ -42,6 +43,7 @@ async def test_decodes_every_endpoint( "steam_id": "456", "member_ids": ["2009463077"], "member_count": 1, + "member_previews": [], } steam_detail = { **detail, diff --git a/tests/test_workshop/test_types.py b/tests/test_workshop/test_types.py index f982625..3138d8c 100644 --- a/tests/test_workshop/test_types.py +++ b/tests/test_workshop/test_types.py @@ -51,6 +51,7 @@ def test_steam_collection_and_empty_page(collection_payload: dict[str, Any]) -> "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( From 3d9b847fc8b16c247cbeceb5adb86e1e45481f5e Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 00:28:22 +0300 Subject: [PATCH 14/69] feat(workshop): redesign item detail page, drop game version label --- src/pxmodrim/ui/plugins/workshop/Workshop.qml | 227 ++++++++++++------ 1 file changed, 150 insertions(+), 77 deletions(-) diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index f66af0d..5693085 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -71,12 +71,6 @@ Rectangle { opacity: workshopPanel.busy ? 1 : 0 Accessible.name: "Loading" } - Copy { - Layout.fillWidth: false - visible: root.width > 1000 - text: workshopPanel.gameVersion ? "Your game: " + workshopPanel.gameVersion : "Game version unknown" - font.pixelSize: Theme.fontSizeSm - } } ColumnLayout { Layout.fillWidth: true @@ -250,91 +244,170 @@ Rectangle { } ColumnLayout { Layout.fillWidth: true + Layout.maximumWidth: 1200 + Layout.alignment: Qt.AlignHCenter visible: workshopPanel.configured && workshopPanel.hasDetail + spacing: 16 PxButton { text: "← Back"; variant: "ghost"; onClicked: workshopPanel.back() } - Rectangle { + RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 240 - color: Theme.elevate2 - radius: Theme.radiusMd - clip: true - Image { - id: detailImage - anchors.fill: parent - source: root.detail.previewUrl || "" - asynchronous: true - sourceSize.width: 1000 - sourceSize.height: 480 - fillMode: Image.PreserveAspectCrop + 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 + Image { + id: detailImage + anchors.fill: parent + source: root.detail.previewUrl || "" + asynchronous: true + sourceSize.width: 1000 + sourceSize.height: 600 + 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: root.detail.collage || [] + Image { + required property string modelData + width: detailCollage.width / detailCollage.columns + height: detailCollage.height / detailCollage.rowCount + source: modelData + asynchronous: true + fillMode: Image.PreserveAspectCrop + sourceSize.width: 480 + sourceSize.height: 300 + } + } + } + Image { + anchors.centerIn: parent + width: 48 + height: 48 + visible: detailImage.status !== Image.Ready && !detailCollage.visible + source: "image://icons/grid?color=" + encodeURIComponent(Theme.textDim) + } } - Image { - anchors.centerIn: parent - width: 48 - height: 48 - visible: detailImage.status !== Image.Ready - 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 === "installed"; 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 { Layout.fillWidth: true; text: "Supports " + (root.detail.versions || ""); font.pixelSize: Theme.fontSizeSm } + Copy { Layout.fillWidth: true; visible: !!root.detail.tags; text: root.detail.tags || ""; font.pixelSize: Theme.fontSizeSm } + Copy { Layout.fillWidth: true; visible: !!root.detail.incompatible; text: "Does not list your game version. Downloading does not make it compatible."; color: Theme.warning; font.pixelSize: Theme.fontSizeSm } + Copy { Layout.fillWidth: true; visible: !!root.detail.warning; text: root.detail.warning || ""; color: Theme.warning; font.pixelSize: Theme.fontSizeSm } + RowLayout { + spacing: 8 + PxButton { + text: root.detail.actionLabel || "Download" + variant: root.detail.state === "outdated" ? "primary" : "secondary" + enabled: !workshopPanel.busy && !workshopPanel.downloading && root.detail.state !== "installed" + 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) } + } + Copy { Layout.fillWidth: true; text: "Local mods · downloading does not enable or subscribe."; font.pixelSize: Theme.fontSizeSm; color: Theme.textDim } + Copy { visible: root.detail.kind === "mod"; text: (root.detail.fileSize || "") + " · " + (root.detail.votes || ""); font.pixelSize: Theme.fontSizeSm } } } - Copy { text: root.detail.sourceLabel || ""; color: Theme.primary; font.pixelSize: Theme.fontSizeSm } - Heading { text: root.detail.title || ""; font.pixelSize: 26 } - Copy { text: "By " + (root.detail.author || "") } - Copy { text: "Versions: " + (root.detail.versions || "") } - Copy { text: root.detail.tags || "" } - Copy { visible: !!root.detail.incompatible; text: "⚠ This item does not support your running game version. Downloading does not make it compatible."; color: Theme.warning } - Copy { visible: !!root.detail.warning; text: root.detail.warning || ""; color: Theme.warning } RowLayout { - PxButton { - text: root.detail.actionLabel || "Download" - variant: "primary" - enabled: !workshopPanel.busy && !workshopPanel.downloading && root.detail.state !== "installed" - onClicked: workshopPanel.downloadItem(root.detail.itemId, root.detail.kind) - } - PxButton { visible: !!root.detail.workshopUrl; text: "View on Steam"; onClicked: workshopPanel.openLink(root.detail.workshopUrl) } - } - Copy { text: "Downloads go to Local mods. Nothing is enabled or subscribed automatically."; font.pixelSize: Theme.fontSizeSm } - Copy { visible: root.detail.kind === "mod"; text: "Size: " + (root.detail.fileSize || "") + " · Votes: " + (root.detail.votes || "") } - Heading { text: "Description" } - Text { Layout.fillWidth: true - text: root.detail.description || "" - textFormat: Text.RichText - wrapMode: Text.Wrap - color: Theme.textMuted - linkColor: Theme.primary - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - onLinkActivated: link => workshopPanel.openLink(link) - } - Heading { text: root.detail.kind === "collection" ? "Collection members (" + catalogMembers.count + ")" : "Required dependencies" } - Copy { visible: catalogMembers.count === 0; text: "None listed." } - Repeater { - model: catalogMembers + spacing: 16 Rectangle { - id: member - required property string itemId - required property string kind - required property string title - required property string versions - required property string stateLabel - required property string state - required property string actionLabel - required property bool incompatible Layout.fillWidth: true - implicitHeight: memberRow.implicitHeight + 24 - radius: Theme.radiusMd + Layout.preferredWidth: 3 + Layout.alignment: Qt.AlignTop + implicitHeight: descColumn.implicitHeight + 32 color: Theme.elevate2 - RowLayout { - id: memberRow + border.color: Theme.border + radius: Theme.radiusMd + ColumnLayout { + id: descColumn anchors.fill: parent - anchors.margins: 12 - ColumnLayout { + anchors.margins: 16 + spacing: 10 + Heading { text: "Description" } + Text { Layout.fillWidth: true - Copy { text: member.title; color: Theme.textMain } - Copy { text: member.stateLabel + " · Versions: " + member.versions; font.pixelSize: Theme.fontSizeSm } - Copy { visible: member.incompatible; text: "⚠ Incompatible with your game"; color: Theme.warning; font.pixelSize: Theme.fontSizeSm } + text: root.detail.description || "" + textFormat: Text.RichText + wrapMode: Text.Wrap + color: Theme.textMuted + linkColor: Theme.primary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + onLinkActivated: link => workshopPanel.openLink(link) + } + } + } + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 2 + Layout.alignment: Qt.AlignTop + implicitHeight: membersColumn.implicitHeight + 32 + color: Theme.elevate2 + border.color: Theme.border + radius: Theme.radiusMd + ColumnLayout { + id: membersColumn + anchors.fill: parent + anchors.margins: 16 + spacing: 8 + Heading { text: root.detail.kind === "collection" ? "Collection members · " + catalogMembers.count : "Required mods · " + catalogMembers.count } + Copy { visible: catalogMembers.count === 0; text: "None listed." } + Repeater { + model: catalogMembers + Rectangle { + id: member + required property string itemId + required property string kind + required property string title + required property string versions + required property string stateLabel + required property string state + required property string actionLabel + required property bool incompatible + Layout.fillWidth: true + implicitHeight: memberRow.implicitHeight + 20 + radius: Theme.radiusSm + color: Theme.elevate3 + RowLayout { + id: memberRow + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Copy { Layout.fillWidth: true; text: member.title; color: Theme.textMain; elide: Text.ElideRight; maximumLineCount: 1 } + Copy { text: member.stateLabel + (member.incompatible ? " · incompatible" : ""); font.pixelSize: Theme.fontSizeSm; color: member.incompatible ? Theme.warning : Theme.textMuted } + } + PxButton { text: "Details"; variant: "ghost"; onClicked: workshopPanel.openItem(member.itemId, member.kind) } + PxButton { visible: member.state !== "installed"; text: member.actionLabel; variant: member.state === "outdated" ? "primary" : "secondary"; enabled: !workshopPanel.busy && !workshopPanel.downloading; onClicked: workshopPanel.downloadItem(member.itemId, member.kind) } + } + } } - PxButton { text: "Details"; variant: "ghost"; onClicked: workshopPanel.openItem(member.itemId, member.kind) } - PxButton { text: member.actionLabel; enabled: !workshopPanel.busy && !workshopPanel.downloading && member.state !== "installed"; onClicked: workshopPanel.downloadItem(member.itemId, member.kind) } } } } From d97239751ed00ca6c46d0f46d657b58fc3b3df65 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 00:33:19 +0300 Subject: [PATCH 15/69] fix(workshop): hide catalog filters on Discover --- src/pxmodrim/ui/plugins/workshop/Workshop.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index 5693085..d59b9b2 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -122,7 +122,7 @@ Rectangle { } RowLayout { Layout.fillWidth: true - visible: workshopPanel.configured && !workshopPanel.hasDetail && workshopPanel.tab !== "Installed" + visible: workshopPanel.configured && !workshopPanel.hasDetail && (workshopPanel.tab === "Mods" || workshopPanel.tab === "Collections") Copy { Layout.fillWidth: false; text: "Game version" } PxComboBox { id: version From fffd2e357ecfe53dc6ff4c762ca930e2735d2221 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 00:38:11 +0300 Subject: [PATCH 16/69] feat(workshop-backend): local full-text search index fed by passive saves and a crawler --- cf-workers/catalog.ts | 8 +- cf-workers/indexer.ts | 62 ++++++++++++++++ cf-workers/migrations/0003_search.sql | 53 +++++++++++++ cf-workers/search.ts | 102 ++++++++++++++++++++++++++ cf-workers/steam.ts | 33 +++++++-- cf-workers/tests/catalog.test.ts | 49 ++++++++++++- cf-workers/worker.ts | 2 + 7 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 cf-workers/indexer.ts create mode 100644 cf-workers/migrations/0003_search.sql create mode 100644 cf-workers/search.ts diff --git a/cf-workers/catalog.ts b/cf-workers/catalog.ts index 4fa131f..a40eb95 100644 --- a/cf-workers/catalog.ts +++ b/cf-workers/catalog.ts @@ -1,4 +1,5 @@ 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'; @@ -228,7 +229,12 @@ async function route(request: Request, env: Env, ctx: ExecutionContext): Promise } if (url.pathname === '/catalog/mods') { requireMethod(request, ['GET']); - return json(await querySteam(env, ctx, url.origin, 'mod', queryOptions(url))); + const query = queryOptions(url); + return json(await searchMods(env.DB, query) ?? await querySteam(env, ctx, url.origin, 'mod', query)); + } + if (url.pathname === '/catalog/index') { + requireMethod(request, ['GET']); + return json(await indexStatus(env.DB)); } if (url.pathname === '/catalog/collections') { requireMethod(request, ['GET']); diff --git a/cf-workers/indexer.ts b/cf-workers/indexer.ts new file mode 100644 index 0000000..6c2291d --- /dev/null +++ b/cf-workers/indexer.ts @@ -0,0 +1,62 @@ +import { queryFiles } from './steam'; +import 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; + +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(); +} + +/** + * 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; 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) 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(env.DB, state); + return; + } +} 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/search.ts b/cf-workers/search.ts new file mode 100644 index 0000000..0905a26 --- /dev/null +++ b/cf-workers/search.ts @@ -0,0 +1,102 @@ +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 = matchExpression(query.query); + const resumed = CURSOR.exec(query.cursor); + if (!order || !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]; + 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 = `FROM catalog_fts JOIN catalog_index ON catalog_index.rowid = catalog_fts.rowid + WHERE catalog_fts MATCH ? ${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 index 57d4510..da4f0ed 100644 --- a/cf-workers/steam.ts +++ b/cf-workers/steam.ts @@ -1,3 +1,4 @@ +import { indexMods } from './search'; import { HttpError } from './types'; import type { Author, CatalogCollection, CatalogItem, CatalogMod, CatalogPage, Env, Preview, SteamFile } from './types'; @@ -208,6 +209,8 @@ async function saveItems(db: D1Database, items: CatalogItem[]): Promise { '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 getSteamItems(env: Env, requestedIds: string[]): Promise<{ items: Map; unavailable_ids: string[] }> { @@ -267,13 +270,15 @@ export async function attachMemberPreviews(env: Env, collections: CatalogCollect }); } -export async function querySteam(env: Env, ctx: ExecutionContext, origin: string, kind: K, query: SteamQuery): Promise> { - const cacheUrl = new URL(`/__catalog_cache/${kind}`, origin); - cacheUrl.searchParams.set('query', JSON.stringify(query)); - const cacheKey = new Request(cacheUrl); - const cached = await caches.default.match(cacheKey); - if (cached) return await cached.json(); +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'); @@ -309,11 +314,23 @@ export async function querySteam(env: Env, ctx: const items = files.map(file => normalized(file, authors)); await saveItems(env.DB, items); const cursor = typeof response.next_cursor === 'string' ? response.next_cursor : ''; - const page = { + 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; +} + +export async function querySteam(env: Env, ctx: ExecutionContext, origin: string, kind: K, query: SteamQuery): Promise> { + const cacheUrl = new URL(`/__catalog_cache/${kind}`, origin); + cacheUrl.searchParams.set('query', JSON.stringify(query)); + const cacheKey = new Request(cacheUrl); + const cached = await caches.default.match(cacheKey); + if (cached) return await cached.json(); + + const { items, total, next_cursor } = await queryFiles(env, kind, query); + const page = { items, total, next_cursor }; ctx.waitUntil(caches.default.put(cacheKey, Response.json(page, { headers: { 'Cache-Control': `public, max-age=${QUERY_TTL_SECONDS}` } }))); return page as CatalogPage; } diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index 61f7be3..fb25559 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -70,10 +70,21 @@ async function upstream(request: Request): Promise { 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']) { + for (const file of ['0001_dependencies.sql', '0002_catalog.sql', '0003_search.sql']) { const sql = await readFile(resolvePath('migrations', file), 'utf8'); - await database.batch(sql.split(';').map(statement => statement.trim()).filter(Boolean).map(statement => database.prepare(statement))); + await database.batch(statements(sql).map(statement => database.prepare(statement))); } } @@ -334,3 +345,37 @@ test('collections without a Steam image get a collage from their member mods, sk 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('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); +}); diff --git a/cf-workers/worker.ts b/cf-workers/worker.ts index a37e91b..71dd59e 100644 --- a/cf-workers/worker.ts +++ b/cf-workers/worker.ts @@ -1,4 +1,5 @@ import { handleCatalogRequest } from './catalog'; +import { indexTick } from './indexer'; import type { DependencyMessage, Env, SteamFile } from './types'; interface DependencyItem { @@ -857,6 +858,7 @@ export default { 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: MessageBatch, env: Env): Promise { From a9afbac96b5be6c68f2f7d5fbc4ead176b94615d Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 00:48:53 +0300 Subject: [PATCH 17/69] feat(workshop): queue downloads in background, serve cached pages first, page the Installed tab --- CHANGELOG.md | 2 +- src/pxmodrim/core/downloads/manager.py | 4 + src/pxmodrim/core/workshop/catalog.py | 117 ++++++- .../ui/plugins/workshop/CatalogCard.qml | 3 +- src/pxmodrim/ui/plugins/workshop/Workshop.qml | 43 +-- src/pxmodrim/ui/plugins/workshop/models.py | 2 + src/pxmodrim/ui/plugins/workshop/view.py | 303 +++++++++++------- tests/test_ui/test_workshop_view.py | 6 +- tests/test_workshop/test_catalog.py | 58 ++++ 9 files changed, 379 insertions(+), 159 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc63844..515042c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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). ### Added -- Native QML Workshop browser: Discover, Mods, Collections and Installed views, catalog filters and detail pages, dependency-aware mod/collection downloads into Local mods, download queue progress, compatibility warnings and updates for downloader-managed copies. 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. +- 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. - 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. diff --git a/src/pxmodrim/core/downloads/manager.py b/src/pxmodrim/core/downloads/manager.py index 4d8a284..8b1ec04 100644 --- a/src/pxmodrim/core/downloads/manager.py +++ b/src/pxmodrim/core/downloads/manager.py @@ -131,6 +131,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/workshop/catalog.py b/src/pxmodrim/core/workshop/catalog.py index 90ebbe2..4ca9e5a 100644 --- a/src/pxmodrim/core/workshop/catalog.py +++ b/src/pxmodrim/core/workshop/catalog.py @@ -1,7 +1,11 @@ from __future__ import annotations -from collections.abc import Callable -from typing import TYPE_CHECKING, ClassVar +import asyncio +import time +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import TYPE_CHECKING, Any, ClassVar + +from loguru import logger from pxmodrim.core.downloads.manager import DownloadManager, download_manager from pxmodrim.core.events import Event @@ -24,6 +28,9 @@ from pxmodrim.core.downloads.types import DownloadResult from pxmodrim.core.models.metadata.structures import ListedMod +_FRESH_SECONDS = 300.0 +_CACHE_LIMIT = 200 + class WorkshopCatalog(Plugin): name = "workshop_catalog" @@ -35,6 +42,11 @@ def __init__( 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._config = config_accessor self._client_factory = client_factory @@ -43,21 +55,27 @@ def __init__( 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]] = {} def setup(self, ctx: CoreContext) -> None: self._ctx = ctx 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._change_url(config.workshop_catalog_url) self.installed_changed.emit(None) @@ -90,11 +108,16 @@ def _catalog_client(self) -> CatalogClient: 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() @@ -118,12 +141,38 @@ async def collection(self, id: str) -> CollectionDetail | None: def _installed(self) -> dict[str, list[ListedMod]]: if self._ctx is None: raise RuntimeError("Workshop catalog accessed before setup") - 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) - return installed + 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, + ) -> 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. + """ + key = f"{self._base_url}|{key}" + entry = self._responses.get(key) + if entry is not None: + yield entry[1] + if time.monotonic() - entry[0] < fresh_for: + return + value = await produce() + self._responses.pop(key, None) + self._responses[key] = (time.monotonic(), value) + while len(self._responses) > _CACHE_LIMIT: + del self._responses[next(iter(self._responses))] + if entry is None or value != entry[1]: + yield value def _install_state( self, mod: CatalogMod, installed: dict[str, list[ListedMod]] @@ -178,3 +227,55 @@ 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/ui/plugins/workshop/CatalogCard.qml b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml index fbc6e00..b52ef13 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml +++ b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml @@ -17,6 +17,7 @@ Rectangle { required property string state required property string actionLabel required property bool incompatible + required property bool queued required property int memberCount color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 @@ -150,7 +151,7 @@ Rectangle { PxButton { text: card.actionLabel variant: card.state === "outdated" ? "primary" : "secondary" - enabled: !workshopPanel.busy && !workshopPanel.downloading && card.state !== "installed" + enabled: card.state !== "installed" && !card.queued onClicked: workshopPanel.downloadItem(card.itemId, card.kind) } } diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index d59b9b2..8061467 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -59,16 +59,16 @@ Rectangle { placeholderText: "Search mods or collections…" Accessible.name: "Search Workshop" maximumLength: 200 - enabled: workshopPanel.configured && !workshopPanel.busy + enabled: workshopPanel.configured onAccepted: root.applyFilters() } - PxButton { text: "Search"; enabled: workshopPanel.configured && !workshopPanel.busy; onClicked: root.applyFilters() } + PxButton { text: "Search"; enabled: workshopPanel.configured; onClicked: root.applyFilters() } PxProgressBar { Layout.preferredWidth: 48 Layout.alignment: Qt.AlignVCenter thickness: 3 indeterminate: true - opacity: workshopPanel.busy ? 1 : 0 + opacity: workshopPanel.busy || workshopPanel.planning ? 1 : 0 Accessible.name: "Loading" } } @@ -86,7 +86,6 @@ Rectangle { readonly property bool current: workshopPanel.tab === modelData implicitWidth: tabLabel.implicitWidth + 32 implicitHeight: 36 - opacity: workshopPanel.busy && !current ? Theme.disabledOpacity : 1 Text { id: tabLabel anchors.centerIn: parent @@ -104,7 +103,7 @@ Rectangle { color: tabItem.current ? Theme.primary : "transparent" } HoverHandler { id: tabHover; cursorShape: Qt.PointingHandCursor } - TapHandler { enabled: !workshopPanel.busy; onTapped: workshopPanel.selectTab(tabItem.modelData) } + TapHandler { onTapped: workshopPanel.selectTab(tabItem.modelData) } Accessible.role: Accessible.PageTab Accessible.name: modelData } @@ -114,7 +113,7 @@ Rectangle { visible: workshopPanel.tab === "Installed" text: "Update all (" + workshopPanel.updateCount + ")" variant: "primary" - enabled: !workshopPanel.busy && !workshopPanel.downloading && workshopPanel.updateCount > 0 + enabled: workshopPanel.updateCount > 0 && !workshopPanel.planning onClicked: workshopPanel.updateAll() } } @@ -129,7 +128,6 @@ Rectangle { model: workshopPanel.versionOptions Accessible.name: "Game version filter" Component.onCompleted: currentIndex = Math.max(0, model.indexOf(workshopPanel.gameVersion)) - enabled: !workshopPanel.busy onActivated: root.applyFilters() } PxComboBox { @@ -139,7 +137,6 @@ Rectangle { valueRole: "value" model: [ {text: "All sources", value: "all"}, {text: "Steam collections", value: "steam"}, {text: "PxModRim picks", value: "picked"} ] Accessible.name: "Collection source" - enabled: !workshopPanel.busy onActivated: root.applyFilters() } PxTextField { @@ -149,7 +146,6 @@ Rectangle { placeholderText: "Tag (exact match)" maximumLength: 100 Accessible.name: "Tag filter" - enabled: !workshopPanel.busy onAccepted: root.applyFilters() } Item { Layout.fillWidth: true } @@ -159,7 +155,6 @@ Rectangle { 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" - enabled: !workshopPanel.busy onActivated: root.applyFilters() } } @@ -190,7 +185,7 @@ Rectangle { Copy { text: workshopPanel.notice; color: workshopPanel.pendingPlan ? Theme.warning : Theme.textMuted } RowLayout { visible: workshopPanel.pendingPlan - PxButton { text: "Download available mods"; enabled: workshopPanel.canDownloadAvailable && !workshopPanel.busy && !workshopPanel.downloading; onClicked: workshopPanel.downloadAvailable() } + PxButton { text: "Download available mods"; enabled: workshopPanel.canDownloadAvailable; onClicked: workshopPanel.downloadAvailable() } PxButton { text: "Cancel"; variant: "ghost"; onClicked: workshopPanel.dismissPlan() } } } @@ -237,7 +232,7 @@ Rectangle { } PxButton { text: "Load more" - visible: workshopPanel.tab === "Collections" ? catalogCollections.hasMore : workshopPanel.tab === "Mods" && catalogMods.hasMore + visible: workshopPanel.tab === "Collections" ? catalogCollections.hasMore : (workshopPanel.tab === "Mods" || workshopPanel.tab === "Installed") && catalogMods.hasMore enabled: !workshopPanel.busy onClicked: workshopPanel.loadMore() } @@ -322,7 +317,7 @@ Rectangle { PxButton { text: root.detail.actionLabel || "Download" variant: root.detail.state === "outdated" ? "primary" : "secondary" - enabled: !workshopPanel.busy && !workshopPanel.downloading && root.detail.state !== "installed" + 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) } @@ -388,6 +383,7 @@ Rectangle { required property string state required property string actionLabel required property bool incompatible + required property bool queued Layout.fillWidth: true implicitHeight: memberRow.implicitHeight + 20 radius: Theme.radiusSm @@ -404,7 +400,7 @@ Rectangle { Copy { text: member.stateLabel + (member.incompatible ? " · incompatible" : ""); font.pixelSize: Theme.fontSizeSm; color: member.incompatible ? Theme.warning : Theme.textMuted } } PxButton { text: "Details"; variant: "ghost"; onClicked: workshopPanel.openItem(member.itemId, member.kind) } - PxButton { visible: member.state !== "installed"; text: member.actionLabel; variant: member.state === "outdated" ? "primary" : "secondary"; enabled: !workshopPanel.busy && !workshopPanel.downloading; onClicked: workshopPanel.downloadItem(member.itemId, member.kind) } + PxButton { visible: member.state !== "installed"; text: member.actionLabel; variant: member.state === "outdated" ? "primary" : "secondary"; enabled: !member.queued; onClicked: workshopPanel.downloadItem(member.itemId, member.kind) } } } } @@ -414,25 +410,6 @@ Rectangle { } } } - Rectangle { - Layout.fillWidth: true - visible: workshopPanel.downloading - implicitHeight: downloadRow.implicitHeight + 24 - color: Theme.elevate2 - radius: Theme.radiusMd - ColumnLayout { - id: downloadRow - anchors.fill: parent - anchors.margins: 12 - RowLayout { - Layout.fillWidth: true - Copy { Layout.fillWidth: true; text: "Downloading · " + workshopPanel.queueSummary; font.pixelSize: Theme.fontSizeSm } - PxButton { text: "Stop"; variant: "danger"; onClicked: workshopPanel.stop() } - PxButton { text: "Open Downloads"; variant: "ghost"; onClicked: workshopPanel.openDownloads() } - } - PxProgressBar { Layout.fillWidth: true; value: workshopPanel.queueProgress } - } - } } Component.onCompleted: workshopPanel.refresh() } diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/src/pxmodrim/ui/plugins/workshop/models.py index 9f0344a..d1eed53 100644 --- a/src/pxmodrim/ui/plugins/workshop/models.py +++ b/src/pxmodrim/ui/plugins/workshop/models.py @@ -138,6 +138,7 @@ def item_row( "versions": ", ".join(item.supported_versions) or "Not specified", "tags": " · ".join(item.tags), "state": state, + "queued": False, "stateLabel": { "missing": "Not installed", "installed": "Installed", @@ -174,6 +175,7 @@ class CatalogListModel(QAbstractListModel): "workshopUrl", "fileSize", "votes", + "queued", ) def __init__(self, parent: QObject) -> None: diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index 13966ce..f1a1ae0 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -1,10 +1,12 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, cast +from loguru import logger from PySide6.QtCore import Property, QUrl, Signal, Slot from PySide6.QtGui import QColor, QDesktopServices from PySide6.QtQuickWidgets import QQuickWidget @@ -31,16 +33,25 @@ from PySide6.QtWidgets import QWidget from pxmodrim.core.context import CoreContext - from pxmodrim.core.downloads import DownloadProgress from pxmodrim.ui.context import AppContext +_PAGE = 48 + + +@dataclass(frozen=True) +class _Detail: + item: CatalogMod | CatalogCollection + members: tuple[CatalogMod | CatalogCollection, ...] + warning: str + complete: bool + + class WorkshopViewPanel(BaseViewPanel): view_id = "workshop" icon_name = "steam" label = "Workshop" changed = Signal() - queueChanged = Signal() def __init__( self, @@ -75,9 +86,10 @@ def __init__( self._pending: DownloadPlan | None = None self._titles: dict[str, str] = {} self._items: dict[str, CatalogMod | CatalogCollection] = {} - self._downloading = self._downloads.is_downloading - self._queue_progress = 0.0 - self._queue_summary = "" + self._planning = 0 + self._update_count = 0 + self._installed_view: list[CatalogMod] = [] + self._installed_shown = _PAGE self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] self._qml.setObjectName("workshopView") self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) @@ -94,8 +106,8 @@ def __init__( QUrl.fromLocalFile(str(Path(__file__).with_name("Workshop.qml"))) ) self._root.addWidget(self._qml, 1) - self._downloads.busy_changed.connect(self._on_busy) - self._downloads.download_progress.connect(self._on_progress) + 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) @@ -104,7 +116,13 @@ def _row(self, item: CatalogMod | CatalogCollection) -> dict[str, Any]: version = ( "" if self._ctx.target_version == "Unknown" else self._ctx.target_version ) - return item_row(item, self._catalog.install_state, version) + row = item_row(item, self._catalog.install_state, version) + if isinstance(item, CatalogMod) and row["state"] != "installed": + running = item.id in self._downloads.active_ids + if running or item.id in self._catalog.queued_ids: + row["queued"] = True + row["actionLabel"] = "Downloading" if running else "Queued" + return row async def _run( self, fetch: Callable[[], Awaitable[Any]], apply: Callable[[Any], None] @@ -133,12 +151,50 @@ async def retry() -> None: self._busy = False self.changed.emit() + async def _run_stream( + self, + stream: Callable[[], AsyncIterator[Any]], + apply: Callable[[Any], None], + ) -> None: + 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 (CatalogError, RuntimeError, ValueError) as exc: + message = exc.message if isinstance(exc, CatalogError) else str(exc) + if generation == self._generation: + if shown: + logger.warning("[workshop] background refresh failed: {}", message) + else: + self._error = message + finally: + if generation == self._generation: + self._busy = False + self.changed.emit() + async def load(self, *, append: bool = False) -> None: if not self._catalog.configured: self._generation += 1 self._busy = False self.changed.emit() return + if append and self._tab == "Installed": + self._show_more_installed() + return self._detail = {} model = ( self.collections_model if self._tab == "Collections" else self.mods_model @@ -165,20 +221,34 @@ def apply_discover(result: Any) -> None: None, ) - await self._run(self._catalog.discover, apply_discover) + await self._run_stream( + lambda: self._catalog.cached("discover", self._catalog.discover), + apply_discover, + ) elif self._tab == "Installed": def apply_installed(items: list[CatalogMod]) -> None: self._installed = items - rows = [ - self._row(item) + self._recount() + needle = self._query.casefold() + self._installed_view = [ + item for item in items - if not self._query - or self._query.casefold() in item.title.casefold() + if not needle or needle in item.title.casefold() ] - self.mods_model.set_page(rows, len(rows), None) + shown = self._installed_view[: self._installed_shown] + self.mods_model.set_page( + [self._row(item) for item in shown], + len(self._installed_view), + "more" if len(shown) < len(self._installed_view) else None, + ) - await self._run(self._catalog.installed_with_updates, apply_installed) + await self._run_stream( + lambda: self._catalog.cached( + "installed", self._catalog.installed_with_updates, fresh_for=0 + ), + apply_installed, + ) else: fetch = ( self._catalog.collections @@ -194,7 +264,26 @@ def apply_page(page: Any) -> None: append=append, ) - await self._run(lambda: fetch(query), apply_page) + if append: + await self._run(lambda: fetch(query), apply_page) + else: + await self._run_stream( + lambda: self._catalog.cached( + f"{self._tab}|{query!r}", lambda: fetch(query) + ), + apply_page, + ) + + def _show_more_installed(self) -> None: + start = self.mods_model.rowCount() + chunk = self._installed_view[start : start + _PAGE] + self._installed_shown = start + len(chunk) + self.mods_model.set_page( + [self._row(item) for item in chunk], + len(self._installed_view), + "more" if self._installed_shown < len(self._installed_view) else None, + append=True, + ) @asyncSlot() async def refresh(self) -> None: @@ -203,12 +292,14 @@ async def refresh(self) -> None: @asyncSlot(str) async def selectTab(self, tab: str) -> None: self._tab, self._notice, self._pending = tab, "", None + self._installed_shown = _PAGE 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._installed_shown = _PAGE self._query, self._version, self._source, self._sort, self._tag = ( query, version, @@ -234,53 +325,59 @@ async def openItem(self, item_id: str, kind: str) -> None: await self.open_item(item_id, kind) async def open_item(self, item_id: str, kind: str) -> None: - async def fetch() -> tuple[dict[str, Any], list[dict[str, Any]]]: + async def fetch() -> _Detail: if kind == "collection": detail = await self._catalog.collection(item_id) if detail is None: raise CatalogError("This collection is no longer available.", 404) - row = self._row(detail.collection) - row["complete"] = detail.is_complete - row["warning"] = ( + warning = ( "" if detail.is_complete else "This collection is incomplete. " "Some members are unavailable or not listed." ) if detail.unavailable_ids: - row["warning"] += " Unavailable: " + ", ".join( - detail.unavailable_ids - ) - members = [self._row(item) for item in detail.members] - row["actionLabel"] = ( - "Complete download" - if any(member["state"] != "missing" for member in members) - else "Download all" + warning += " Unavailable: " + ", ".join(detail.unavailable_ids) + return _Detail( + detail.collection, + tuple(detail.members), + warning, + detail.is_complete, ) - return row, members mod = await self._catalog.mod(item_id) if mod is None: raise CatalogError("This mod is no longer available.", 404) dependencies = await asyncio.gather( *(self._catalog.mod(pid) for pid in mod.dependencies) ) - row = self._row(mod) missing = [ pid for pid, item in zip(mod.dependencies, dependencies, strict=True) if item is None ] - row["warning"] = ( - "Unavailable dependencies: " + ", ".join(missing) if missing else "" + return _Detail( + mod, + tuple(item for item in dependencies if item is not None), + "Unavailable dependencies: " + ", ".join(missing) if missing else "", + not missing, ) - row["complete"] = not missing - return row, [self._row(item) for item in dependencies if item is not None] - def apply(result: tuple[dict[str, Any], list[dict[str, Any]]]) -> None: - self._detail, members = result + def apply(detail: _Detail) -> None: + row = self._row(detail.item) + members = [self._row(item) for item in detail.members] + row["complete"], row["warning"] = detail.complete, detail.warning + if isinstance(detail.item, CatalogCollection): + row["actionLabel"] = ( + "Complete download" + if any(member["state"] != "missing" for member in members) + else "Download all" + ) + self._detail = row self.members_model.set_page(members, len(members), None) - await self._run(fetch, apply) + await self._run_stream( + lambda: self._catalog.cached(f"detail|{kind}|{item_id}", fetch), apply + ) @Slot() def back(self) -> None: @@ -288,39 +385,44 @@ def back(self) -> None: self._detail, self._error, self._busy = {}, "", False self.changed.emit() - async def _download(self, ids: list[str], collections: list[str]) -> None: - self._notice, self._pending = "", None + def _queue_plan(self, plan: DownloadPlan) -> None: + self._catalog.enqueue(plan) + count = len(plan.to_download) + self._notice = ( + f"Queued {count} mod{'s' if count != 1 else ''}. Progress is shown " + "in the header and results in Downloads. Mods are not activated." + ) - async def fetch() -> DownloadPlan: + async def _download(self, ids: list[str], collections: list[str]) -> None: + self._notice, self._pending = "Preparing download…", None + self._planning += 1 + self.changed.emit() + try: plan = await self._catalog.plan(ids, collections) - self._titles.update(plan.titles) - if plan.is_complete and plan.to_download: - await self._catalog.download(plan) - return plan - - def apply(plan: DownloadPlan) -> None: - if not plan.is_complete: - self._pending = plan - 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) - ) - self._notice = " ".join(parts) - elif not plan.to_download: - self._notice = ( - "All available mods are already installed and up to date." - ) - else: - self._notice = ( - "Download finished. See Downloads for item results. " - "Mods were not activated." + except (CatalogError, RuntimeError, ValueError) as exc: + message = exc.message if isinstance(exc, CatalogError) else str(exc) + self._notice = f"Could not prepare the download: {message}" + return + finally: + self._planning -= 1 + self.changed.emit() + self._titles.update(plan.titles) + if not plan.is_complete: + self._pending = plan + 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) ) - - await self._run(fetch, apply) + self._notice = " ".join(parts) + 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() @asyncSlot(str, str) async def downloadItem(self, item_id: str, kind: str) -> None: @@ -343,14 +445,8 @@ async def updateAll(self) -> None: async def downloadAvailable(self) -> None: plan, self._pending = self._pending, None if plan and plan.to_download: - await self._run( - lambda: self._catalog.download(plan), - lambda _: setattr( - self, - "_notice", - "Download finished. See Downloads for item results.", - ), - ) + self._queue_plan(plan) + self.changed.emit() @Slot() def dismissPlan(self) -> None: @@ -362,11 +458,6 @@ def openLink(self, url: str) -> None: if safe_url(url): QDesktopServices.openUrl(QUrl(url)) - @Slot() - def openDownloads(self) -> None: - if self._app_ctx: - self._app_ctx.navigate("downloads") - @asyncSlot() async def openSettings(self) -> None: if self._qml_engine is None: @@ -378,22 +469,9 @@ async def openSettings(self) -> None: self._ctx.update_config(dialog.get_config()) dialog.deleteLater() - @Slot() - def stop(self) -> None: - self._downloads.cancel() - - def _on_busy(self, busy: bool) -> None: - self._downloading = busy - if busy: - self._queue_progress, self._queue_summary = 0.0, "Starting…" - self.queueChanged.emit() - - def _on_progress(self, progress: DownloadProgress) -> None: - self._queue_progress = ( - progress.completed / progress.total if progress.total else 0.0 - ) - self._queue_summary = f"{progress.completed} / {progress.total} mods" - self.queueChanged.emit() + def _on_queue_changed(self, *_: object) -> None: + self._refresh_rows() + self.changed.emit() @asyncSlot(str) async def _on_url_changed(self, _url: str) -> None: @@ -404,8 +482,12 @@ async def _on_url_changed(self, _url: str) -> None: self.members_model.set_page([], 0, None) await self.load() - @asyncSlot(object) - async def _on_installed_changed(self, _value: None) -> None: + def _recount(self) -> None: + self._update_count = sum( + self._catalog.install_state(item) == "outdated" for item in self._installed + ) + + def _refresh_rows(self) -> None: def convert(row: dict[str, Any]) -> dict[str, Any]: return self._row(self._items[row["itemId"]]) @@ -424,6 +506,11 @@ def convert(row: dict[str, Any]) -> dict[str, Any]: self._detail["actionLabel"] = ( "Complete download" if partially_installed else "Download all" ) + + @asyncSlot(object) + async def _on_installed_changed(self, _value: None) -> None: + self._recount() + self._refresh_rows() self.changed.emit() if self._tab == "Installed" and not self._busy: await self.load() @@ -485,18 +572,8 @@ def versionOptions(self) -> list[str]: @Property(int, notify=changed) # type: ignore[arg-type] def updateCount(self) -> int: - return sum( - self._catalog.install_state(item) == "outdated" for item in self._installed - ) - - @Property(bool, notify=queueChanged) # type: ignore[arg-type] - def downloading(self) -> bool: - return self._downloading + return self._update_count - @Property(float, notify=queueChanged) # type: ignore[arg-type] - def queueProgress(self) -> float: - return self._queue_progress - - @Property(str, notify=queueChanged) # type: ignore[arg-type] - def queueSummary(self) -> str: - return self._queue_summary + @Property(bool, notify=changed) # type: ignore[arg-type] + def planning(self) -> bool: + return self._planning > 0 diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index 533fa27..52e63ec 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -11,7 +11,7 @@ from pxmodrim.core.config import AppConfig, ConfigService from pxmodrim.core.context import CoreContext -from pxmodrim.core.downloads import DownloadManager, DownloadResult +from pxmodrim.core.downloads import DownloadManager from pxmodrim.core.workshop import ( Author, CatalogCollection, @@ -124,9 +124,8 @@ async def plan(self, mod_ids: list[str], collection_ids: list[str]) -> DownloadP not collection_ids, ) - async def download(self, plan: DownloadPlan) -> DownloadResult: + def enqueue(self, plan: DownloadPlan) -> None: self.downloaded.extend(plan.to_download) - return DownloadResult(plan.to_download, []) @pytest.fixture @@ -175,6 +174,7 @@ async def test_view_paging_filters_and_catalog_errors( catalog.queries[-1].cursor == "second" and catalog.queries[-1].version == "1.6" ) catalog.failure = True + view._query = "uncached" await view.load() assert view.error == "Catalog unavailable" catalog.failure = False diff --git a/tests/test_workshop/test_catalog.py b/tests/test_workshop/test_catalog.py index 53c44b2..7113433 100644 --- a/tests/test_workshop/test_catalog.py +++ b/tests/test_workshop/test_catalog.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from pathlib import Path from typing import Any @@ -242,3 +243,60 @@ def factory(url: str) -> CatalogClient: 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(lambda: ctx.config) + 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(lambda: ctx.config) + 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() From 9a40c87e2962729503ad08aa76a4309619538e15 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 00:51:19 +0300 Subject: [PATCH 18/69] feat(workshop-backend): answer browse reads from stored data and refresh in the background --- cf-workers/README.md | 5 ++- cf-workers/catalog.ts | 12 ++--- cf-workers/steam.ts | 75 +++++++++++++++++++++++--------- cf-workers/tests/catalog.test.ts | 19 ++++++++ 4 files changed, 82 insertions(+), 29 deletions(-) diff --git a/cf-workers/README.md b/cf-workers/README.md index 7671b95..6bc7b5c 100644 --- a/cf-workers/README.md +++ b/cf-workers/README.md @@ -67,7 +67,7 @@ Exports remain `fetch`, `scheduled`, and `queue`. Catalog routes use Steam direc 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`. Internally, Steam query pages are cached at the edge for five minutes, and rich metadata is cached in D1 for fifteen minutes. Curated listings are read from D1 on each request, so writes and deletes are not hidden by a public response cache. +Catalog responses are JSON with `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`. Steam query pages are cached at the edge and metadata 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 up to a day). 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. ### Discovery and browsing @@ -101,7 +101,7 @@ Discovery does not accept a continuation cursor. Start the browsing endpoints wi | 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 uses the fifteen-minute cache; this endpoint does not promise an uncached Steam refresh. The client can compare each mod's `updated_at` against its installed copy. +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. @@ -163,6 +163,7 @@ JSON requests require `application/json` and are bounded to 64 KiB, including st - `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. Until then, and for cursors that do not start with `ix:`, text searches go to Steam. Every mod the catalog fetches for any reason is indexed too. The catalog cache is separate from `items`: the legacy resolver intentionally strips nested collection children in some paths; catalog resolution retains them. diff --git a/cf-workers/catalog.ts b/cf-workers/catalog.ts index a40eb95..e691fc3 100644 --- a/cf-workers/catalog.ts +++ b/cf-workers/catalog.ts @@ -147,8 +147,8 @@ async function collectionPage(env: Env, ctx: ExecutionContext, url: URL, query: }; } -async function collectionDetails(env: Env, collection: CatalogCollection): Promise { - const { items, unavailable_ids } = await getSteamItems(env, collection.member_ids); +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); @@ -261,7 +261,7 @@ async function route(request: Request, env: Env, ctx: ExecutionContext): Promise if (modMatch) { requireMethod(request, ['GET']); const id = modMatch[1]!; validateSteamId(id); - const result = await getSteamItems(env, [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); @@ -272,10 +272,10 @@ async function route(request: Request, env: Env, ctx: ExecutionContext): Promise const id = collectionMatch[2]!; if (source === 'steam') { requireMethod(request, ['GET']); validateSteamId(id); - const result = await getSteamItems(env, [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, item); + return await collectionDetails(env, ctx, item); } validatePickSlug(id); requireMethod(request, ['GET', 'PUT', 'DELETE']); @@ -295,7 +295,7 @@ async function route(request: Request, env: Env, ctx: ExecutionContext): Promise } const collection = await getPick(env.DB, id); if (!collection) throw new HttpError(404, 'Picked collection not found'); - return await collectionDetails(env, collection); + return await collectionDetails(env, ctx, collection); } throw new HttpError(404, 'Catalog endpoint not found'); } diff --git a/cf-workers/steam.ts b/cf-workers/steam.ts index da4f0ed..dc756c4 100644 --- a/cf-workers/steam.ts +++ b/cf-workers/steam.ts @@ -213,18 +213,10 @@ async function saveItems(db: D1Database, items: CatalogItem[]): Promise { console.error('Search index update failed', error instanceof Error ? error.message : error)); } -export async function getSteamItems(env: Env, requestedIds: string[]): Promise<{ items: Map; unavailable_ids: string[] }> { - const ids = [...new Set(requestedIds)]; - const items = new Map(); - 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 FROM catalog_items WHERE id IN (${chunk.map(() => '?').join(',')}) AND updated_at > ?`) - .bind(...chunk, Date.now() - DETAIL_TTL_MS).all<{ id: string; data: string }>(); - for (const row of rows.results) items.set(row.id, JSON.parse(row.data) as CatalogItem); - } - const missing = ids.filter(id => !items.has(id)); - for (let offset = 0; offset < missing.length; offset += API_BATCH_SIZE) { - const chunk = missing.slice(offset, offset + API_BATCH_SIZE); +async function fetchItems(env: Env, ids: string[]): Promise> { + const fetched = new Map(); + 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, @@ -237,10 +229,36 @@ export async function getSteamItems(env: Env, requestedIds: string[]): Promise<{ })); const files = fileDetails(responseObject(await steamJson(url))).filter(file => chunk.includes(file.publishedfileid) && publicFile(file)); const authors = await authorsFor(env, files); - const fetched = files.map(file => normalized(file, authors)); - await saveItems(env.DB, fetched); - for (const item of fetched) items.set(item.kind === 'collection' ? item.steam_id! : item.id, item); + const items = files.map(file => normalized(file, authors)); + await saveItems(env.DB, items); + for (const item of items) fetched.set(item.kind === 'collection' ? item.steam_id! : item.id, item); } + 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(fetchItems(env, stale).then(() => undefined).catch(error => console.error('Background refresh failed', error instanceof Error ? error.message : error))); + } + const fetched = await fetchItems(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)) }; } @@ -322,15 +340,30 @@ export async function queryFiles(env: Env, kind: } as FilesPage; } +const QUERY_STALE_SECONDS = 24 * 60 * 60; + export async function querySteam(env: Env, ctx: ExecutionContext, origin: string, kind: K, query: SteamQuery): Promise> { + type Page = CatalogPage; const cacheUrl = new URL(`/__catalog_cache/${kind}`, origin); cacheUrl.searchParams.set('query', JSON.stringify(query)); const cacheKey = new Request(cacheUrl); - const cached = await caches.default.match(cacheKey); - if (cached) return await cached.json(); - const { items, total, next_cursor } = await queryFiles(env, kind, query); - const page = { items, total, next_cursor }; - ctx.waitUntil(caches.default.put(cacheKey, Response.json(page, { headers: { 'Cache-Control': `public, max-age=${QUERY_TTL_SECONDS}` } }))); - return page as CatalogPage; + const refresh = async (): Promise => { + const { items, total, next_cursor } = await queryFiles(env, kind, query); + const page = { items, total, next_cursor } as Page; + await caches.default.put(cacheKey, Response.json(page, { + headers: { 'Cache-Control': `public, max-age=${QUERY_STALE_SECONDS}`, 'X-Cached-At': String(Date.now()) }, + })); + return page; + }; + + const cached = await caches.default.match(cacheKey); + if (cached) { + const age = Date.now() - Number(cached.headers.get('X-Cached-At') ?? 0); + if (age > QUERY_TTL_SECONDS * 1000) { + ctx.waitUntil(refresh().catch(error => console.error('Background refresh failed', error instanceof Error ? error.message : error))); + } + return await cached.json(); + } + return await refresh(); } diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index fb25559..313cdb8 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -379,3 +379,22 @@ test('text search is answered by the local index only after a full crawl cycle, 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'); +}); From cb78b47e06c3d447ba635991b9a4f2c6ae3fd916 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 00:57:44 +0300 Subject: [PATCH 19/69] feat(workshop-backend): keep query pages in D1 and serve browsing from the index when ready --- cf-workers/README.md | 5 +- cf-workers/catalog.ts | 10 ++-- cf-workers/migrations/0004_query_cache.sql | 9 ++++ cf-workers/search.ts | 11 +++-- cf-workers/steam.ts | 53 +++++++++++++++------- cf-workers/tests/catalog.test.ts | 35 +++++++++++++- src/pxmodrim/ui/plugins/workshop/view.py | 36 ++++++++++----- 7 files changed, 120 insertions(+), 39 deletions(-) create mode 100644 cf-workers/migrations/0004_query_cache.sql diff --git a/cf-workers/README.md b/cf-workers/README.md index 6bc7b5c..da4d927 100644 --- a/cf-workers/README.md +++ b/cf-workers/README.md @@ -67,7 +67,7 @@ Exports remain `fetch`, `scheduled`, and `queue`. Catalog routes use Steam direc 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 are cached at the edge and metadata 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 up to a day). 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. +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. ### Discovery and browsing @@ -163,7 +163,8 @@ JSON requests require `application/json` and are bounded to 64 KiB, including st - `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. Until then, and for cursors that do not start with `ix:`, text searches go to Steam. Every mod the catalog fetches for any reason is indexed too. +- `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. +- `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. The catalog cache is separate from `items`: the legacy resolver intentionally strips nested collection children in some paths; catalog resolution retains them. diff --git a/cf-workers/catalog.ts b/cf-workers/catalog.ts index e691fc3..2f7779a 100644 --- a/cf-workers/catalog.ts +++ b/cf-workers/catalog.ts @@ -117,6 +117,10 @@ function decodeCursor(value: string, key: string): CollectionCursor { 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'); @@ -135,7 +139,7 @@ async function collectionPage(env: Env, ctx: ExecutionContext, url: URL, query: 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, url.origin, 'collection', { ...query, limit: steamLimit, cursor: cursor.steam }); + 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, @@ -222,7 +226,7 @@ async function route(request: Request, env: Env, ctx: ExecutionContext): Promise const query = queryOptions(url); if (query.cursor !== '*') throw new HttpError(400, 'Use the browsing endpoints for pagination'); const [mods, collections] = await Promise.all([ - querySteam(env, ctx, url.origin, 'mod', { ...query, limit: 4 }), + modPage(env, ctx, { ...query, limit: 4 }), collectionPage(env, ctx, url, { ...query, limit: 3 }), ]); return json({ mods, collections }); @@ -230,7 +234,7 @@ async function route(request: Request, env: Env, ctx: ExecutionContext): Promise if (url.pathname === '/catalog/mods') { requireMethod(request, ['GET']); const query = queryOptions(url); - return json(await searchMods(env.DB, query) ?? await querySteam(env, ctx, url.origin, 'mod', query)); + return json(await modPage(env, ctx, query)); } if (url.pathname === '/catalog/index') { requireMethod(request, ['GET']); 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/search.ts b/cf-workers/search.ts index 0905a26..b1ae4bd 100644 --- a/cf-workers/search.ts +++ b/cf-workers/search.ts @@ -74,20 +74,21 @@ const ORDER: Record = { /** 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 = matchExpression(query.query); + const match = query.query ? matchExpression(query.query) : null; const resumed = CURSOR.exec(query.cursor); - if (!order || !match || (query.cursor !== '*' && !resumed)) return null; + 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]; + 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 = `FROM catalog_fts JOIN catalog_index ON catalog_index.rowid = catalog_fts.rowid - WHERE catalog_fts MATCH ? ${filters.join(' ')}`; + 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 ?`) diff --git a/cf-workers/steam.ts b/cf-workers/steam.ts index dc756c4..e62611d 100644 --- a/cf-workers/steam.ts +++ b/cf-workers/steam.ts @@ -340,30 +340,51 @@ export async function queryFiles(env: Env, kind: } as FilesPage; } -const QUERY_STALE_SECONDS = 24 * 60 * 60; +const QUERY_KEEP_MS = 7 * 24 * 60 * 60 * 1000; -export async function querySteam(env: Env, ctx: ExecutionContext, origin: string, kind: K, query: SteamQuery): Promise> { +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 cacheUrl = new URL(`/__catalog_cache/${kind}`, origin); - cacheUrl.searchParams.set('query', JSON.stringify(query)); - const cacheKey = new Request(cacheUrl); + const key = JSON.stringify([kind, query]); const refresh = async (): Promise => { const { items, total, next_cursor } = await queryFiles(env, kind, query); - const page = { items, total, next_cursor } as Page; - await caches.default.put(cacheKey, Response.json(page, { - headers: { 'Cache-Control': `public, max-age=${QUERY_STALE_SECONDS}`, 'X-Cached-At': String(Date.now()) }, - })); - return page; + 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 cached = await caches.default.match(cacheKey); - if (cached) { - const age = Date.now() - Number(cached.headers.get('X-Cached-At') ?? 0); - if (age > QUERY_TTL_SECONDS * 1000) { - ctx.waitUntil(refresh().catch(error => console.error('Background refresh failed', error instanceof Error ? error.message : error))); + 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 cached.json(); } return await refresh(); } diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index 313cdb8..54c8236 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -35,6 +35,7 @@ const files: Record = { }; let flakyCalls = 0; +let queryCalls = 0; async function upstream(request: Request): Promise { const url = new URL(request.url); @@ -53,6 +54,7 @@ async function upstream(request: Request): Promise { return Response.json({ response: { publishedfiledetails: (input.publishedfileids ?? []).flatMap(id => files[id] ? [files[id]] : []) } }); } 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']; @@ -82,7 +84,7 @@ function statements(sql: string): string[] { } async function migrate(database: D1Database): Promise { - for (const file of ['0001_dependencies.sql', '0002_catalog.sql', '0003_search.sql']) { + for (const file of ['0001_dependencies.sql', '0002_catalog.sql', '0003_search.sql', '0004_query_cache.sql']) { const sql = await readFile(resolvePath('migrations', file), 'utf8'); await database.batch(statements(sql).map(statement => database.prepare(statement))); } @@ -398,3 +400,34 @@ test('expired detail reads answer from storage immediately and refresh in the ba 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); +}); diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index f1a1ae0..8264791 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -230,18 +230,7 @@ def apply_discover(result: Any) -> None: def apply_installed(items: list[CatalogMod]) -> None: self._installed = items self._recount() - needle = self._query.casefold() - self._installed_view = [ - item - for item in items - if not needle or needle in item.title.casefold() - ] - shown = self._installed_view[: self._installed_shown] - self.mods_model.set_page( - [self._row(item) for item in shown], - len(self._installed_view), - "more" if len(shown) < len(self._installed_view) else None, - ) + self._render_installed() await self._run_stream( lambda: self._catalog.cached( @@ -274,6 +263,24 @@ def apply_page(page: Any) -> None: apply_page, ) + def _render_installed(self) -> None: + needle = self._query.casefold() + self._installed_view = [ + item + for item in self._installed + if not needle + or needle + in " ".join( + (item.title, item.author.name or "", item.id, *item.tags) + ).casefold() + ] + shown = self._installed_view[: self._installed_shown] + self.mods_model.set_page( + [self._row(item) for item in shown], + len(self._installed_view), + "more" if len(shown) < len(self._installed_view) else None, + ) + def _show_more_installed(self) -> None: start = self.mods_model.rowCount() chunk = self._installed_view[start : start + _PAGE] @@ -307,6 +314,11 @@ async def filter( sort, tag, ) + if self._tab == "Installed" and self._installed: + self._detail = {} + self._render_installed() + self.changed.emit() + return if self._tab == "Discover": self._tab = "Mods" await self.load() From 433929935335259d77ceef2678497e493daa5659 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 01:01:06 +0300 Subject: [PATCH 20/69] feat(workshop): persist catalog responses on disk and filter Installed locally --- src/pxmodrim/core/workshop/catalog.py | 59 ++++++++++++++++++++++-- src/pxmodrim/ui/plugins/workshop/view.py | 36 +++++++++++---- tests/test_ui/test_workshop_view.py | 28 +++++++++++ tests/test_workshop/test_catalog.py | 32 +++++++++++++ 4 files changed, 142 insertions(+), 13 deletions(-) diff --git a/src/pxmodrim/core/workshop/catalog.py b/src/pxmodrim/core/workshop/catalog.py index 4ca9e5a..04b5e09 100644 --- a/src/pxmodrim/core/workshop/catalog.py +++ b/src/pxmodrim/core/workshop/catalog.py @@ -1,10 +1,12 @@ from __future__ import annotations import asyncio +import hashlib import time from collections.abc import AsyncIterator, Awaitable, Callable -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar +import msgspec from loguru import logger from pxmodrim.core.downloads.manager import DownloadManager, download_manager @@ -23,6 +25,8 @@ ) 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 @@ -30,6 +34,37 @@ _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) class WorkshopCatalog(Plugin): @@ -57,9 +92,12 @@ def __init__( 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._cache_dir = ctx.config_service.config_dir / "workshop-cache" self._manager = download_manager(ctx) ctx.mod_service.mods_changed.connect(self._on_installed_changed) self._manager.download_finished.connect(self._on_download_finished) @@ -155,22 +193,37 @@ async def cached[T]( 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: yield entry[1] - if time.monotonic() - entry[0] < fresh_for: + if time.time() - entry[0] < fresh_for: return value = await produce() + remembered = (time.time(), value) self._responses.pop(key, None) - self._responses[key] = (time.monotonic(), value) + 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 entry is None or value != entry[1]: yield value diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index 8264791..9bbdbfe 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -2,10 +2,10 @@ import asyncio from collections.abc import AsyncIterator, Awaitable, Callable -from dataclasses import dataclass 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 @@ -18,7 +18,9 @@ CatalogCollection, CatalogError, CatalogMod, + CatalogPage, CatalogQuery, + Discover, DownloadPlan, WorkshopCatalog, ) @@ -39,10 +41,9 @@ _PAGE = 48 -@dataclass(frozen=True) -class _Detail: +class _Detail(msgspec.Struct, frozen=True): item: CatalogMod | CatalogCollection - members: tuple[CatalogMod | CatalogCollection, ...] + members: list[CatalogMod | CatalogCollection] warning: str complete: bool @@ -222,7 +223,9 @@ def apply_discover(result: Any) -> None: ) await self._run_stream( - lambda: self._catalog.cached("discover", self._catalog.discover), + lambda: self._catalog.cached( + "discover", self._catalog.discover, value_type=Discover + ), apply_discover, ) elif self._tab == "Installed": @@ -234,7 +237,10 @@ def apply_installed(items: list[CatalogMod]) -> None: await self._run_stream( lambda: self._catalog.cached( - "installed", self._catalog.installed_with_updates, fresh_for=0 + "installed", + self._catalog.installed_with_updates, + fresh_for=0, + value_type=list[CatalogMod], ), apply_installed, ) @@ -256,9 +262,16 @@ def apply_page(page: Any) -> None: if append: await self._run(lambda: fetch(query), apply_page) else: + page_type = ( + CatalogPage[CatalogCollection] + if self._tab == "Collections" + else CatalogPage[CatalogMod] + ) await self._run_stream( lambda: self._catalog.cached( - f"{self._tab}|{query!r}", lambda: fetch(query) + f"{self._tab}|{query!r}", + lambda: fetch(query), + value_type=page_type, ), apply_page, ) @@ -352,7 +365,7 @@ async def fetch() -> _Detail: warning += " Unavailable: " + ", ".join(detail.unavailable_ids) return _Detail( detail.collection, - tuple(detail.members), + list(detail.members), warning, detail.is_complete, ) @@ -369,7 +382,7 @@ async def fetch() -> _Detail: ] return _Detail( mod, - tuple(item for item in dependencies if item is not None), + [item for item in dependencies if item is not None], "Unavailable dependencies: " + ", ".join(missing) if missing else "", not missing, ) @@ -388,7 +401,10 @@ def apply(detail: _Detail) -> None: self.members_model.set_page(members, len(members), None) await self._run_stream( - lambda: self._catalog.cached(f"detail|{kind}|{item_id}", fetch), apply + lambda: self._catalog.cached( + f"detail|{kind}|{item_id}", fetch, value_type=_Detail + ), + apply, ) @Slot() diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index 52e63ec..2896635 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -4,6 +4,7 @@ from collections.abc import AsyncIterator from typing import Any, cast +import msgspec import pytest from PySide6.QtCore import QUrl from PySide6.QtQuickWidgets import QQuickWidget @@ -241,3 +242,30 @@ async def test_settings_saves_and_applies_catalog_url( assert view._ctx.config.workshop_catalog_url == "https://catalog.example.test" await asyncio.sleep(0) settings.deleteLater() + + +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.selectTab("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 diff --git a/tests/test_workshop/test_catalog.py b/tests/test_workshop/test_catalog.py index 7113433..1e877be 100644 --- a/tests/test_workshop/test_catalog.py +++ b/tests/test_workshop/test_catalog.py @@ -300,3 +300,35 @@ async def download(ids: list[str]) -> DownloadResult: 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(lambda: ctx.config) + first.setup(ctx) + assert [v async for v in first.cached("k", produce, value_type=list[int])] == [ + [1, 2] + ] + + second = WorkshopCatalog(lambda: ctx.config) + 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(lambda: ctx.config) + third.setup(ctx) + assert [v async for v in third.cached("k", produce, value_type=list[int])] == [ + [1, 2] + ] From f3e7c90a8fff47bd8fb7974f5321ff249835b398 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 01:06:29 +0300 Subject: [PATCH 21/69] refactor(workshop): split the view into per-tab loaders, InstalledList and detail helpers --- src/pxmodrim/core/workshop/catalog.py | 4 +- src/pxmodrim/ui/plugins/workshop/details.py | 71 +++ src/pxmodrim/ui/plugins/workshop/installed.py | 62 +++ src/pxmodrim/ui/plugins/workshop/view.py | 505 ++++++++---------- tests/test_ui/test_workshop_installed.py | 68 +++ tests/test_ui/test_workshop_view.py | 17 +- 6 files changed, 425 insertions(+), 302 deletions(-) create mode 100644 src/pxmodrim/ui/plugins/workshop/details.py create mode 100644 src/pxmodrim/ui/plugins/workshop/installed.py create mode 100644 tests/test_ui/test_workshop_installed.py diff --git a/src/pxmodrim/core/workshop/catalog.py b/src/pxmodrim/core/workshop/catalog.py index 04b5e09..f285256 100644 --- a/src/pxmodrim/core/workshop/catalog.py +++ b/src/pxmodrim/core/workshop/catalog.py @@ -32,7 +32,7 @@ from pxmodrim.core.downloads.types import DownloadResult from pxmodrim.core.models.metadata.structures import ListedMod -_FRESH_SECONDS = 300.0 +FRESH_SECONDS = 300.0 _CACHE_LIMIT = 200 _DISK_LIMIT = 300 @@ -192,7 +192,7 @@ async def cached[T]( self, key: str, produce: Callable[[], Awaitable[T]], - fresh_for: float = _FRESH_SECONDS, + fresh_for: float = FRESH_SECONDS, value_type: Any = None, ) -> AsyncIterator[T]: """Yield the remembered value at once, then the refreshed one if it differs. 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/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index 9bbdbfe..39edd0b 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -1,7 +1,6 @@ from __future__ import annotations -import asyncio -from collections.abc import AsyncIterator, Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable, Iterable from pathlib import Path from typing import TYPE_CHECKING, Any, cast @@ -24,8 +23,16 @@ DownloadPlan, WorkshopCatalog, ) +from pxmodrim.core.workshop.catalog import FRESH_SECONDS from pxmodrim.ui.components.dialogs import await_dialog from pxmodrim.ui.panels.settings_panel import SettingsPanel +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 @@ -37,15 +44,11 @@ from pxmodrim.core.context import CoreContext from pxmodrim.ui.context import AppContext - -_PAGE = 48 +_FAILURES = (CatalogError, RuntimeError, ValueError) -class _Detail(msgspec.Struct, frozen=True): - item: CatalogMod | CatalogCollection - members: list[CatalogMod | CatalogCollection] - warning: str - complete: bool +def _message(exc: Exception) -> str: + return exc.message if isinstance(exc, CatalogError) else str(exc) class WorkshopViewPanel(BaseViewPanel): @@ -72,25 +75,19 @@ def __init__( 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._detail: dict[str, Any] = {} - self._query = "" - self._version = "" if ctx.target_version == "Unknown" else ctx.target_version - self._tag = "" - self._sort = "popular" - self._source = "all" self._generation = 0 self._retry: Callable[[], Awaitable[None]] | None = None - self._installed: list[CatalogMod] = [] + self._detail: dict[str, Any] = {} + self._detail_data: Detail | None = None + self._installed = InstalledList() + self._update_count = 0 self._pending: DownloadPlan | None = None - self._titles: dict[str, str] = {} - self._items: dict[str, CatalogMod | CatalogCollection] = {} self._planning = 0 - self._update_count = 0 - self._installed_view: list[CatalogMod] = [] - self._installed_shown = _PAGE + self._items: dict[str, CatalogMod | CatalogCollection] = {} self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] self._qml.setObjectName("workshopView") self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) @@ -112,12 +109,16 @@ def __init__( self._catalog.catalog_url_changed.connect(self._on_url_changed) self._catalog.installed_changed.connect(self._on_installed_changed) + @property + def _game_version(self) -> str: + version = self._ctx.target_version + return "" if version == "Unknown" else version + + # -- rows ------------------------------------------------------------- + def _row(self, item: CatalogMod | CatalogCollection) -> dict[str, Any]: self._items[item.id] = item - version = ( - "" if self._ctx.target_version == "Unknown" else self._ctx.target_version - ) - row = item_row(item, self._catalog.install_state, version) + row = item_row(item, self._catalog.install_state, self._game_version) if isinstance(item, CatalogMod) and row["state"] != "installed": running = item.id in self._downloads.active_ids if running or item.id in self._catalog.queued_ids: @@ -125,38 +126,47 @@ def _row(self, item: CatalogMod | CatalogCollection) -> dict[str, Any]: row["actionLabel"] = "Downloading" if running else "Queued" return row + def _rows(self, items: Iterable[CatalogMod | CatalogCollection]) -> list[Any]: + return [self._row(item) for item in items] + + def _detail_row(self, detail: Detail) -> dict[str, Any]: + row = self._row(detail.item) + row["complete"], row["warning"] = detail.complete, detail.warning + if isinstance(detail.item, CatalogCollection): + started = any( + isinstance(member, CatalogMod) + and self._catalog.install_state(member) != "missing" + for member in detail.members + ) + row["actionLabel"] = "Complete download" if started else "Download all" + 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 + + # -- running fetches -------------------------------------------------- + async def _run( self, fetch: Callable[[], Awaitable[Any]], apply: Callable[[Any], None] ) -> None: - self._generation += 1 - generation = self._generation - self._busy, self._error = True, "" - self.changed.emit() + async def once() -> AsyncIterator[Any]: + yield await fetch() - async def retry() -> None: - await self._run(fetch, apply) - - self._retry = retry - try: - result = await fetch() - if generation == self._generation: - apply(result) - except CatalogError as exc: - if generation == self._generation: - self._error = exc.message - except (RuntimeError, ValueError) as exc: - if generation == self._generation: - self._error = str(exc) - finally: - if generation == self._generation: - self._busy = False - self.changed.emit() + 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.""" self._generation += 1 generation = self._generation self._busy, self._error = True, "" @@ -175,136 +185,129 @@ async def retry() -> None: shown = True self._busy = False self.changed.emit() - except (CatalogError, RuntimeError, ValueError) as exc: - message = exc.message if isinstance(exc, CatalogError) else str(exc) + except _FAILURES as exc: if generation == self._generation: if shown: - logger.warning("[workshop] background refresh failed: {}", message) + logger.warning( + "[workshop] background refresh failed: {}", _message(exc) + ) else: - self._error = message + self._error = _message(exc) finally: if generation == self._generation: self._busy = False self.changed.emit() + 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 not self._catalog.configured: self._generation += 1 self._busy = False self.changed.emit() return - if append and self._tab == "Installed": - self._show_more_installed() + self._clear_detail() + match self._tab: + case "Discover": + await self._load_discover() + case "Installed": + await self._load_installed(append) + 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) + + 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 - self._detail = {} - model = ( - self.collections_model if self._tab == "Collections" else self.mods_model + page_type = ( + CatalogPage[CatalogCollection] if collections else CatalogPage[CatalogMod] ) - query = CatalogQuery( - query=self._query, - tag=self._tag or None, - version=self._version or None, - sort=self._sort, - source=self._source, - cursor=model.cursor if append else None, + await self._run_cached( + f"{self._tab}|{query!r}", lambda: fetch(query), page_type, apply ) - if self._tab == "Discover": - def apply_discover(result: Any) -> None: - self.mods_model.set_page( - [self._row(item) for item in result.mods.items], - result.mods.total, - None, - ) - self.collections_model.set_page( - [self._row(item) for item in result.collections.items], - result.collections.total, - None, - ) - - await self._run_stream( - lambda: self._catalog.cached( - "discover", self._catalog.discover, value_type=Discover - ), - apply_discover, - ) - elif self._tab == "Installed": - - def apply_installed(items: list[CatalogMod]) -> None: - self._installed = items - self._recount() - self._render_installed() - - await self._run_stream( - lambda: self._catalog.cached( - "installed", - self._catalog.installed_with_updates, - fresh_for=0, - value_type=list[CatalogMod], - ), - apply_installed, - ) - else: - fetch = ( - self._catalog.collections - if self._tab == "Collections" - else self._catalog.mods + 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 - def apply_page(page: Any) -> None: - model.set_page( - [self._row(item) for item in page.items], - page.total, - page.next_cursor, - append=append, - ) - - if append: - await self._run(lambda: fetch(query), apply_page) - else: - page_type = ( - CatalogPage[CatalogCollection] - if self._tab == "Collections" - else CatalogPage[CatalogMod] - ) - await self._run_stream( - lambda: self._catalog.cached( - f"{self._tab}|{query!r}", - lambda: fetch(query), - value_type=page_type, - ), - apply_page, - ) - - def _render_installed(self) -> None: - needle = self._query.casefold() - self._installed_view = [ - item - for item in self._installed - if not needle - or needle - in " ".join( - (item.title, item.author.name or "", item.id, *item.tags) - ).casefold() - ] - shown = self._installed_view[: self._installed_shown] - self.mods_model.set_page( - [self._row(item) for item in shown], - len(self._installed_view), - "more" if len(shown) < len(self._installed_view) else None, + def apply(mods: list[CatalogMod]) -> None: + self._installed.replace(mods) + self._recount() + self._show_installed() + + await self._run_cached( + "installed", + self._catalog.installed_with_updates, + list[CatalogMod], + apply, + fresh_for=0, ) - def _show_more_installed(self) -> None: - start = self.mods_model.rowCount() - chunk = self._installed_view[start : start + _PAGE] - self._installed_shown = start + len(chunk) + 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._row(item) for item in chunk], - len(self._installed_view), - "more" if self._installed_shown < len(self._installed_view) else None, - append=True, + 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() @@ -312,25 +315,23 @@ async def refresh(self) -> None: @asyncSlot(str) async def selectTab(self, tab: str) -> None: self._tab, self._notice, self._pending = tab, "", None - self._installed_shown = _PAGE + 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._installed_shown = _PAGE - self._query, self._version, self._source, self._sort, self._tag = ( - query, - version, - source, - sort, - tag, + self._criteria = CatalogQuery( + query=query, + tag=tag or None, + version=version or None, + sort=sort, + source=source, ) - if self._tab == "Installed" and self._installed: - self._detail = {} - self._render_installed() - self.changed.emit() + self._installed.search(query) + if self._tab == "Installed" and self._installed.loaded: + self._show_installed() return if self._tab == "Discover": self._tab = "Mods" @@ -350,106 +351,24 @@ async def openItem(self, item_id: str, kind: str) -> None: await self.open_item(item_id, kind) async def open_item(self, item_id: str, kind: str) -> None: - async def fetch() -> _Detail: - if kind == "collection": - detail = await self._catalog.collection(item_id) - if detail is None: - raise CatalogError("This collection is no longer available.", 404) - warning = ( - "" - if detail.is_complete - else "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, - ) - mod = await self._catalog.mod(item_id) - if mod is None: - raise CatalogError("This mod is no longer available.", 404) - dependencies = await asyncio.gather( - *(self._catalog.mod(pid) for pid in mod.dependencies) - ) - missing = [ - pid - for pid, item in zip(mod.dependencies, dependencies, strict=True) - if item is None - ] - return _Detail( - mod, - [item for item in dependencies if item is not None], - "Unavailable dependencies: " + ", ".join(missing) if missing else "", - not missing, - ) - - def apply(detail: _Detail) -> None: - row = self._row(detail.item) - members = [self._row(item) for item in detail.members] - row["complete"], row["warning"] = detail.complete, detail.warning - if isinstance(detail.item, CatalogCollection): - row["actionLabel"] = ( - "Complete download" - if any(member["state"] != "missing" for member in members) - else "Download all" - ) - self._detail = row + def apply(detail: Detail) -> None: + 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_stream( - lambda: self._catalog.cached( - f"detail|{kind}|{item_id}", fetch, value_type=_Detail - ), + await self._run_cached( + f"detail|{kind}|{item_id}", + lambda: fetch_detail(self._catalog, item_id, kind), + Detail, apply, ) @Slot() def back(self) -> None: self._generation += 1 - self._detail, self._error, self._busy = {}, "", False - self.changed.emit() - - def _queue_plan(self, plan: DownloadPlan) -> None: - self._catalog.enqueue(plan) - count = len(plan.to_download) - self._notice = ( - f"Queued {count} mod{'s' if count != 1 else ''}. Progress is shown " - "in the header and results in Downloads. Mods are not activated." - ) - - async def _download(self, ids: list[str], collections: list[str]) -> None: - self._notice, self._pending = "Preparing download…", None - self._planning += 1 - self.changed.emit() - try: - plan = await self._catalog.plan(ids, collections) - except (CatalogError, RuntimeError, ValueError) as exc: - message = exc.message if isinstance(exc, CatalogError) else str(exc) - self._notice = f"Could not prepare the download: {message}" - return - finally: - self._planning -= 1 - self.changed.emit() - self._titles.update(plan.titles) - if not plan.is_complete: - self._pending = plan - 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) - ) - self._notice = " ".join(parts) - elif not plan.to_download: - self._notice = "All available mods are already installed and up to date." - else: - self._queue_plan(plan) + self._clear_detail() + self._error, self._busy = "", False self.changed.emit() @asyncSlot(str, str) @@ -462,9 +381,9 @@ async def downloadItem(self, item_id: str, kind: str) -> None: @asyncSlot() async def updateAll(self) -> None: ids = [ - item.id - for item in self._installed - if self._catalog.install_state(item) == "outdated" + mod.id + for mod in self._installed.mods + if self._catalog.install_state(mod) == "outdated" ] if ids: await self._download(ids, []) @@ -497,43 +416,46 @@ async def openSettings(self) -> None: self._ctx.update_config(dialog.get_config()) dialog.deleteLater() + # -- 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: + self._notice, self._pending = "Preparing download…", None + self._planning += 1 + self.changed.emit() + try: + plan = await self._catalog.plan(ids, collections) + except _FAILURES as exc: + self._notice = f"Could not prepare the download: {_message(exc)}" + return + finally: + self._planning -= 1 + self.changed.emit() + 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_queue_changed(self, *_: object) -> None: self._refresh_rows() self.changed.emit() @asyncSlot(str) async def _on_url_changed(self, _url: str) -> None: - self._detail, self._error, self._notice, self._pending = {}, "", "", None + self._clear_detail() + self._error, self._notice, self._pending = "", "", None self._items.clear() - self.mods_model.set_page([], 0, None) - self.collections_model.set_page([], 0, None) - self.members_model.set_page([], 0, None) - await self.load() - - def _recount(self) -> None: - self._update_count = sum( - self._catalog.install_state(item) == "outdated" for item in self._installed - ) - - def _refresh_rows(self) -> None: - def convert(row: dict[str, Any]) -> dict[str, Any]: - return self._row(self._items[row["itemId"]]) - for model in (self.mods_model, self.collections_model, self.members_model): - model.refresh_rows(convert) - if self._detail: - item = self._items[self._detail["itemId"]] - self._detail.update(self._row(item)) - if isinstance(item, CatalogCollection): - members = [self._items.get(pid) for pid in item.member_ids] - partially_installed = any( - isinstance(member, CatalogMod) - and self._catalog.install_state(member) != "missing" - for member in members - ) - self._detail["actionLabel"] = ( - "Complete download" if partially_installed else "Download all" - ) + model.set_page([], 0, None) + await self.load() @asyncSlot(object) async def _on_installed_changed(self, _value: None) -> None: @@ -543,6 +465,8 @@ async def _on_installed_changed(self, _value: None) -> None: if self._tab == "Installed" and not self._busy: await self.load() + # -- QML properties --------------------------------------------------- + @Property(str, notify=changed) # type: ignore[arg-type] def tab(self) -> str: return self._tab @@ -581,15 +505,16 @@ def canDownloadAvailable(self) -> bool: @Property(str, constant=True) # type: ignore[arg-type] def gameVersion(self) -> str: - return "" if self._ctx.target_version == "Unknown" else self._ctx.target_version + 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", - *([self._version] if self._version else []), + *([current] if current else []), "1.6", "1.5", "1.4", 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_view.py b/tests/test_ui/test_workshop_view.py index 2896635..ed7b498 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -166,21 +166,18 @@ async def test_view_paging_filters_and_catalog_errors( ) -> None: view, catalog, _ = panel await asyncio.sleep(0) - view._tab = "Mods" - view._version = "1.6" - await view.load() - await view.load(append=True) + await view.selectTab("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 - view._query = "uncached" - await view.load() + await view.filter("uncached", "1.6", "all", "popular", "") assert view.error == "Catalog unavailable" catalog.failure = False - assert view._retry is not None - await view._retry() + await view.retry() assert view.error == "" and view.mods_model.count == 1 @@ -194,7 +191,7 @@ async def test_mod_detail_safe_description_dependencies_and_download( assert detail["description"] == "Safe description" assert detail["actionLabel"] == "Update" assert view.members_model.count == 1 - await view._download(["1"], []) + await view.downloadItem("1", "mod") assert catalog.downloaded == ["2", "1"] assert not warnings, [warning.toString() for warning in warnings] @@ -208,7 +205,7 @@ async def test_incomplete_collection_requires_explicit_partial_download( detail = cast("dict[str, Any]", view.detail) assert not detail["complete"] and "missing" in detail["warning"] assert detail["description"] == "<b>Plain text</b>" - await view._download([], ["picked:test"]) + await view.downloadItem("picked:test", "collection") assert view.pendingPlan and not catalog.downloaded assert view.canDownloadAvailable await view.downloadAvailable() From d31123efb545a5dbc978718a938f2a494c8c0fb4 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 01:15:46 +0300 Subject: [PATCH 22/69] perf(workshop): request Steam images at display size to cut decoded memory --- .../ui/plugins/workshop/CatalogCard.qml | 18 +++++++++++------- src/pxmodrim/ui/plugins/workshop/Workshop.qml | 15 +++++++++------ src/pxmodrim/ui/plugins/workshop/thumbs.js | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 src/pxmodrim/ui/plugins/workshop/thumbs.js diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml index b52ef13..58f4627 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml +++ b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import "../../components/controls" +import "thumbs.js" as Thumbs Rectangle { id: card @@ -31,19 +32,22 @@ Rectangle { anchors.margins: 12 spacing: 8 Rectangle { + id: thumb Layout.fillWidth: true Layout.preferredHeight: 110 color: Theme.elevate3 radius: Theme.radiusMd clip: true + readonly property int pixelW: Thumbs.bucket(width * Screen.devicePixelRatio, 64) + readonly property int pixelH: Thumbs.bucket(height * Screen.devicePixelRatio, 32) Image { id: preview anchors.fill: parent - source: card.previewUrl + source: thumb.width > 0 ? Thumbs.sized(card.previewUrl, thumb.pixelW, thumb.pixelH) : "" asynchronous: true fillMode: Image.PreserveAspectCrop - sourceSize.width: 480 - sourceSize.height: 240 + sourceSize.width: thumb.pixelW + sourceSize.height: thumb.pixelH } Grid { id: collageGrid @@ -52,16 +56,16 @@ Rectangle { columns: card.collage.length === 1 ? 1 : 2 readonly property int rowCount: card.collage.length > 2 ? 2 : 1 Repeater { - model: card.collage + model: collageGrid.visible ? card.collage : [] Image { required property string modelData width: collageGrid.width / collageGrid.columns height: collageGrid.height / collageGrid.rowCount - source: modelData + source: Thumbs.sized(modelData, thumb.pixelW / collageGrid.columns, thumb.pixelH / collageGrid.rowCount) asynchronous: true fillMode: Image.PreserveAspectCrop - sourceSize.width: 240 - sourceSize.height: 120 + sourceSize.width: thumb.pixelW / collageGrid.columns + sourceSize.height: thumb.pixelH / collageGrid.rowCount } } } diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index 8061467..5a7f49f 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import "../../components/controls" +import "thumbs.js" as Thumbs Rectangle { id: root @@ -259,10 +260,12 @@ Rectangle { Image { id: detailImage anchors.fill: parent - source: root.detail.previewUrl || "" + readonly property int pixelW: Thumbs.bucket(width * Screen.devicePixelRatio, 128) + readonly property int pixelH: Thumbs.bucket(height * Screen.devicePixelRatio, 64) + source: width > 0 ? Thumbs.sized(root.detail.previewUrl || "", pixelW, pixelH) : "" asynchronous: true - sourceSize.width: 1000 - sourceSize.height: 600 + sourceSize.width: pixelW + sourceSize.height: pixelH fillMode: Image.PreserveAspectFit } Grid { @@ -277,11 +280,11 @@ Rectangle { required property string modelData width: detailCollage.width / detailCollage.columns height: detailCollage.height / detailCollage.rowCount - source: modelData + source: Thumbs.sized(modelData, detailImage.pixelW / detailCollage.columns, detailImage.pixelH / detailCollage.rowCount) asynchronous: true fillMode: Image.PreserveAspectCrop - sourceSize.width: 480 - sourceSize.height: 300 + sourceSize.width: detailImage.pixelW / detailCollage.columns + sourceSize.height: detailImage.pixelH / detailCollage.rowCount } } } diff --git a/src/pxmodrim/ui/plugins/workshop/thumbs.js b/src/pxmodrim/ui/plugins/workshop/thumbs.js new file mode 100644 index 0000000..da73524 --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/thumbs.js @@ -0,0 +1,15 @@ +.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 (!url) return "" + if (!/^https:\/\/[^/]*(steamusercontent\.com|steamstatic\.com)\//.test(url)) return url + var separator = url.indexOf("?") < 0 ? "?" : "&" + return url + separator + "imw=" + width + "&imh=" + height + "&ima=fit&impolicy=Letterbox" +} + +// 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) +} From b1c18cf5aa5beb6a2b2ecea39abadade1912228c Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 01:26:10 +0300 Subject: [PATCH 23/69] refactor(workshop): centralize image sizing and fix hidden preview loading --- CHANGELOG.md | 1 + .../ui/plugins/workshop/CatalogCard.qml | 19 +--- .../ui/plugins/workshop/CatalogImage.qml | 18 ++++ src/pxmodrim/ui/plugins/workshop/Workshop.qml | 22 ++-- src/pxmodrim/ui/plugins/workshop/thumbs.js | 17 ++- tests/test_ui/test_workshop_images.py | 101 ++++++++++++++++++ 6 files changed, 144 insertions(+), 34 deletions(-) create mode 100644 src/pxmodrim/ui/plugins/workshop/CatalogImage.qml create mode 100644 tests/test_ui/test_workshop_images.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 515042c..1f9024d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Fixed - 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. ### 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. diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml index 58f4627..9cf9acb 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml +++ b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml @@ -2,7 +2,6 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import "../../components/controls" -import "thumbs.js" as Thumbs Rectangle { id: card @@ -38,16 +37,10 @@ Rectangle { color: Theme.elevate3 radius: Theme.radiusMd clip: true - readonly property int pixelW: Thumbs.bucket(width * Screen.devicePixelRatio, 64) - readonly property int pixelH: Thumbs.bucket(height * Screen.devicePixelRatio, 32) - Image { + CatalogImage { id: preview anchors.fill: parent - source: thumb.width > 0 ? Thumbs.sized(card.previewUrl, thumb.pixelW, thumb.pixelH) : "" - asynchronous: true - fillMode: Image.PreserveAspectCrop - sourceSize.width: thumb.pixelW - sourceSize.height: thumb.pixelH + previewUrl: card.previewUrl } Grid { id: collageGrid @@ -57,15 +50,11 @@ Rectangle { readonly property int rowCount: card.collage.length > 2 ? 2 : 1 Repeater { model: collageGrid.visible ? card.collage : [] - Image { + CatalogImage { required property string modelData width: collageGrid.width / collageGrid.columns height: collageGrid.height / collageGrid.rowCount - source: Thumbs.sized(modelData, thumb.pixelW / collageGrid.columns, thumb.pixelH / collageGrid.rowCount) - asynchronous: true - fillMode: Image.PreserveAspectCrop - sourceSize.width: thumb.pixelW / collageGrid.columns - sourceSize.height: thumb.pixelH / collageGrid.rowCount + previewUrl: modelData } } } 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/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index 5a7f49f..1148445 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -2,7 +2,6 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import "../../components/controls" -import "thumbs.js" as Thumbs Rectangle { id: root @@ -257,15 +256,12 @@ Rectangle { border.color: Theme.border radius: Theme.radiusMd clip: true - Image { + CatalogImage { id: detailImage anchors.fill: parent - readonly property int pixelW: Thumbs.bucket(width * Screen.devicePixelRatio, 128) - readonly property int pixelH: Thumbs.bucket(height * Screen.devicePixelRatio, 64) - source: width > 0 ? Thumbs.sized(root.detail.previewUrl || "", pixelW, pixelH) : "" - asynchronous: true - sourceSize.width: pixelW - sourceSize.height: pixelH + previewUrl: root.detail.previewUrl || "" + widthStep: 128 + heightStep: 64 fillMode: Image.PreserveAspectFit } Grid { @@ -275,16 +271,12 @@ Rectangle { columns: (root.detail.collage || []).length === 1 ? 1 : 2 readonly property int rowCount: (root.detail.collage || []).length > 2 ? 2 : 1 Repeater { - model: root.detail.collage || [] - Image { + model: detailCollage.visible ? root.detail.collage : [] + CatalogImage { required property string modelData width: detailCollage.width / detailCollage.columns height: detailCollage.height / detailCollage.rowCount - source: Thumbs.sized(modelData, detailImage.pixelW / detailCollage.columns, detailImage.pixelH / detailCollage.rowCount) - asynchronous: true - fillMode: Image.PreserveAspectCrop - sourceSize.width: detailImage.pixelW / detailCollage.columns - sourceSize.height: detailImage.pixelH / detailCollage.rowCount + previewUrl: modelData } } } diff --git a/src/pxmodrim/ui/plugins/workshop/thumbs.js b/src/pxmodrim/ui/plugins/workshop/thumbs.js index da73524..fb16142 100644 --- a/src/pxmodrim/ui/plugins/workshop/thumbs.js +++ b/src/pxmodrim/ui/plugins/workshop/thumbs.js @@ -3,10 +3,19 @@ // 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 (!url) return "" - if (!/^https:\/\/[^/]*(steamusercontent\.com|steamstatic\.com)\//.test(url)) return url - var separator = url.indexOf("?") < 0 ? "?" : "&" - return url + separator + "imw=" + width + "&imh=" + height + "&ima=fit&impolicy=Letterbox" + 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. 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() From 86ea45677aa24f5ba3963bdc6483fdc980bde5ea Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 01:32:17 +0300 Subject: [PATCH 24/69] fix(workshop): sort detail versions and restore collection collages --- CHANGELOG.md | 2 ++ cf-workers/README.md | 2 ++ cf-workers/catalog.ts | 3 ++- cf-workers/steam.ts | 15 ++++++++------- cf-workers/tests/catalog.test.ts | 11 +++++++++++ src/pxmodrim/ui/plugins/workshop/models.py | 8 ++++++-- src/pxmodrim/ui/plugins/workshop/view.py | 14 ++++++++++++++ tests/test_ui/test_workshop_models.py | 13 +++++++++++++ tests/test_ui/test_workshop_view.py | 15 +++++++++++++++ 9 files changed, 73 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f9024d..8277dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - 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. ### 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. diff --git a/cf-workers/README.md b/cf-workers/README.md index da4d927..86d9913 100644 --- a/cf-workers/README.md +++ b/cf-workers/README.md @@ -107,6 +107,8 @@ Mods expose title, Steam author/profile, description, previews, Workshop URL, ta Collections expose source, author, description, preview, tags, supported versions, ordered direct member IDs, member count, and timestamps. 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 diff --git a/cf-workers/catalog.ts b/cf-workers/catalog.ts index 2f7779a..bd5f695 100644 --- a/cf-workers/catalog.ts +++ b/cf-workers/catalog.ts @@ -158,7 +158,8 @@ async function collectionDetails(env: Env, ctx: ExecutionContext, collection: Ca const item = items.get(id); if (item) members.push(item); } - return json({ collection, members, unavailable_ids, is_complete: unavailable_ids.length === 0 && collection.member_count === collection.member_ids.length }); + const [withPreviews] = await attachMemberPreviews(env, [collection], items); + return json({ collection: withPreviews!, members, unavailable_ids, is_complete: unavailable_ids.length === 0 && collection.member_count === collection.member_ids.length }); } async function resolve(env: Env, body: Record): Promise { diff --git a/cf-workers/steam.ts b/cf-workers/steam.ts index e62611d..01be862 100644 --- a/cf-workers/steam.ts +++ b/cf-workers/steam.ts @@ -266,16 +266,17 @@ 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[]): Promise { +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; - let members: Map; - try { - members = (await getSteamItems(env, ids)).items; - } catch (error) { - console.error('Collection thumbnails unavailable:', error instanceof Error ? error.message : error); - 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[] = []; diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index 54c8236..d8562b5 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -348,6 +348,17 @@ test('collections without a Steam image get a collage from their member mods, sk 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'); diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/src/pxmodrim/ui/plugins/workshop/models.py index d1eed53..6f4880a 100644 --- a/src/pxmodrim/ui/plugins/workshop/models.py +++ b/src/pxmodrim/ui/plugins/workshop/models.py @@ -115,6 +115,7 @@ def item_row( ) action_label = "Download all" member_count = item.member_count + versions = set(item.supported_versions) return { "itemId": item.id, "kind": item.kind, @@ -135,8 +136,11 @@ def item_row( if url ], "sourceLabel": source_label, - "versions": ", ".join(item.supported_versions) or "Not specified", - "tags": " · ".join(item.tags), + "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, "queued": False, "stateLabel": { diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index 39edd0b..b1d5a48 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -133,6 +133,20 @@ def _detail_row(self, detail: Detail) -> dict[str, Any]: row = self._row(detail.item) 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" diff --git a/tests/test_ui/test_workshop_models.py b/tests/test_ui/test_workshop_models.py index c4e4595..a6c23ca 100644 --- a/tests/test_ui/test_workshop_models.py +++ b/tests/test_ui/test_workshop_models.py @@ -1,5 +1,6 @@ from __future__ import annotations +import msgspec import pytest from PySide6.QtCore import QModelIndex, QObject, Qt @@ -137,6 +138,18 @@ def test_row_shows_update_and_game_incompatibility() -> None: assert row["fileSize"] == "1.0 MB" +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", diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index ed7b498..a326865 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -24,6 +24,7 @@ Discover, DownloadPlan, InstallState, + Preview, WorkshopCatalog, ) from pxmodrim.ui.components import create_qml_engine @@ -213,6 +214,20 @@ async def test_incomplete_collection_requires_explicit_partial_download( 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"] + + async def test_unset_catalog_never_fetches( panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], ) -> None: From 03cd43fb97c84fc223eaff55a8bb22a83c4a0768 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 01:40:55 +0300 Subject: [PATCH 25/69] fix(workshop): disconnect view events before Qt destruction --- CHANGELOG.md | 1 + src/pxmodrim/ui/plugins/workshop/view.py | 15 +++++++ tests/test_ui/test_workshop_view.py | 54 +++++++++++++++++++++++- 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8277dda..1e6c37e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. ### 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. diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index b1d5a48..de4d9e7 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -80,6 +80,7 @@ def __init__( 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 @@ -108,6 +109,18 @@ def __init__( 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.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) @property def _game_version(self) -> str: @@ -181,6 +194,8 @@ async def _run_stream( ) -> 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, "" diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index a326865..df6b1bf 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -9,6 +9,7 @@ from PySide6.QtCore import QUrl from PySide6.QtQuickWidgets import QQuickWidget from PySide6.QtWidgets import QWidget +from shiboken6 import delete, isValid from pxmodrim.core.config import AppConfig, ConfigService from pxmodrim.core.context import CoreContext @@ -148,7 +149,9 @@ async def panel( view = WorkshopViewPanel(ctx, engine, owner, catalog=catalog) view.resize(1100, 800) yield view, catalog, warnings - view._qml.setSource(QUrl()) + if isValid(view): + view.teardown() + view._qml.setSource(QUrl()) owner.deleteLater() @@ -162,6 +165,55 @@ async def test_view_loads_qml_and_discovery_without_errors( 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 + view._qml.setSource(QUrl()) + delete(view) + await catalog.shutdown() + downloads.busy_changed.emit(False) + catalog.installed_changed.emit(None) + catalog.catalog_url_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: From 99d7ed419124c615d8ddd79a8ac9d213458fe7f6 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 02:00:23 +0300 Subject: [PATCH 26/69] feat(workshop): polish UX, metadata presentation, and build matrix variants --- .github/workflows/build.yml | 160 +++++++-------- CHANGELOG.md | 7 + justfile | 4 +- packaging/build.py | 76 ++++++-- src/pxmodrim/_app.py | 25 ++- src/pxmodrim/ui/assets/logo_nobg_1.svg | 2 +- src/pxmodrim/ui/assets/logo_nobg_2.svg | 2 +- src/pxmodrim/ui/assets/logo_nobg_4.svg | 2 +- .../ui/components/controls/ModDescription.qml | 29 +++ src/pxmodrim/ui/components/icons.py | 8 + src/pxmodrim/ui/panels/ModInfo.qml | 24 +-- src/pxmodrim/ui/panels/mod_info_data.py | 20 +- .../ui/plugins/workshop/CatalogCard.qml | 47 ++++- .../ui/plugins/workshop/TagPicker.qml | 94 +++++++++ src/pxmodrim/ui/plugins/workshop/Workshop.qml | 106 +++++++--- src/pxmodrim/ui/plugins/workshop/models.py | 71 ++----- src/pxmodrim/ui/plugins/workshop/view.py | 47 +++++ tests/test_ui/test_mod_info_data.py | 68 +++++-- tests/test_ui/test_workshop_models.py | 83 ++++---- tests/test_ui/test_workshop_view.py | 184 +++++++++++++++++- 20 files changed, 764 insertions(+), 295 deletions(-) create mode 100644 src/pxmodrim/ui/components/controls/ModDescription.qml create mode 100644 src/pxmodrim/ui/plugins/workshop/TagPicker.qml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 786ca18..d31b006 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,10 @@ permissions: jobs: build-linux: runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + workshop: [SteamWorkshop, NativeWorkshop] steps: - uses: actions/checkout@v7 @@ -46,7 +50,7 @@ jobs: run: uv sync --locked --dev --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 +62,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: [SteamWorkshop, NativeWorkshop] container: image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-24.08 options: --privileged @@ -96,25 +116,28 @@ 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: [SteamWorkshop, NativeWorkshop] steps: - uses: actions/checkout@v7 @@ -154,22 +177,29 @@ 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: [SteamWorkshop, NativeWorkshop] env: MACOS_SIGNING_CONFIGURED: >- ${{ secrets.MACOS_CERT != '' && @@ -205,7 +235,7 @@ jobs: run: uv sync --locked --dev --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 +288,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 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/CHANGELOG.md b/CHANGELOG.md index 1e6c37e..5833825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. ### 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. @@ -20,6 +21,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. +- CI release matrix builds separate `SteamWorkshop` and `NativeWorkshop` artifact packages across Linux, macOS, and Windows. ### 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/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..70a7981 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,9 @@ def debian_architecture(machine: str | None = None) -> str: ) from None -def get_standalone_args(release: bool = False) -> list[str]: +def get_standalone_args( + release: bool = False, workshop: str = "NativeWorkshop" +) -> list[str]: project_root = Path(__file__).parent.parent args = [ @@ -47,6 +51,29 @@ def get_standalone_args(release: bool = False) -> list[str]: args.append("--include-qt-plugins=qml") + if workshop == "NativeWorkshop": + args.extend( + [ + ( + "--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*", + ] + ) + else: + args.extend( + [ + "--nofollow-import-to=pxmodrim.ui.plugins.workshop", + "--noinclude-data-files=pxmodrim/ui/plugins/workshop", + ] + ) + if release: args.extend( [ @@ -450,16 +477,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 +567,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=("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 +614,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/src/pxmodrim/_app.py b/src/pxmodrim/_app.py index 8cacf66..441d09b 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 @@ -100,7 +99,15 @@ 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 = { + "NativeWorkshop": {"steamworkshop"}, + "SteamWorkshop": {"workshop_catalog", "workshop_ui"}, + } + disabled.update(exclusions[manifest.read_text(encoding="utf-8").strip()]) + return disabled _WHEEL_SCROLL_LINES = 8 @@ -112,13 +119,21 @@ 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) @@ -185,7 +200,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 @@ -200,7 +215,7 @@ def _setup( downloaders.append("steam") 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()) 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/icons.py b/src/pxmodrim/ui/components/icons.py index ce4ecb5..b5e130a 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", @@ -53,6 +54,13 @@ # 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": ( '' 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/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/plugins/workshop/CatalogCard.qml b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml index 9cf9acb..a9a7429 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml +++ b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml @@ -19,6 +19,9 @@ Rectangle { required property bool incompatible required property bool queued required property int memberCount + required property string votes + required property string fileSize + required property bool active color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 radius: Theme.radiusMd @@ -83,7 +86,7 @@ Rectangle { textFormat: Text.PlainText color: Theme.textMuted font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm + font.pixelSize: Theme.fontSizeMd elide: Text.ElideRight } Flow { @@ -104,7 +107,7 @@ Rectangle { fillColor: Theme.elevate3 } PxBadge { - visible: card.kind === "mod" && card.state === "installed" + visible: card.kind === "mod" && card.state !== "missing" compact: true text: "INSTALLED" textColor: Theme.success @@ -127,24 +130,54 @@ Rectangle { Layout.fillWidth: true text: "Versions: " + card.versions textFormat: Text.PlainText - color: Theme.textDim + color: Theme.textMuted font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs + font.pixelSize: Theme.fontSizeMd elide: Text.ElideRight } - Item { Layout.fillHeight: true } RowLayout { Layout.fillWidth: true + visible: card.kind === "mod" + spacing: 6 + Image { + width: 16; height: 16 + sourceSize.width: 16; sourceSize.height: 16 + source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) + } + Text { + text: card.votes + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + Accessible.name: text === "Unrated" ? text : text + " positive votes" + } + Item { Layout.fillWidth: true } + Text { + text: card.fileSize + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + } + Item { Layout.fillHeight: true } + Flow { + Layout.fillWidth: true + spacing: 6 PxButton { text: "Details" variant: "ghost" onClicked: workshopPanel.openItem(card.itemId, card.kind) } - Item { Layout.fillWidth: true } PxButton { + visible: card.kind === "mod" && card.state !== "missing" + text: card.active ? "Deactivate" : "Activate" + onClicked: workshopPanel.toggleActivation(card.itemId) + } + PxButton { + visible: card.state !== "installed" text: card.actionLabel variant: card.state === "outdated" ? "primary" : "secondary" - enabled: card.state !== "installed" && !card.queued + enabled: !card.queued onClicked: workshopPanel.downloadItem(card.itemId, card.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 index 1148445..c9d7915 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -11,7 +11,7 @@ Rectangle { function applyFilters() { workshopPanel.filter(search.text.trim(), version.currentIndex === 0 ? "" : version.currentText, - source.currentValue, sort.currentValue, tag.text.trim()) + source.currentValue, sort.currentValue, tag.selectedTag) } component Copy: Text { @@ -32,7 +32,7 @@ Rectangle { Layout.fillWidth: true Layout.preferredHeight: Math.ceil(count / columns) * cellHeight cellWidth: width / columns - cellHeight: 298 + cellHeight: 382 interactive: false clip: true delegate: CatalogCard { @@ -50,7 +50,7 @@ Rectangle { ColumnLayout { spacing: 2 Heading { text: "Workshop" } - Copy { text: "Mods & collections for RimWorld"; font.pixelSize: Theme.fontSizeSm } + Copy { text: "Mods & collections for RimWorld" } } PxTextField { id: search @@ -84,6 +84,16 @@ Rectangle { 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 { @@ -106,6 +116,8 @@ Rectangle { 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 } @@ -139,14 +151,12 @@ Rectangle { Accessible.name: "Collection source" onActivated: root.applyFilters() } - PxTextField { + TagPicker { id: tag Layout.fillWidth: true Layout.maximumWidth: 230 - placeholderText: "Tag (exact match)" - maximumLength: 100 - Accessible.name: "Tag filter" - onAccepted: root.applyFilters() + options: workshopPanel.tagOptions + onSelectedTagChanged: root.applyFilters() } Item { Layout.fillWidth: true } PxComboBox { @@ -192,6 +202,8 @@ Rectangle { } ScrollView { id: contentScroll + objectName: "listingScroll" + visible: !workshopPanel.hasDetail Layout.fillWidth: true Layout.fillHeight: true clip: true @@ -208,7 +220,7 @@ Rectangle { } ColumnLayout { Layout.fillWidth: true - visible: workshopPanel.configured && !workshopPanel.hasDetail + visible: workshopPanel.configured Heading { text: workshopPanel.tab === "Discover" ? "Discover your next colony" : workshopPanel.tab } Copy { visible: workshopPanel.tab === "Discover"; text: "Explore Steam mods and collections alongside PxModRim picks. Downloads include required dependencies, without activating mods." } RowLayout { @@ -237,13 +249,30 @@ Rectangle { onClicked: workshopPanel.loadMore() } } + } + } + ScrollView { + id: detailScroll + objectName: "detailScroll" + visible: workshopPanel.hasDetail + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: availableWidth + ColumnLayout { + width: detailScroll.availableWidth ColumnLayout { Layout.fillWidth: true Layout.maximumWidth: 1200 Layout.alignment: Qt.AlignHCenter visible: workshopPanel.configured && workshopPanel.hasDetail spacing: 16 - PxButton { text: "← Back"; variant: "ghost"; onClicked: workshopPanel.back() } + PxButton { + text: "Back to " + workshopPanel.tab + iconName: "chevron-left" + Accessible.description: "Return to the listing with the same scroll position and filters" + onClicked: workshopPanel.back() + } RowLayout { Layout.fillWidth: true spacing: 16 @@ -297,19 +326,25 @@ Rectangle { Layout.fillWidth: true spacing: 6 PxBadge { text: (root.detail.sourceLabel || "").toUpperCase(); textColor: Theme.textMuted } - PxBadge { visible: root.detail.state === "installed"; text: "INSTALLED"; textColor: Theme.success } + PxBadge { visible: root.detail.kind === "mod" && 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 { Layout.fillWidth: true; text: "Supports " + (root.detail.versions || ""); font.pixelSize: Theme.fontSizeSm } - Copy { Layout.fillWidth: true; visible: !!root.detail.tags; text: root.detail.tags || ""; font.pixelSize: Theme.fontSizeSm } - Copy { Layout.fillWidth: true; visible: !!root.detail.incompatible; text: "Does not list your game version. Downloading does not make it compatible."; color: Theme.warning; font.pixelSize: Theme.fontSizeSm } - Copy { Layout.fillWidth: true; visible: !!root.detail.warning; text: root.detail.warning || ""; color: Theme.warning; font.pixelSize: Theme.fontSizeSm } + Copy { Layout.fillWidth: true; text: "Supports " + (root.detail.versions || "") } + Copy { Layout.fillWidth: true; visible: !!root.detail.tags; text: root.detail.tags || "" } + Copy { Layout.fillWidth: true; visible: !!root.detail.incompatible; text: "Does not list your game version. Downloading does not make it compatible."; color: Theme.warning } + Copy { Layout.fillWidth: true; visible: !!root.detail.warning; text: root.detail.warning || ""; color: Theme.warning } RowLayout { spacing: 8 PxButton { + visible: root.detail.kind === "mod" && root.detail.state !== "missing" + text: root.detail.active ? "Deactivate" : "Activate" + onClicked: workshopPanel.toggleActivation(root.detail.itemId) + } + PxButton { + visible: root.detail.state !== "installed" text: root.detail.actionLabel || "Download" variant: root.detail.state === "outdated" ? "primary" : "secondary" enabled: root.detail.state !== "installed" && !root.detail.queued @@ -317,8 +352,35 @@ Rectangle { } PxButton { visible: !!root.detail.workshopUrl; text: "View on Steam"; variant: "ghost"; onClicked: workshopPanel.openLink(root.detail.workshopUrl) } } - Copy { Layout.fillWidth: true; text: "Local mods · downloading does not enable or subscribe."; font.pixelSize: Theme.fontSizeSm; color: Theme.textDim } - Copy { visible: root.detail.kind === "mod"; text: (root.detail.fileSize || "") + " · " + (root.detail.votes || ""); font.pixelSize: Theme.fontSizeSm } + 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.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" } + } } } RowLayout { @@ -338,15 +400,9 @@ Rectangle { anchors.margins: 16 spacing: 10 Heading { text: "Description" } - Text { + ModDescription { Layout.fillWidth: true - text: root.detail.description || "" - textFormat: Text.RichText - wrapMode: Text.Wrap - color: Theme.textMuted - linkColor: Theme.primary - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd + description: root.detail.description || "" onLinkActivated: link => workshopPanel.openLink(link) } } diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/src/pxmodrim/ui/plugins/workshop/models.py index 6f4880a..b4c3d3c 100644 --- a/src/pxmodrim/ui/plugins/workshop/models.py +++ b/src/pxmodrim/ui/plugins/workshop/models.py @@ -1,7 +1,5 @@ from __future__ import annotations -import html -import re from collections.abc import Callable, Sequence from typing import Any from urllib.parse import urlsplit @@ -18,10 +16,7 @@ ) from pxmodrim.core.workshop import CatalogCollection, CatalogMod, InstallState - -_TOKEN = re.compile( - r"\[(/?)(b|i|u|h[1-3]|list|\*|url|img)(?:=([^\]]*))?\]", re.IGNORECASE -) +from pxmodrim.ui.panels.mod_info_data import description_to_html def safe_url(value: str) -> str: @@ -34,56 +29,6 @@ def safe_url(value: str) -> str: return value if parsed.scheme.lower() in {"http", "https"} and parsed.netloc else "" -def description_rich_text(text: str, description_format: str = "bbcode") -> str: - """Convert untrusted descriptions to the small Qt rich-text subset we emit.""" - if description_format == "text": - return html.escape(text).replace("\n", "
      ") - result: list[str] = [] - stack: list[tuple[str, str]] = [] - position = 0 - while match := _TOKEN.search(text, position): - result.append(html.escape(text[position : match.start()]).replace("\n", "
      ")) - closing, tag, argument = match.groups() - tag = tag.lower() - position = match.end() - if not closing and tag in {"url", "img"}: - end = re.search(rf"\[/{tag}\]", text[position:], re.IGNORECASE) - if end is None: - result.append(html.escape(match.group())) - continue - body = text[position : position + end.start()] - url = safe_url(argument if argument is not None else body) - label = html.escape(body if tag == "url" else "Image") - result.append( - f'{label}' - if url - else label - ) - position += end.end() - elif closing: - if stack and stack[-1][0] == "*" and tag == "list": - result.append(stack.pop()[1]) - if stack and stack[-1][0] == tag: - result.append(stack.pop()[1]) - else: - result.append(html.escape(match.group())) - elif tag == "*": - if stack and stack[-1][0] == "*": - result.append(stack.pop()[1]) - if stack and stack[-1][0] == "list": - result.append("
    • ") - stack.append((tag, "
    • ")) - else: - result.append(html.escape(match.group())) - else: - output_tag = "ul" if tag == "list" else tag - result.append(f"<{output_tag}>") - stack.append((tag, f"")) - result.append(html.escape(text[position:]).replace("\n", "
      ")) - result.extend(close for _, close in reversed(stack)) - return "".join(result) - - def item_row( item: CatalogMod | CatalogCollection, install_state: Callable[[CatalogMod], InstallState], @@ -95,6 +40,7 @@ def item_row( and game_version not in item.supported_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 @@ -107,7 +53,10 @@ def item_row( if item.file_size is not None: file_size = f"{int(item.file_size) / 1048576:.1f} MB" if item.votes is not None: - votes = f"{item.votes.up} up / {item.votes.down} down" + 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 = ( @@ -142,6 +91,7 @@ def item_row( or "Not specified", "tags": " · ".join(tag for tag in item.tags if tag not in versions), "state": state, + "active": False, "queued": False, "stateLabel": { "missing": "Not installed", @@ -151,10 +101,12 @@ def item_row( "actionLabel": action_label, "incompatible": incompatible, "memberCount": member_count, - "description": description_rich_text(item.description, item.description_format), + "description": description_to_html(item.description, item.description_format), "workshopUrl": safe_url(item.workshop_url or ""), "fileSize": file_size, "votes": votes, + "votesUp": votes_up, + "votesDown": votes_down, } @@ -171,6 +123,7 @@ class CatalogListModel(QAbstractListModel): "versions", "tags", "state", + "active", "stateLabel", "actionLabel", "incompatible", @@ -179,6 +132,8 @@ class CatalogListModel(QAbstractListModel): "workshopUrl", "fileSize", "votes", + "votesUp", + "votesDown", "queued", ) diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index de4d9e7..2d98ce8 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -25,6 +25,7 @@ ) from pxmodrim.core.workshop.catalog import FRESH_SECONDS from pxmodrim.ui.components.dialogs import await_dialog +from pxmodrim.ui.components.mod_activation import toggle_mods from pxmodrim.ui.panels.settings_panel import SettingsPanel from pxmodrim.ui.plugins.workshop.details import ( Detail, @@ -89,6 +90,9 @@ def __init__( self._pending: DownloadPlan | None = None self._planning = 0 self._items: dict[str, CatalogMod | CatalogCollection] = {} + self._known_tags: set[str] = set() + self._active_ids: set[str] = set() + self._refresh_active_ids() self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] self._qml.setObjectName("workshopView") self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) @@ -109,6 +113,7 @@ def __init__( 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._ctx.active_state_changed.connect(self._on_active_state_changed) self.destroyed.connect(lambda: self.teardown()) def teardown(self) -> None: @@ -121,6 +126,7 @@ def teardown(self) -> None: 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._ctx.active_state_changed.disconnect(self._on_active_state_changed) @property def _game_version(self) -> str: @@ -129,9 +135,20 @@ def _game_version(self) -> str: # -- rows ------------------------------------------------------------- + def _refresh_active_ids(self) -> None: + mods = self._ctx.all_mods + 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) -> 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) + row["active"] = isinstance(item, CatalogMod) and item.id in self._active_ids if isinstance(item, CatalogMod) and row["state"] != "installed": running = item.id in self._downloads.active_ids if running or item.id in self._catalog.queued_ids: @@ -407,6 +424,21 @@ async def downloadItem(self, item_id: str, kind: str) -> None: [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) + uuid = next((uuid for uuid in installed if uuid in active), installed[0]) + await toggle_mods(self._ctx, self, [uuid]) + @asyncSlot() async def updateAll(self) -> None: ids = [ @@ -473,6 +505,11 @@ async def _download(self, ids: list[str], collections: list[str]) -> None: # -- catalog events --------------------------------------------------- + def _on_active_state_changed(self, _uuids: tuple[str, ...]) -> None: + self._refresh_active_ids() + self._refresh_rows() + self.changed.emit() + def _on_queue_changed(self, *_: object) -> None: self._refresh_rows() self.changed.emit() @@ -482,12 +519,14 @@ async def _on_url_changed(self, _url: str) -> None: 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: + self._refresh_active_ids() self._recount() self._refresh_rows() self.changed.emit() @@ -532,6 +571,14 @@ def pendingPlan(self) -> bool: 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 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_workshop_models.py b/tests/test_ui/test_workshop_models.py index a6c23ca..ca3858c 100644 --- a/tests/test_ui/test_workshop_models.py +++ b/tests/test_ui/test_workshop_models.py @@ -4,63 +4,15 @@ import pytest from PySide6.QtCore import QModelIndex, QObject, Qt -from pxmodrim.core.workshop import Author, CatalogCollection, CatalogMod, Preview +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, - description_rich_text, item_row, safe_url, ) -@pytest.mark.parametrize( - ("source", "expected"), - [ - ( - "[b]Bold[/b] [i]italic[/i] [u]underlined[/u]", - "Bold italic underlined", - ), - ( - "[h1]Heading[/h1][h2]Two[/h2][h3]Three[/h3]", - "

      Heading

      Two

      Three

      ", - ), - ("[list][*]one[*]two[/list]", "
      • one
      • two
      "), - ("&", "<script>alert(1)</script>&"), - ( - '[b][/b]', - "<img src="file:///secret">", - ), - ("[url=javascript:alert(1)]click[/url]", "click"), - ("[url=file:///etc/passwd]secret[/url]", "secret"), - ("[url=data:text/html,test]data[/url]", "data"), - ( - '[url=https://example.com/?x="&y=1]safe[/url]', - '
      safe', - ), - ( - "[url]https://example.com[/url]", - 'https://example.com', - ), - ( - "[img]https://example.com/picture.png[/img]", - 'Image', - ), - ("[img]file:///secret[/img]", "Image"), - ("[b]unfinished", "unfinished"), - ("[unknown]raw[/unknown]", "[unknown]<b>raw</b>[/unknown]"), - ], -) -def test_bbcode_emits_only_safe_markup(source: str, expected: str) -> None: - assert description_rich_text(source) == expected - - -def test_text_descriptions_never_parse_bbcode_or_html() -> None: - assert ( - description_rich_text("[b]Text[/b]\nnext", "text") - == "[b]<b>Text</b>[/b]
      next" - ) - - @pytest.mark.parametrize( "url", [ @@ -138,6 +90,35 @@ def test_row_shows_update_and_game_incompatibility() -> None: 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") + 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( @@ -176,3 +157,5 @@ def test_collection_image_prefers_own_then_first_image_then_member_collage() -> 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 diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index df6b1bf..ef86819 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -2,11 +2,12 @@ import asyncio from collections.abc import AsyncIterator +from pathlib import Path from typing import Any, cast import msgspec import pytest -from PySide6.QtCore import QUrl +from PySide6.QtCore import QObject, QUrl from PySide6.QtQuickWidgets import QQuickWidget from PySide6.QtWidgets import QWidget from shiboken6 import delete, isValid @@ -14,6 +15,11 @@ 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, + CaseInsensitiveStr, + ListedMod, +) from pxmodrim.core.workshop import ( Author, CatalogCollection, @@ -29,6 +35,7 @@ WorkshopCatalog, ) from pxmodrim.ui.components import create_qml_engine +from pxmodrim.ui.panels.mod_info_data import description_to_html from pxmodrim.ui.plugins.workshop.view import WorkshopViewPanel from pxmodrim.ui.theme.qml_theme import Theme @@ -155,6 +162,20 @@ async def panel( 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: @@ -175,12 +196,14 @@ async def test_destroyed_view_does_not_receive_core_shutdown_events( 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] @@ -241,11 +264,14 @@ async def test_mod_detail_safe_description_dependencies_and_download( await asyncio.sleep(0) await view.open_item("1", "mod") detail = cast("dict[str, Any]", view.detail) - assert detail["description"] == "Safe description" + 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] @@ -257,7 +283,9 @@ async def test_incomplete_collection_requires_explicit_partial_download( 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"] == "<b>Plain text</b>" + 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 @@ -333,3 +361,153 @@ async def installed() -> list[CatalogMod]: 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 == "" + + +@pytest.mark.parametrize("state", ["installed", "outdated"]) +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, +) -> None: + view, catalog, _ = panel + steamcmd, steam, unrelated = installed_mods + view._ctx.load(installed_mods, []) + view._ctx.set_active([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 == [] + + +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.selectTab("Installed") + assert view.searchQuery == "utility" + + +async def test_back_preserves_scroll_position_and_active_filters( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, _, _ = panel + view._qml.resize(800, 600) + await view.load() + await view.filter("test search", "1.5", "all", "popular", "Utility") + 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("contentY", 184) + + await view.open_item("1", "mod") + assert view.hasDetail + + view.back() + assert not view.hasDetail + assert view.tab == "Mods" + assert view.searchQuery == "test search" + assert listing_scroll.property("contentY") == 184 From cefbdadc826b5b96db0587598d003dd8035899c4 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 02:07:38 +0300 Subject: [PATCH 27/69] feat(workshop): activate downloaded collections and colour-code buttons --- CHANGELOG.md | 1 + .../ui/components/controls/PxButton.qml | 12 ++- .../ui/plugins/workshop/CatalogCard.qml | 3 +- src/pxmodrim/ui/plugins/workshop/Workshop.qml | 28 ++++--- src/pxmodrim/ui/plugins/workshop/view.py | 76 ++++++++++++++++++- tests/test_ui/test_workshop_view.py | 55 ++++++++++++++ 6 files changed, 161 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5833825..e7fee9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. - CI release matrix builds separate `SteamWorkshop` and `NativeWorkshop` artifact packages across Linux, macOS, and Windows. +- 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/src/pxmodrim/ui/components/controls/PxButton.qml b/src/pxmodrim/ui/components/controls/PxButton.qml index 420365c..a31e040 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,8 +40,9 @@ 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 @@ -96,6 +98,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 +116,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/plugins/workshop/CatalogCard.qml b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml index a9a7429..76426a3 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml +++ b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml @@ -171,12 +171,13 @@ Rectangle { PxButton { visible: card.kind === "mod" && card.state !== "missing" text: card.active ? "Deactivate" : "Activate" + variant: card.active ? "danger" : "success" onClicked: workshopPanel.toggleActivation(card.itemId) } PxButton { visible: card.state !== "installed" text: card.actionLabel - variant: card.state === "outdated" ? "primary" : "secondary" + variant: card.state === "outdated" ? "warning" : "primary" enabled: !card.queued onClicked: workshopPanel.downloadItem(card.itemId, card.kind) } diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index c9d7915..5d91379 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -62,7 +62,7 @@ Rectangle { enabled: workshopPanel.configured onAccepted: root.applyFilters() } - PxButton { text: "Search"; enabled: workshopPanel.configured; onClicked: root.applyFilters() } + PxButton { text: "Search"; variant: "primary"; enabled: workshopPanel.configured; onClicked: root.applyFilters() } PxProgressBar { Layout.preferredWidth: 48 Layout.alignment: Qt.AlignVCenter @@ -124,7 +124,7 @@ Rectangle { PxButton { visible: workshopPanel.tab === "Installed" text: "Update all (" + workshopPanel.updateCount + ")" - variant: "primary" + variant: "warning" enabled: workshopPanel.updateCount > 0 && !workshopPanel.planning onClicked: workshopPanel.updateAll() } @@ -326,7 +326,7 @@ Rectangle { Layout.fillWidth: true spacing: 6 PxBadge { text: (root.detail.sourceLabel || "").toUpperCase(); textColor: Theme.textMuted } - PxBadge { visible: root.detail.kind === "mod" && root.detail.state !== "missing"; text: "INSTALLED"; textColor: Theme.success } + 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 } } @@ -336,17 +336,27 @@ Rectangle { Copy { Layout.fillWidth: true; visible: !!root.detail.tags; text: root.detail.tags || "" } Copy { Layout.fillWidth: true; visible: !!root.detail.incompatible; text: "Does not list your game version. Downloading does not make it compatible."; color: Theme.warning } Copy { Layout.fillWidth: true; visible: !!root.detail.warning; text: root.detail.warning || ""; color: Theme.warning } - RowLayout { + Flow { + Layout.fillWidth: true spacing: 8 PxButton { - visible: root.detail.kind === "mod" && root.detail.state !== "missing" - text: root.detail.active ? "Deactivate" : "Activate" - onClicked: workshopPanel.toggleActivation(root.detail.itemId) + 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" ? "primary" : "secondary" + variant: root.detail.state === "outdated" ? "warning" : "primary" enabled: root.detail.state !== "installed" && !root.detail.queued onClicked: workshopPanel.downloadItem(root.detail.itemId, root.detail.kind) } @@ -451,7 +461,7 @@ Rectangle { Copy { text: member.stateLabel + (member.incompatible ? " · incompatible" : ""); font.pixelSize: Theme.fontSizeSm; color: member.incompatible ? Theme.warning : Theme.textMuted } } PxButton { text: "Details"; variant: "ghost"; onClicked: workshopPanel.openItem(member.itemId, member.kind) } - PxButton { visible: member.state !== "installed"; text: member.actionLabel; variant: member.state === "outdated" ? "primary" : "secondary"; enabled: !member.queued; onClicked: workshopPanel.downloadItem(member.itemId, member.kind) } + PxButton { visible: member.state !== "installed"; text: member.actionLabel; variant: member.state === "outdated" ? "warning" : "primary"; enabled: !member.queued; onClicked: workshopPanel.downloadItem(member.itemId, member.kind) } } } } diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index 2d98ce8..260b6f5 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -25,7 +25,7 @@ ) from pxmodrim.core.workshop.catalog import FRESH_SECONDS from pxmodrim.ui.components.dialogs import await_dialog -from pxmodrim.ui.components.mod_activation import toggle_mods +from pxmodrim.ui.components.mod_activation import apply_activation, toggle_mods from pxmodrim.ui.panels.settings_panel import SettingsPanel from pxmodrim.ui.plugins.workshop.details import ( Detail, @@ -92,6 +92,7 @@ def __init__( self._items: dict[str, CatalogMod | CatalogCollection] = {} self._known_tags: set[str] = set() self._active_ids: set[str] = set() + self._pack_ids: frozenset[str] = frozenset() self._refresh_active_ids() self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] self._qml.setObjectName("workshopView") @@ -183,6 +184,12 @@ def _detail_row(self, detail: Detail) -> dict[str, Any]: for member in detail.members ) 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"] = "installed" + row["actionLabel"] = "Installed" + row["active"] = all(uuid in active for uuid in copies) return row def _refresh_rows(self) -> None: @@ -193,6 +200,42 @@ def _refresh_rows(self) -> None: def _clear_detail(self) -> None: self._detail, self._detail_data = {}, None + 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)) + self._refresh_rows() + self.changed.emit() # -- running fetches -------------------------------------------------- @@ -397,6 +440,8 @@ async def openItem(self, item_id: str, kind: str) -> None: await self.open_item(item_id, kind) async def open_item(self, item_id: str, kind: str) -> None: + self._pack_ids = frozenset() + def apply(detail: Detail) -> None: members = self._rows(detail.members) self._detail_data = detail @@ -409,6 +454,8 @@ def apply(detail: Detail) -> None: Detail, apply, ) + if kind == "collection": + await self._load_pack() @Slot() def back(self) -> None: @@ -439,6 +486,33 @@ async def toggleActivation(self, item_id: str) -> None: uuid = next((uuid for uuid in installed if uuid in active), installed[0]) await toggle_mods(self._ctx, self, [uuid]) + @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=copies) + 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 = [ diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index ef86819..a4669db 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -511,3 +511,58 @@ async def test_back_preserves_scroll_position_and_active_filters( 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, _, _ = panel + 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_partly_downloaded_collection_has_no_activation( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], + installed_mods: dict[str, ListedMod], +) -> None: + view, _, _ = panel + 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 == [] From 9a15ca90a5bc7d54a642cd8403d118e2a17f0201 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 02:44:14 +0300 Subject: [PATCH 28/69] fix(packaging): drop eager Steam WebView import from plugin package --- src/pxmodrim/ui/plugins/__init__.py | 4 ---- 1 file changed, 4 deletions(-) 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"] From 233197c3ec2a68405a2ff874c4cfa29b8360ffe8 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 03:12:04 +0300 Subject: [PATCH 29/69] perf(workshop): virtualise the listing grid and free hidden grids --- CHANGELOG.md | 1 + src/pxmodrim/ui/plugins/workshop/Workshop.qml | 69 +++++++++++++------ tests/test_ui/test_workshop_view.py | 24 ++++++- 3 files changed, 71 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7fee9a..743b448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. ### 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. diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index 5d91379..4b976f4 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -202,8 +202,8 @@ Rectangle { } ScrollView { id: contentScroll - objectName: "listingScroll" - visible: !workshopPanel.hasDetail + objectName: "discoverScroll" + visible: !workshopPanel.hasDetail && (!workshopPanel.configured || workshopPanel.tab === "Discover") Layout.fillWidth: true Layout.fillHeight: true clip: true @@ -221,36 +221,63 @@ Rectangle { ColumnLayout { Layout.fillWidth: true visible: workshopPanel.configured - Heading { text: workshopPanel.tab === "Discover" ? "Discover your next colony" : workshopPanel.tab } - Copy { visible: workshopPanel.tab === "Discover"; text: "Explore Steam mods and collections alongside PxModRim picks. Downloads include required dependencies, without activating mods." } + Heading { text: "Discover your next colony" } + Copy { text: "Explore Steam mods and collections alongside PxModRim picks. Downloads include required dependencies, without activating mods." } RowLayout { - visible: workshopPanel.tab === "Discover" Heading { text: "Collections" } PxButton { text: "Browse all"; variant: "ghost"; onClicked: workshopPanel.selectTab("Collections") } } - CatalogGrid { - visible: workshopPanel.tab === "Discover" || workshopPanel.tab === "Collections" - model: catalogCollections - } + CatalogGrid { model: visible ? catalogCollections : null } RowLayout { - visible: workshopPanel.tab === "Discover" Heading { text: "Popular mods" } PxButton { text: "Browse all"; variant: "ghost"; onClicked: workshopPanel.selectTab("Mods") } } - CatalogGrid { visible: workshopPanel.tab !== "Collections"; model: catalogMods } - Copy { - visible: !workshopPanel.busy && !workshopPanel.error && (workshopPanel.tab === "Collections" ? catalogCollections.count === 0 : catalogMods.count === 0) - text: workshopPanel.tab === "Installed" ? "No installed Workshop mods were found." : "No matching items. Try another search or filter." - } - PxButton { - text: "Load more" - visible: workshopPanel.tab === "Collections" ? catalogCollections.hasMore : (workshopPanel.tab === "Mods" || workshopPanel.tab === "Installed") && catalogMods.hasMore - enabled: !workshopPanel.busy - onClicked: workshopPanel.loadMore() - } + CatalogGrid { model: visible ? catalogMods : null } } } } + GridView { + id: listingGrid + objectName: "listingScroll" + readonly property bool collections: workshopPanel.tab === "Collections" + readonly property var listing: collections ? catalogCollections : catalogMods + property int columns: Math.max(1, Math.floor(width / 250)) + readonly property bool listingTab: workshopPanel.configured && workshopPanel.tab !== "Discover" + visible: !workshopPanel.hasDetail && listingTab + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + cellWidth: width / columns + cellHeight: 382 + model: listingTab ? listing : null + ScrollBar.vertical: PxScrollBar {} + header: Item { + width: listingGrid.width + height: pageHeading.implicitHeight + 16 + Heading { id: pageHeading; width: parent.width; text: workshopPanel.tab } + } + footer: ColumnLayout { + width: listingGrid.width + height: implicitHeight + spacing: 12 + Copy { + visible: !workshopPanel.busy && !workshopPanel.error && listingGrid.listing.count === 0 + text: workshopPanel.tab === "Installed" ? "No installed Workshop mods were found." : "No matching items. Try another search or filter." + } + PxButton { + Layout.alignment: Qt.AlignHCenter + text: "Load more" + visible: listingGrid.listing.hasMore + enabled: !workshopPanel.busy + onClicked: workshopPanel.loadMore() + } + Item { implicitHeight: 12 } + } + delegate: CatalogCard { + width: GridView.view.cellWidth - 12 + height: GridView.view.cellHeight - 12 + } + } ScrollView { id: detailScroll objectName: "detailScroll" diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index a4669db..3a87e49 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -491,22 +491,42 @@ async def test_known_tag_options_accumulate_loaded_tags_but_not_version_tags( async def test_back_preserves_scroll_position_and_active_filters( panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], ) -> None: - view, _, _ = panel - view._qml.resize(800, 600) + 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 view.back() + await asyncio.sleep(0.2) + view._qml.grab() assert not view.hasDetail assert view.tab == "Mods" assert view.searchQuery == "test search" From 9d52d5b4700ce8f4d484e9203e03bbd0227b8eda Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 03:15:51 +0300 Subject: [PATCH 30/69] perf(workshop): cache the Installed list and render descriptions only in details --- CHANGELOG.md | 1 + src/pxmodrim/ui/plugins/workshop/models.py | 6 +++++- src/pxmodrim/ui/plugins/workshop/view.py | 11 +++++++---- tests/test_ui/test_workshop_models.py | 2 +- tests/test_ui/test_workshop_view.py | 19 +++++++++++++++++++ 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 743b448..30b2dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. ### 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. diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/src/pxmodrim/ui/plugins/workshop/models.py index b4c3d3c..13a712f 100644 --- a/src/pxmodrim/ui/plugins/workshop/models.py +++ b/src/pxmodrim/ui/plugins/workshop/models.py @@ -33,6 +33,8 @@ def item_row( item: CatalogMod | CatalogCollection, install_state: Callable[[CatalogMod], InstallState], game_version: str, + *, + detail: bool = False, ) -> dict[str, Any]: incompatible = bool( game_version @@ -101,7 +103,9 @@ def item_row( "actionLabel": action_label, "incompatible": incompatible, "memberCount": member_count, - "description": description_to_html(item.description, item.description_format), + "description": description_to_html(item.description, item.description_format) + if detail + else "", "workshopUrl": safe_url(item.workshop_url or ""), "fileSize": file_size, "votes": votes, diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index 260b6f5..a88c415 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -145,10 +145,14 @@ def _refresh_active_ids(self) -> None: and (published_id := mods[uuid].published_file_id) is not None } - def _row(self, item: CatalogMod | CatalogCollection) -> dict[str, Any]: + 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) + 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, CatalogMod) and row["state"] != "installed": running = item.id in self._downloads.active_ids @@ -161,7 +165,7 @@ def _rows(self, items: Iterable[CatalogMod | CatalogCollection]) -> list[Any]: return [self._row(item) for item in items] def _detail_row(self, detail: Detail) -> dict[str, Any]: - row = self._row(detail.item) + 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"]: @@ -374,7 +378,6 @@ def apply(mods: list[CatalogMod]) -> None: self._catalog.installed_with_updates, list[CatalogMod], apply, - fresh_for=0, ) def _installed_cursor(self) -> str | None: diff --git a/tests/test_ui/test_workshop_models.py b/tests/test_ui/test_workshop_models.py index ca3858c..4502e4b 100644 --- a/tests/test_ui/test_workshop_models.py +++ b/tests/test_ui/test_workshop_models.py @@ -113,7 +113,7 @@ def test_vote_approval_and_counts( @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") + row = item_row(mod, lambda _: "missing", "1.5", detail=True) assert row["description"] == description_to_html( mod.description, description_format ) diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index 3a87e49..6a9c541 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -586,3 +586,22 @@ async def test_partly_downloaded_collection_has_no_activation( await view.toggleCollection() await view.activateOnlyCollection() assert view._ctx.active_uuids == [] + + +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.selectTab(tab) + assert fetches == 1 and view.mods_model.count == 1 From f5c67bc0ed6c43878967e8acd1a0ed6f211fa555 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 03:22:55 +0300 Subject: [PATCH 31/69] ci: build a NoWorkshop variant without the downloader alongside Steam and Native --- .github/workflows/build.yml | 16 ++++----- AGENTS.md | 1 + CHANGELOG.md | 2 +- packaging/build.py | 66 ++++++++++++++++++++++++------------- src/pxmodrim/_app.py | 7 ++++ tests/test_packaging.py | 34 +++++++++++++++++++ tests/test_plugin.py | 27 +++++++++++++++ 7 files changed, 121 insertions(+), 32 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d31b006..5d66a31 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - workshop: [SteamWorkshop, NativeWorkshop] + workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] steps: - uses: actions/checkout@v7 @@ -47,7 +47,7 @@ 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 --workshop ${{ matrix.workshop }} @@ -106,7 +106,7 @@ jobs: strategy: fail-fast: false matrix: - workshop: [SteamWorkshop, NativeWorkshop] + workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] container: image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-24.08 options: --privileged @@ -137,7 +137,7 @@ jobs: strategy: fail-fast: false matrix: - workshop: [SteamWorkshop, NativeWorkshop] + workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] steps: - uses: actions/checkout@v7 @@ -162,7 +162,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: | @@ -199,7 +199,7 @@ jobs: strategy: fail-fast: false matrix: - workshop: [SteamWorkshop, NativeWorkshop] + workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] env: MACOS_SIGNING_CONFIGURED: >- ${{ secrets.MACOS_CERT != '' && @@ -232,7 +232,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: Build with Nuitka run: just build-release-bundled --workshop ${{ matrix.workshop }} @@ -319,7 +319,7 @@ jobs: - name: Package Windows release run: | - for variant in SteamWorkshop NativeWorkshop; do + for variant in NoWorkshop SteamWorkshop NativeWorkshop; do 7z a "PxModRim-Windows-${variant}.zip" "./artifacts/PxModRim-Windows-${variant}/*" done diff --git a/AGENTS.md b/AGENTS.md index a4a9e5f..1b591ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ Downloads are plugins; the UI only talks to `core/downloads/manager.py` (`Downlo - 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. +- 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. ## Key conventions - `from __future__ import annotations` in every `.py` file diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b2dae..a3b72e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. -- CI release matrix builds separate `SteamWorkshop` and `NativeWorkshop` artifact packages across Linux, macOS, and Windows. +- 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 diff --git a/packaging/build.py b/packaging/build.py index 70a7981..4b573bf 100644 --- a/packaging/build.py +++ b/packaging/build.py @@ -31,6 +31,47 @@ def debian_architecture(machine: str | None = None) -> str: ) from None +_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]: @@ -51,28 +92,7 @@ def get_standalone_args( args.append("--include-qt-plugins=qml") - if workshop == "NativeWorkshop": - args.extend( - [ - ( - "--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*", - ] - ) - else: - args.extend( - [ - "--nofollow-import-to=pxmodrim.ui.plugins.workshop", - "--noinclude-data-files=pxmodrim/ui/plugins/workshop", - ] - ) + args.extend(_variant_exclusions(workshop)) if release: args.extend( @@ -572,7 +592,7 @@ def main() -> None: parser.add_argument("--bundle-qt", action="store_true") parser.add_argument( "--workshop", - choices=("SteamWorkshop", "NativeWorkshop"), + choices=("NoWorkshop", "SteamWorkshop", "NativeWorkshop"), default="NativeWorkshop", ) options = parser.parse_args() diff --git a/src/pxmodrim/_app.py b/src/pxmodrim/_app.py index 441d09b..154b4a1 100644 --- a/src/pxmodrim/_app.py +++ b/src/pxmodrim/_app.py @@ -103,6 +103,13 @@ def _parse_disabled_plugins() -> set[str]: 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"}, } 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 From 8c35b52b4236db353a6f82667de91b200160f971 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 03:33:22 +0300 Subject: [PATCH 32/69] fix(ui): enforce Basic QQuickStyle across platforms and filter style customization warnings in tests --- CHANGELOG.md | 1 + src/pxmodrim/_app.py | 1 + src/pxmodrim/core/workshop/catalog.py | 8 +++- src/pxmodrim/ui/components/svg_provider.py | 3 ++ tests/conftest.py | 2 + tests/test_ui/test_workshop_view.py | 10 +++- tests/test_workshop/test_catalog.py | 56 ++++++++++++++++++++++ 7 files changed, 79 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3b72e8..2e6ebce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. ### 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. diff --git a/src/pxmodrim/_app.py b/src/pxmodrim/_app.py index 154b4a1..474fef7 100644 --- a/src/pxmodrim/_app.py +++ b/src/pxmodrim/_app.py @@ -19,6 +19,7 @@ # this matches the workaround used by Anki and qutebrowser for QTBUG-113574. os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "RoundPreferFloor") os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1") +os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Basic") _we_verbose = os.environ.get("PX_WEBENGINE_VERBOSE") if _we_verbose is not None: diff --git a/src/pxmodrim/core/workshop/catalog.py b/src/pxmodrim/core/workshop/catalog.py index f285256..98899ee 100644 --- a/src/pxmodrim/core/workshop/catalog.py +++ b/src/pxmodrim/core/workshop/catalog.py @@ -208,10 +208,16 @@ async def cached[T]( _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 @@ -224,7 +230,7 @@ async def cached[T]( ) except OSError as exc: logger.warning("[workshop] cannot write cache: {}", exc) - if entry is None or value != entry[1]: + if changed: yield value def _install_state( diff --git a/src/pxmodrim/ui/components/svg_provider.py b/src/pxmodrim/ui/components/svg_provider.py index 8d03f75..e5723f9 100644 --- a/src/pxmodrim/ui/components/svg_provider.py +++ b/src/pxmodrim/ui/components/svg_provider.py @@ -6,6 +6,7 @@ from PySide6.QtGui import QPainter, QPixmap from PySide6.QtQml import QQmlEngine from PySide6.QtQuick import QQuickImageProvider +from PySide6.QtQuickControls2 import QQuickStyle from PySide6.QtSvg import QSvgRenderer from pxmodrim.ui.components.icons import svg_str @@ -56,6 +57,8 @@ def create_qml_engine(parent: QObject | None = None) -> QQmlEngine: caller can no longer forget the registration the way a bare ``QQmlEngine()`` call would. """ + if not QQuickStyle.name(): + QQuickStyle.setStyle("Basic") engine = QQmlEngine(parent) engine.addImageProvider("icons", SvgIconProvider()) return engine diff --git a/tests/conftest.py b/tests/conftest.py index d8958de..a5cb68e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,11 @@ from __future__ import annotations import gc +import os 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_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index 6a9c541..f65ada3 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -152,7 +152,15 @@ async def panel( theme = Theme(engine) engine.rootContext().setContextProperty("Theme", theme) warnings: list[Any] = [] - engine.warnings.connect(warnings.extend) + + 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 diff --git a/tests/test_workshop/test_catalog.py b/tests/test_workshop/test_catalog.py index 1e877be..bce1d62 100644 --- a/tests/test_workshop/test_catalog.py +++ b/tests/test_workshop/test_catalog.py @@ -332,3 +332,59 @@ async def must_not_run() -> list[int]: 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(lambda: ctx.config) + 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(lambda: ctx.config) + first.setup(ctx) + second = WorkshopCatalog(lambda: ctx.config) + 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() From 3b2d7b9f46d9a843f5b711359e3a5c30f66cf84e Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 03:38:18 +0300 Subject: [PATCH 33/69] fix(tests): wait for organizer rule window activation --- CHANGELOG.md | 1 + tests/test_ui/test_organizer_rule_editor.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6ebce..f60b733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Fixed +- 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. diff --git a/tests/test_ui/test_organizer_rule_editor.py b/tests/test_ui/test_organizer_rule_editor.py index fdd8973..a9dac62 100644 --- a/tests/test_ui/test_organizer_rule_editor.py +++ b/tests/test_ui/test_organizer_rule_editor.py @@ -162,6 +162,8 @@ async def test_rule_editor_persists_ordered_rules_and_keeps_invalid_draft( async def open_editor(count: int) -> list[QQuickItem]: QTest.mouseClick(rules_button, Qt.MouseButton.LeftButton) await _until(lambda: bool(dialog.property("opened"))) + # Opening the QML root does not wait for native window activation. + assert QTest.qWaitForWindowActive(view._rules_dialog) return await _laid_out_rows(dialog, count) async def save_and_close() -> None: @@ -293,6 +295,7 @@ async def test_rule_editor_adds_standard_rules_only_from_clean_draft( assert dialog is not None QTest.mouseClick(view.rules_button, Qt.MouseButton.LeftButton) await _until(lambda: bool(dialog.property("opened"))) + assert QTest.qWaitForWindowActive(view._rules_dialog) await _laid_out_rows(dialog, 0) standard = dialog.findChild(QQuickItem, "organizerAddStandardRules") assert standard is not None From 9a85a16cff3402f6f46a0dda7f38e053da42fe62 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 03:37:28 +0300 Subject: [PATCH 34/69] perf(workshop): lazily create card control tooltips --- CHANGELOG.md | 1 + .../ui/components/controls/PxBadge.qml | 10 ++- .../ui/components/controls/PxButton.qml | 10 ++- tests/test_ui/test_workshop_view.py | 88 ++++++++++++++++++- 4 files changed, 102 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f60b733..2e065d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. ### 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. 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 a31e040..f404145 100644 --- a/src/pxmodrim/ui/components/controls/PxButton.qml +++ b/src/pxmodrim/ui/components/controls/PxButton.qml @@ -48,9 +48,13 @@ Button { 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 { diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index f65ada3..83a9e2f 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -7,9 +7,11 @@ import msgspec import pytest -from PySide6.QtCore import QObject, QUrl +from PySide6.QtCore import QObject, QPoint, QUrl +from PySide6.QtGui import QAccessible from PySide6.QtQuickWidgets import QQuickWidget from PySide6.QtWidgets import QWidget +from pytestqt.qtbot import QtBot from shiboken6 import delete, isValid from pxmodrim.core.config import AppConfig, ConfigService @@ -613,3 +615,87 @@ async def installed() -> list[CatalogMod]: for tab in ("Installed", "Mods", "Installed"): await view.selectTab(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() From 22662f990acdcc00ac7ab426782dc5397fe17bb0 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 03:40:27 +0300 Subject: [PATCH 35/69] test(ui): increase async UI pump timeout to 10s for loaded CI runners --- tests/test_ui/test_mod_tree_model.py | 2 +- tests/test_ui/test_organizer_rule_editor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_ui/test_mod_tree_model.py b/tests/test_ui/test_mod_tree_model.py index c584610..b2b133a 100644 --- a/tests/test_ui/test_mod_tree_model.py +++ b/tests/test_ui/test_mod_tree_model.py @@ -249,7 +249,7 @@ def _find_quick_item(root_item: QQuickItem, name: str) -> QQuickItem | None: return None -async def _until(predicate: Callable[[], object], timeout: float = 3.0) -> None: +async def _until(predicate: Callable[[], object], timeout: float = 10.0) -> None: deadline = asyncio.get_running_loop().time() + timeout while not predicate() and asyncio.get_running_loop().time() < deadline: await asyncio.sleep(0.02) diff --git a/tests/test_ui/test_organizer_rule_editor.py b/tests/test_ui/test_organizer_rule_editor.py index a9dac62..9540b68 100644 --- a/tests/test_ui/test_organizer_rule_editor.py +++ b/tests/test_ui/test_organizer_rule_editor.py @@ -39,7 +39,7 @@ def qapp() -> Iterator[QApplication]: yield app -async def _until(condition: Callable[[], bool], timeout: float = 3.0) -> None: +async def _until(condition: Callable[[], bool], timeout: float = 10.0) -> None: """Pump both Qt and asyncio (asyncSlot tasks) until ``condition`` holds.""" loop = asyncio.get_running_loop() deadline = loop.time() + timeout From 0fc709ffb15d1c1aee05f9f17db7b6c6fab8c5ee Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 04:42:29 +0300 Subject: [PATCH 36/69] fix(workshop): evict unavailable Steam catalog records --- CHANGELOG.md | 1 + cf-workers/README.md | 2 + .../migrations/0005_catalog_eviction.sql | 3 + cf-workers/steam.ts | 24 +++- cf-workers/tests/catalog.test.ts | 107 +++++++++++++++++- 5 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 cf-workers/migrations/0005_catalog_eviction.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e065d9..94bdfc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Fixed +- 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. - 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. diff --git a/cf-workers/README.md b/cf-workers/README.md index 86d9913..8767c9b 100644 --- a/cf-workers/README.md +++ b/cf-workers/README.md @@ -69,6 +69,8 @@ All Workshop IDs and file sizes are decimal **strings**, not JavaScript numbers. 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 | 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/steam.ts b/cf-workers/steam.ts index 01be862..cf17a51 100644 --- a/cf-workers/steam.ts +++ b/cf-workers/steam.ts @@ -144,7 +144,7 @@ async function authorsFor(env: Env, details: SteamFile[]): Promise): CatalogItem { @@ -215,6 +215,7 @@ async function saveItems(db: D1Database, items: CatalogItem[]): Promise { async function fetchItems(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'); @@ -227,12 +228,29 @@ async function fetchItems(env: Env, ids: string[]): Promise chunk.includes(file.publishedfileid) && publicFile(file)); + 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)); - await saveItems(env.DB, items); + 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; } diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index d8562b5..4dbaaef 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -36,6 +36,7 @@ const files: Record = { let flakyCalls = 0; let queryCalls = 0; +const detailFailures = new Map(); async function upstream(request: Request): Promise { const url = new URL(request.url); @@ -48,10 +49,16 @@ async function upstream(request: Request): Promise { publishedfileids?: string[]; filetype?: number; search_text?: string; cursor?: string; numperpage?: number; requiredtags?: string[]; }; if (url.pathname.includes('GetDetails')) { - if (input.publishedfileids?.includes('999')) { + 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: (input.publishedfileids ?? []).flatMap(id => files[id] ? [files[id]] : []) } }); + 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++; @@ -84,7 +91,7 @@ function statements(sql: string): string[] { } async function migrate(database: D1Database): Promise { - for (const file of ['0001_dependencies.sql', '0002_catalog.sql', '0003_search.sql', '0004_query_cache.sql']) { + for (const file of ['0001_dependencies.sql', '0002_catalog.sql', '0003_search.sql', '0004_query_cache.sql', '0005_catalog_eviction.sql']) { const sql = await readFile(resolvePath('migrations', file), 'utf8'); await database.batch(statements(sql).map(statement => database.prepare(statement))); } @@ -442,3 +449,97 @@ test('once the index is ready, mod browsing without text comes from it, except t 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); + } + } +}); From 19feade880fee57b1ed3002d518f766524867f35 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 04:42:48 +0300 Subject: [PATCH 37/69] fix(workshop): refresh installed sets and deactivate duplicate copies --- CHANGELOG.md | 1 + .../core/services/activation_service.py | 17 ++- src/pxmodrim/ui/components/mod_activation.py | 2 +- src/pxmodrim/ui/plugins/workshop/view.py | 30 ++++- .../test_services/test_activation_service.py | 23 ++++ tests/test_ui/test_mod_tree_model.py | 2 +- tests/test_ui/test_workshop_view.py | 105 +++++++++++++++++- 7 files changed, 169 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94bdfc9..f624ed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Fixed - 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. - 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. 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/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/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index a88c415..1d36e48 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Awaitable, Callable, Iterable +from hashlib import sha256 from pathlib import Path from typing import TYPE_CHECKING, Any, cast @@ -25,7 +26,7 @@ ) from pxmodrim.core.workshop.catalog import FRESH_SECONDS from pxmodrim.ui.components.dialogs import await_dialog -from pxmodrim.ui.components.mod_activation import apply_activation, toggle_mods +from pxmodrim.ui.components.mod_activation import apply_activation from pxmodrim.ui.panels.settings_panel import SettingsPanel from pxmodrim.ui.plugins.workshop.details import ( Detail, @@ -373,8 +374,15 @@ def apply(mods: list[CatalogMod]) -> None: 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( - "installed", + f"installed|{fingerprint}", self._catalog.installed_with_updates, list[CatalogMod], apply, @@ -486,8 +494,11 @@ async def toggleActivation(self, item_id: str) -> None: if not installed: return active = set(self._ctx.active_uuids) - uuid = next((uuid for uuid in installed if uuid in active), installed[0]) - await toggle_mods(self._ctx, self, [uuid]) + 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: @@ -496,7 +507,16 @@ async def toggleCollection(self) -> None: return active = set(self._ctx.active_uuids) if all(uuid in active for uuid in copies): - await apply_activation(self._ctx, self, disable=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) 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_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_workshop_view.py b/tests/test_ui/test_workshop_view.py index 83a9e2f..91fc401 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -19,7 +19,9 @@ from pxmodrim.core.downloads import DownloadManager from pxmodrim.core.models.metadata.structures import ( AboutXmlMod, + BaseRules, CaseInsensitiveStr, + DependencyMod, ListedMod, ) from pxmodrim.core.workshop import ( @@ -374,17 +376,67 @@ async def installed() -> list[CatalogMod]: 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.selectTab("Installed") + await view.selectTab("Mods") + await view.selectTab("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.selectTab("Mods") + await view.selectTab("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([steam, unrelated]) + 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") @@ -584,6 +636,57 @@ async def test_downloaded_collection_activates_all_or_only_its_pack( 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, _, _ = panel + 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, _, _ = panel + 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], From f611cfab50b9dc302a30c9adfcab831e364591c9 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 04:42:43 +0300 Subject: [PATCH 38/69] fix(workshop): isolate cancellation and handle unavailable details --- CHANGELOG.md | 1 + src/pxmodrim/core/downloads/manager.py | 4 +- src/pxmodrim/core/workshop/client.py | 18 +++- tests/test_downloads/test_steam.py | 67 ++++++++++++++ tests/test_workshop/test_client.py | 117 ++++++++++++++++++++++++- 5 files changed, 201 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f624ed8..ee46e1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - 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. diff --git a/src/pxmodrim/core/downloads/manager.py b/src/pxmodrim/core/downloads/manager.py index 8b1ec04..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 diff --git a/src/pxmodrim/core/workshop/client.py b/src/pxmodrim/core/workshop/client.py index c00f3d2..a494aaf 100644 --- a/src/pxmodrim/core/workshop/client.py +++ b/src/pxmodrim/core/workshop/client.py @@ -116,15 +116,25 @@ async def collections(self, q: CatalogQuery) -> CatalogPage[CatalogCollection]: ) async def mod(self, id: str) -> CatalogMod | None: - return await self._request("GET", f"mods/{quote(id, safe='')}", CatalogMod) + 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.") - return await self._request( - "GET", f"collections/{source}/{quote(key, safe='')}", CollectionDetail - ) + 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] = [] diff --git a/tests/test_downloads/test_steam.py b/tests/test_downloads/test_steam.py index 9d4c5d7..ecdc229 100644 --- a/tests/test_downloads/test_steam.py +++ b/tests/test_downloads/test_steam.py @@ -14,12 +14,15 @@ 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, 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: @@ -372,6 +375,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")]) + ctx.config.workshop_auto_update_hours = 6 + manager = download_manager(ctx) + catalog = WorkshopCatalog(lambda: ctx.config) + 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] = {} diff --git a/tests/test_workshop/test_client.py b/tests/test_workshop/test_client.py index 596fd08..1ff8292 100644 --- a/tests/test_workshop/test_client.py +++ b/tests/test_workshop/test_client.py @@ -6,13 +6,16 @@ import msgspec import pytest +from pxmodrim.core.config import AppConfig 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( @@ -119,7 +122,7 @@ def handler(request: httpx.Request) -> httpx.Response: await client.shutdown() -@pytest.mark.parametrize("status", [400, 401, 404, 502, 503]) +@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 @@ -146,6 +149,118 @@ def handler(_: httpx.Request) -> httpx.Response: 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"}}) + + config = AppConfig() + catalog = WorkshopCatalog( + lambda: config, + 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"] ) From 55827cd350d1cc0f58f981287f1fed94eb8024a8 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 04:46:27 +0300 Subject: [PATCH 39/69] fix(build): preserve explicitly synced variant environments --- .github/workflows/build.yml | 5 +++++ AGENTS.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5d66a31..3928397 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,6 +16,8 @@ jobs: fail-fast: false matrix: workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] + env: + UV_NO_SYNC: '1' steps: - uses: actions/checkout@v7 @@ -138,6 +140,8 @@ jobs: fail-fast: false matrix: workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] + env: + UV_NO_SYNC: '1' steps: - uses: actions/checkout@v7 @@ -201,6 +205,7 @@ jobs: matrix: workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] env: + UV_NO_SYNC: '1' MACOS_SIGNING_CONFIGURED: >- ${{ secrets.MACOS_CERT != '' && secrets.MACOS_CERT_PASSWORD != '' && diff --git a/AGENTS.md b/AGENTS.md index 1b591ac..61ea438 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ Downloads are plugins; the UI only talks to `core/downloads/manager.py` (`Downlo - 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. -- 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 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 From df0d572da61b695272787e98487aeb139558f5e2 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:03:13 +0300 Subject: [PATCH 40/69] fix(workshop): verify omitted index entries without starvation; keep installed view and downloads consistent --- cf-workers/README.md | 1 + cf-workers/indexer.ts | 75 ++++++++++-- cf-workers/steam.ts | 6 +- cf-workers/tests/catalog.test.ts | 93 +++++++++++++++ src/pxmodrim/ui/plugins/workshop/view.py | 44 ++++++- tests/test_ui/test_workshop_view.py | 143 +++++++++++++++++++++-- 6 files changed, 337 insertions(+), 25 deletions(-) diff --git a/cf-workers/README.md b/cf-workers/README.md index 8767c9b..9715d1c 100644 --- a/cf-workers/README.md +++ b/cf-workers/README.md @@ -168,6 +168,7 @@ JSON requests require `application/json` and are bounded to 64 KiB, including st - `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. The catalog cache is separate from `items`: the legacy resolver intentionally strips nested collection children in some paths; catalog resolution retains them. diff --git a/cf-workers/indexer.ts b/cf-workers/indexer.ts index 6c2291d..77db302 100644 --- a/cf-workers/indexer.ts +++ b/cf-workers/indexer.ts @@ -1,10 +1,11 @@ -import { queryFiles } from './steam'; +import { queryFiles, refreshSteamItems } from './steam'; import 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; @@ -27,6 +28,62 @@ async function saveState(db: D1Database, state: IndexState): Promise { ).bind(state.cursor, state.cycle_started_at, state.since, state.last_started_at, state.full_at, state.indexed, Date.now()).run(); } +/** Refreshes `ids`, bisecting on failure so one bad file cannot block its neighbours. */ +async function refreshIsolating(env: Env, ids: string[]): Promise { + try { + await refreshSteamItems(env, ids); + return true; + } catch (error) { + if (ids.length === 1) { + console.error(`[INDEX] Verification of ${ids[0]} failed`, error instanceof Error ? error.message : error); + return false; + } + } + const middle = ids.length >> 1; + const left = await refreshIsolating(env, ids.slice(0, middle)); + const right = await refreshIsolating(env, ids.slice(middle)); + 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); + 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 @@ -42,7 +99,7 @@ export async function indexTick(env: Env): Promise { 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; page++) { + 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); @@ -51,12 +108,14 @@ export async function indexTick(env: Env): Promise { await saveState(env.DB, state); continue; } - 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`); + if (state.since !== 0) { + await finishCycle(env.DB, state, now); + return; + } + state.cursor = `${VERIFY_PREFIX}0:`; await saveState(env.DB, state); - return; } + 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/steam.ts b/cf-workers/steam.ts index cf17a51..b5f63c2 100644 --- a/cf-workers/steam.ts +++ b/cf-workers/steam.ts @@ -213,7 +213,7 @@ async function saveItems(db: D1Database, items: CatalogItem[]): Promise { console.error('Search index update failed', error instanceof Error ? error.message : error)); } -async function fetchItems(env: Env, ids: string[]): Promise> { +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) { @@ -273,9 +273,9 @@ export async function getSteamItems(env: Env, requestedIds: string[], ctx?: Exec } } if (ctx && stale.length) { - ctx.waitUntil(fetchItems(env, stale).then(() => undefined).catch(error => console.error('Background refresh failed', error instanceof Error ? error.message : error))); + ctx.waitUntil(refreshSteamItems(env, stale).then(() => undefined).catch(error => console.error('Background refresh failed', error instanceof Error ? error.message : error))); } - const fetched = await fetchItems(env, ids.filter(id => !items.has(id))); + 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)) }; } diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index 4dbaaef..d39f3ed 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -543,3 +543,96 @@ test('failed and partial Steam refreshes retain cached metadata and search entri } } }); + +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', 'http'); + 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); +}); diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index 1d36e48..a5c83e9 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -87,6 +87,7 @@ def __init__( self._detail: dict[str, Any] = {} self._detail_data: Detail | None = None self._installed = InstalledList() + self._installed_dirty = False self._update_count = 0 self._pending: DownloadPlan | None = None self._planning = 0 @@ -238,7 +239,11 @@ async def _load_pack(self) -> None: return if generation != self._generation: return - self._pack_ids = frozenset((*plan.to_download, *plan.already_current)) + self._pack_ids = ( + frozenset((*plan.to_download, *plan.already_current)) + if plan.is_complete + else frozenset() + ) self._refresh_rows() self.changed.emit() @@ -291,6 +296,12 @@ async def retry() -> None: 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, @@ -311,6 +322,8 @@ async def _run_cached( # -- 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 @@ -369,6 +382,8 @@ async def _load_installed(self, append: bool) -> None: ) return + self._installed_dirty = False + def apply(mods: list[CatalogMod]) -> None: self._installed.replace(mods) self._recount() @@ -468,12 +483,16 @@ def apply(detail: Detail) -> None: if kind == "collection": await self._load_pack() - @Slot() - def back(self) -> None: + @asyncSlot() + async def back(self) -> None: + if self._torn_down: + return self._generation += 1 self._clear_detail() self._error, self._busy = "", False self.changed.emit() + if self._tab == "Installed" and self._installed_dirty: + await self.load() @asyncSlot(str, str) async def downloadItem(self, item_id: str, kind: str) -> None: @@ -548,6 +567,8 @@ async def updateAll(self) -> None: @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) @@ -581,17 +602,23 @@ def _queue_plan(self, plan: DownloadPlan) -> None: 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 self.changed.emit() try: plan = await self._catalog.plan(ids, collections) except _FAILURES as exc: - self._notice = f"Could not prepare the download: {_message(exc)}" + if not self._torn_down: + self._notice = f"Could not prepare the download: {_message(exc)}" return finally: self._planning -= 1 - self.changed.emit() + if not self._torn_down: + self.changed.emit() + if self._torn_down: + return if not plan.is_complete: self._pending, self._notice = plan, incomplete_plan_notice(plan) elif not plan.to_download: @@ -613,6 +640,8 @@ def _on_queue_changed(self, *_: object) -> None: @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() @@ -623,11 +652,14 @@ async def _on_url_changed(self, _url: str) -> None: @asyncSlot(object) async def _on_installed_changed(self, _value: None) -> None: + if self._torn_down: + return + self._installed_dirty = True self._refresh_active_ids() self._recount() self._refresh_rows() self.changed.emit() - if self._tab == "Installed" and not self._busy: + if self._tab == "Installed" and not self._busy and self._detail_data is None: await self.load() # -- QML properties --------------------------------------------------- diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index 91fc401..2ec4bd2 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -92,6 +92,7 @@ def __init__(self, ctx: CoreContext) -> None: member_count=2, ) self.failure = False + self.collection_complete = False self.downloaded: list[str] = [] self.queries: list[CatalogQuery] = [] @@ -118,6 +119,11 @@ 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 ) @@ -129,13 +135,14 @@ 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 collection_ids else [], - collection_ids, + ["missing"] if incomplete else [], + collection_ids if incomplete else [], {"1": "Mod", "2": "Dependency"}, - not collection_ids, + not incomplete, ) def enqueue(self, plan: DownloadPlan) -> None: @@ -586,7 +593,7 @@ async def long_listing(query: CatalogQuery) -> CatalogPage[CatalogMod]: await view.open_item("1", "mod") assert view.hasDetail - view.back() + await view.back() await asyncio.sleep(0.2) view._qml.grab() assert not view.hasDetail @@ -600,7 +607,8 @@ async def test_downloaded_collection_activates_all_or_only_its_pack( installed_mods: dict[str, ListedMod], tmp_path: Path, ) -> None: - view, _, _ = panel + view, catalog, _ = panel + catalog.collection_complete = True steamcmd, _, dependency = installed_mods outside = AboutXmlMod( name="Outside", @@ -640,7 +648,8 @@ async def test_collection_deactivation_removes_every_active_copy( panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], installed_mods: dict[str, ListedMod], ) -> None: - view, _, _ = panel + 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 @@ -658,7 +667,8 @@ async def test_activate_only_collection_keeps_dependents_of_retained_duplicate( ) -> None: from PySide6.QtWidgets import QMessageBox - view, _, _ = panel + view, catalog, _ = panel + catalog.collection_complete = True steamcmd, steam, dependent = installed_mods mod = cast("AboutXmlMod", installed_mods[dependent]) mod.about_rules = BaseRules( @@ -691,7 +701,8 @@ async def test_partly_downloaded_collection_has_no_activation( panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], installed_mods: dict[str, ListedMod], ) -> None: - view, _, _ = panel + view, catalog, _ = panel + catalog.collection_complete = True steamcmd, _, _ = installed_mods view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) await view.open_item("picked:test", "collection") @@ -701,6 +712,122 @@ async def test_partly_downloaded_collection_has_no_activation( 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.selectTab("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: From 5ecec605011a01cddde68010a56c9e041f58e78a Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:08:23 +0300 Subject: [PATCH 41/69] feat(ui): pxmodrim:// routes and detail history for Workshop back navigation --- src/pxmodrim/ui/context.py | 9 ++-- src/pxmodrim/ui/navigation.py | 33 +++++++++++++++ .../ui/plugins/steam_workshop/plugin.py | 3 +- src/pxmodrim/ui/plugins/workshop/Workshop.qml | 4 +- src/pxmodrim/ui/plugins/workshop/view.py | 42 ++++++++++++++++++- src/pxmodrim/ui/window/main_window.py | 13 ++++-- tests/test_ui/test_navigation.py | 21 ++++++++++ tests/test_ui/test_workshop_view.py | 39 +++++++++++++++++ 8 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 src/pxmodrim/ui/navigation.py create mode 100644 tests/test_ui/test_navigation.py diff --git a/src/pxmodrim/ui/context.py b/src/pxmodrim/ui/context.py index ebeb53c..647ac13 100644 --- a/src/pxmodrim/ui/context.py +++ b/src/pxmodrim/ui/context.py @@ -28,6 +28,9 @@ class RailView(QWidget): icon_name: ClassVar[str] label: ClassVar[str] + def open_route(self, path: tuple[str, ...]) -> None: + """Handle the part of a ``pxmodrim:///…`` link after the view id.""" + def __init__( self, ctx: CoreContext, @@ -91,10 +94,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 ``pxmodrim://[/…]`` 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..fd3d7b0 --- /dev/null +++ b/src/pxmodrim/ui/navigation.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from dataclasses import dataclass +from urllib.parse import quote, unquote, urlsplit + +SCHEME = "pxmodrim" + + +@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 ``pxmodrim://[/…]``; 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/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/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index 4b976f4..19dc95a 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -295,9 +295,9 @@ Rectangle { visible: workshopPanel.configured && workshopPanel.hasDetail spacing: 16 PxButton { - text: "Back to " + workshopPanel.tab + text: workshopPanel.backLabel iconName: "chevron-left" - Accessible.description: "Return to the listing with the same scroll position and filters" + Accessible.description: "Return to the previous page, keeping the listing's scroll position and filters" onClicked: workshopPanel.back() } RowLayout { diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index a5c83e9..2c957fb 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -27,6 +27,7 @@ from pxmodrim.core.workshop.catalog import FRESH_SECONDS from pxmodrim.ui.components.dialogs import await_dialog from pxmodrim.ui.components.mod_activation import apply_activation +from pxmodrim.ui.navigation import build_route from pxmodrim.ui.panels.settings_panel import SettingsPanel from pxmodrim.ui.plugins.workshop.details import ( Detail, @@ -47,6 +48,7 @@ from pxmodrim.ui.context import AppContext _FAILURES = (CatalogError, RuntimeError, ValueError) +_TABS = ("Discover", "Mods", "Collections", "Installed") def _message(exc: Exception) -> str: @@ -88,6 +90,8 @@ def __init__( self._detail_data: Detail | None = None self._installed = InstalledList() self._installed_dirty = False + 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 @@ -206,6 +210,8 @@ def _refresh_rows(self) -> None: 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: @@ -430,6 +436,7 @@ async def refresh(self) -> None: @asyncSlot(str) async def selectTab(self, tab: str) -> None: self._tab, self._notice, self._pending = tab, "", None + self._clear_detail() self._installed.reset_paging() await self.load() @@ -465,10 +472,18 @@ async def retry(self) -> None: async def openItem(self, item_id: str, kind: str) -> None: await self.open_item(item_id, kind) - async def open_item(self, item_id: str, kind: str) -> None: + 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) @@ -483,10 +498,23 @@ def apply(detail: Detail) -> None: if kind == "collection": await self._load_pack() + def open_route(self, path: tuple[str, ...]) -> None: + if self._torn_down: + return + if len(path) == 1 and path[0].title() in _TABS: + self.selectTab(path[0].title()) + elif len(path) == 2 and path[0] in ("mod", "collection"): + self._clear_detail() + self.openItem(path[1], path[0]) + @asyncSlot() async def back(self) -> None: if self._torn_down: return + if self._trail: + kind, item_id, _ = self._trail.pop() + await self.open_item(item_id, kind, record=False) + return self._generation += 1 self._clear_detail() self._error, self._busy = "", False @@ -668,6 +696,18 @@ async def _on_installed_changed(self, _value: None) -> None: 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 diff --git a/src/pxmodrim/ui/window/main_window.py b/src/pxmodrim/ui/window/main_window.py index ede8c2f..f808470 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 build_route, parse_route from pxmodrim.ui.panels.about_panel import AboutPanel from pxmodrim.ui.panels.keyboard_shortcuts_dialog import ( QML_SHORTCUTS, @@ -212,9 +213,9 @@ def _setup_header_and_shortcuts(self) -> None: 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") + lambda: self._app_ctx.navigate(build_route("downloads")) ) - self._app_ctx.set_navigator(self._show_view) + 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) @@ -265,10 +266,14 @@ 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: + return for index, view in enumerate(self._views): - if view.view_id == view_id: + if view.view_id == route.view_id: self._select_view(index) + view.open_route(route.path) return def _select_view(self, index: int) -> None: diff --git a/tests/test_ui/test_navigation.py b/tests/test_ui/test_navigation.py new file mode 100644 index 0000000..e4d61fa --- /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 == "pxmodrim://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("pxmodrim://downloads") == Route("downloads") + assert parse_route("pxmodrim://downloads/") == Route("downloads") + + +@pytest.mark.parametrize("url", ["downloads", "https://downloads", "pxmodrim://", ""]) +def test_non_routes_are_rejected(url: str) -> None: + assert parse_route(url) is None diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index 2ec4bd2..81bd6cd 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -557,6 +557,45 @@ async def test_known_tag_options_accumulate_loaded_tags_but_not_version_tags( 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.selectTab("Mods") + await view.open_item("1", "mod") + await view.open_item("2", "mod") + assert view.url == "pxmodrim://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 == "pxmodrim://workshop/mod/1" + assert view.backLabel == "Back to Mods" + + await view.back() + assert not view.hasDetail + assert view.url == "pxmodrim://workshop/mods" + + +async def test_routes_open_tabs_and_items_and_reset_history( + panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], +) -> None: + view, _, _ = panel + await view.open_item("1", "mod") + await view.open_item("2", "mod") + + view.open_route(("collection", "picked:test")) + await asyncio.sleep(0.2) + assert view.url == "pxmodrim://workshop/collection/picked%3Atest" + await view.back() + assert not view.hasDetail + + view.open_route(("installed",)) + await asyncio.sleep(0.2) + assert view.tab == "Installed" + assert view.url == "pxmodrim://workshop/installed" + + async def test_back_preserves_scroll_position_and_active_filters( panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], ) -> None: From 0ddb75af84644660dbec9d741cccc343b6a00c84 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:09:37 +0300 Subject: [PATCH 42/69] fix(ui): awaitable route dispatch; register ui.navigation layer --- scripts/check-deps.py | 4 ++++ src/pxmodrim/ui/plugins/workshop/view.py | 11 +++++++++-- tests/test_ui/test_main_window.py | 23 +++++++++++++++++++++++ tests/test_ui/test_workshop_view.py | 6 ++---- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/scripts/check-deps.py b/scripts/check-deps.py index 6170e82..29c3024 100644 --- a/scripts/check-deps.py +++ b/scripts/check-deps.py @@ -48,6 +48,7 @@ "core.support": {"core.support"}, "ui.progress": {"ui.progress"}, "ui.context": {"ui.context"}, + "ui.navigation": {"ui.navigation"}, "ui.theme": {"ui.theme"}, "ui.components": {"ui.components"}, "ui.models": {"ui.models"}, @@ -131,6 +132,7 @@ "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": { @@ -142,6 +144,7 @@ "ui.views", "ui.panels", "ui.mod_selection", + "ui.navigation", }, "ui.panels": { "ui.theme", @@ -171,6 +174,7 @@ "ui.config", "ui.ui_prefs", "ui.mod_selection", + "ui.navigation", }, } diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index 2c957fb..522243b 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterator, Awaitable, Callable, Iterable from hashlib import sha256 from pathlib import Path @@ -90,6 +91,7 @@ def __init__( 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 @@ -499,13 +501,18 @@ def apply(detail: Detail) -> None: 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: return if len(path) == 1 and path[0].title() in _TABS: - self.selectTab(path[0].title()) + await self.selectTab(path[0].title()) elif len(path) == 2 and path[0] in ("mod", "collection"): self._clear_detail() - self.openItem(path[1], path[0]) + await self.open_item(path[1], path[0]) @asyncSlot() async def back(self) -> None: diff --git a/tests/test_ui/test_main_window.py b/tests/test_ui/test_main_window.py index bf3cd6e..b5394f5 100644 --- a/tests/test_ui/test_main_window.py +++ b/tests/test_ui/test_main_window.py @@ -51,6 +51,29 @@ 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, ...]] = [] + selected: 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")], _select_view=selected.append + ), + ) + MainWindow._open_route(window, "pxmodrim://workshop/mod/42") + MainWindow._open_route(window, "pxmodrim://missing/x") + MainWindow._open_route(window, "workshop") + assert selected == [1] + assert opened == [("workshop", "mod", "42")] + + def test_dirty_state_tracks_active_list_order_and_updates_header() -> None: class Header: unsaved_changes = False diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index 81bd6cd..33581d6 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -584,14 +584,12 @@ async def test_routes_open_tabs_and_items_and_reset_history( await view.open_item("1", "mod") await view.open_item("2", "mod") - view.open_route(("collection", "picked:test")) - await asyncio.sleep(0.2) + await view.follow_route(("collection", "picked:test")) assert view.url == "pxmodrim://workshop/collection/picked%3Atest" await view.back() assert not view.hasDetail - view.open_route(("installed",)) - await asyncio.sleep(0.2) + await view.follow_route(("installed",)) assert view.tab == "Installed" assert view.url == "pxmodrim://workshop/installed" From 5e9ca4dae62fb7dea21815e1f33805544acc6ba3 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:14:25 +0300 Subject: [PATCH 43/69] feat(workshop): compact cards, collection banners and installed rows --- CHANGELOG.md | 4 + .../ui/plugins/workshop/CatalogCard.qml | 184 +++++++----------- .../ui/plugins/workshop/CatalogThumb.qml | 45 +++++ .../ui/plugins/workshop/CollectionBanner.qml | 98 ++++++++++ .../ui/plugins/workshop/InstalledRow.qml | 90 +++++++++ src/pxmodrim/ui/plugins/workshop/Workshop.qml | 112 ++++++++--- src/pxmodrim/ui/plugins/workshop/models.py | 35 ++++ tests/test_ui/test_workshop_models.py | 34 ++++ 8 files changed, 453 insertions(+), 149 deletions(-) create mode 100644 src/pxmodrim/ui/plugins/workshop/CatalogThumb.qml create mode 100644 src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml create mode 100644 src/pxmodrim/ui/plugins/workshop/InstalledRow.qml diff --git a/CHANGELOG.md b/CHANGELOG.md index ff00dd7..73d7304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ 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] +### Changed +- 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 compact row list with Activate and Update actions. +- Workshop Back walks the detail history (a dependency's Back returns to the mod you came from) and is labelled with the previous page. Workshop pages and the rail views are addressable with `pxmodrim://` links, e.g. `pxmodrim://workshop/mod/` and `pxmodrim://downloads`. + ### Fixed - 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. diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml index 76426a3..d568139 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml +++ b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml @@ -11,9 +11,8 @@ Rectangle { required property string author required property string previewUrl required property var collage - required property string sourceLabel - required property string versions - required property string stateLabel + required property string summary + required property string compatLabel required property string state required property string actionLabel required property bool incompatible @@ -23,164 +22,113 @@ Rectangle { required property string fileSize required property bool active + readonly property bool isMod: kind === "mod" + readonly property string meta: [ + isMod ? author : memberCount + " mods · " + author, + isMod && votes !== "Unrated" ? votes + " liked" : "", + isMod && 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 } + 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: 12 - spacing: 8 - Rectangle { - id: thumb + anchors.margins: 10 + spacing: 6 + CatalogThumb { Layout.fillWidth: true - Layout.preferredHeight: 110 - color: Theme.elevate3 - radius: Theme.radiusMd - clip: true - CatalogImage { - id: preview - anchors.fill: parent - previewUrl: card.previewUrl - } - Grid { - id: collageGrid - anchors.fill: parent - visible: card.previewUrl === "" && card.collage.length > 0 - columns: card.collage.length === 1 ? 1 : 2 - readonly property int rowCount: card.collage.length > 2 ? 2 : 1 - Repeater { - model: collageGrid.visible ? card.collage : [] - CatalogImage { - required property string modelData - width: collageGrid.width / collageGrid.columns - height: collageGrid.height / collageGrid.rowCount - previewUrl: modelData - } - } - } - Image { - anchors.centerIn: parent - width: 32 - height: 32 - visible: preview.status !== Image.Ready && !collageGrid.visible - source: "image://icons/grid?color=" + encodeURIComponent(Theme.textDim) - } - TapHandler { onTapped: workshopPanel.openItem(card.itemId, card.kind) } + 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.author + text: card.meta textFormat: Text.PlainText - color: Theme.textMuted + color: Theme.textDim font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd + font.pixelSize: Theme.fontSizeSm elide: Text.ElideRight } - Flow { - Layout.fillWidth: true - spacing: 6 - PxBadge { - visible: card.kind === "collection" - compact: true - text: card.sourceLabel.toUpperCase() - textColor: card.sourceLabel.indexOf("pick") >= 0 ? Theme.warning : Theme.textMuted - fillColor: card.sourceLabel.indexOf("pick") >= 0 ? Theme.warningBg : Theme.elevate3 - } - PxBadge { - visible: card.kind === "collection" - compact: true - text: card.memberCount + " MODS" - textColor: Theme.textMuted - fillColor: Theme.elevate3 - } - PxBadge { - visible: card.kind === "mod" && card.state !== "missing" - compact: true - text: "INSTALLED" - textColor: Theme.success - fillColor: Theme.successBg - } - PxBadge { - visible: card.kind === "mod" && card.state === "outdated" - compact: true - text: "UPDATE AVAILABLE" - } - PxBadge { - visible: card.incompatible - compact: true - text: "INCOMPATIBLE" - textColor: Theme.warning - fillColor: Theme.warningBg - } - } Text { Layout.fillWidth: true - text: "Versions: " + card.versions + 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 - visible: card.kind === "mod" spacing: 6 - Image { - width: 16; height: 16 - sourceSize.width: 16; sourceSize.height: 16 - source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) - } Text { - text: card.votes - color: Theme.textMuted + 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.fontSizeMd - Accessible.name: text === "Unrated" ? text : text + " positive votes" + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight } - Item { Layout.fillWidth: true } - Text { - text: card.fileSize - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd + 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 } - Flow { + PxButton { Layout.fillWidth: true - spacing: 6 - PxButton { - text: "Details" - variant: "ghost" - onClicked: workshopPanel.openItem(card.itemId, card.kind) - } - PxButton { - visible: card.kind === "mod" && card.state !== "missing" - text: card.active ? "Deactivate" : "Activate" - variant: card.active ? "danger" : "success" - onClicked: workshopPanel.toggleActivation(card.itemId) - } - PxButton { - visible: card.state !== "installed" - text: card.actionLabel - variant: card.state === "outdated" ? "warning" : "primary" - enabled: !card.queued - onClicked: workshopPanel.downloadItem(card.itemId, card.kind) - } + 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/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..784c46e --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml @@ -0,0 +1,98 @@ +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 actionLabel + + 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.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 { + text: banner.actionLabel + variant: "primary" + enabled: !banner.queued + onClicked: workshopPanel.downloadItem(banner.itemId, banner.kind) + } + } + } + } +} diff --git a/src/pxmodrim/ui/plugins/workshop/InstalledRow.qml b/src/pxmodrim/ui/plugins/workshop/InstalledRow.qml new file mode 100644 index 0000000..e004c4b --- /dev/null +++ b/src/pxmodrim/ui/plugins/workshop/InstalledRow.qml @@ -0,0 +1,90 @@ +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 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: 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: 8 + anchors.rightMargin: 12 + anchors.topMargin: 6 + anchors.bottomMargin: 6 + spacing: 12 + 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.author + (row.fileSize !== "Unknown" ? " · " + row.fileSize : "") + textFormat: Text.PlainText + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSm + elide: Text.ElideRight + } + } + PxBadge { + visible: row.incompatible + compact: true + text: "INCOMPATIBLE" + textColor: Theme.warning + fillColor: Theme.warningBg + } + PxBadge { + visible: row.active + compact: true + text: "ACTIVE" + textColor: Theme.success + fillColor: Theme.successBg + } + PxButton { + visible: row.state === "outdated" + text: "Update" + variant: "warning" + enabled: !row.queued + onClicked: workshopPanel.downloadItem(row.itemId, row.kind) + } + PxButton { + text: row.active ? "Deactivate" : "Activate" + variant: row.active ? "danger" : "success" + onClicked: workshopPanel.toggleActivation(row.itemId) + } + } +} diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index 19dc95a..a6b8ef6 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -28,11 +28,11 @@ Rectangle { font.bold: true } component CatalogGrid: GridView { - property int columns: Math.max(1, Math.floor(width / 250)) + property int columns: Math.max(1, Math.floor(width / 230)) Layout.fillWidth: true Layout.preferredHeight: Math.ceil(count / columns) * cellHeight cellWidth: width / columns - cellHeight: 382 + cellHeight: 312 interactive: false clip: true delegate: CatalogCard { @@ -40,6 +40,40 @@ Rectangle { 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." : "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 @@ -227,7 +261,7 @@ Rectangle { Heading { text: "Collections" } PxButton { text: "Browse all"; variant: "ghost"; onClicked: workshopPanel.selectTab("Collections") } } - CatalogGrid { model: visible ? catalogCollections : null } + CollectionGrid { model: visible ? catalogCollections : null } RowLayout { Heading { text: "Popular mods" } PxButton { text: "Browse all"; variant: "ghost"; onClicked: workshopPanel.selectTab("Mods") } @@ -239,45 +273,61 @@ Rectangle { GridView { id: listingGrid objectName: "listingScroll" - readonly property bool collections: workshopPanel.tab === "Collections" - readonly property var listing: collections ? catalogCollections : catalogMods - property int columns: Math.max(1, Math.floor(width / 250)) - readonly property bool listingTab: workshopPanel.configured && workshopPanel.tab !== "Discover" + 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: 382 - model: listingTab ? listing : null + cellHeight: 312 + model: listingTab ? catalogMods : null ScrollBar.vertical: PxScrollBar {} - header: Item { - width: listingGrid.width - height: pageHeading.implicitHeight + 16 - Heading { id: pageHeading; width: parent.width; text: workshopPanel.tab } - } - footer: ColumnLayout { - width: listingGrid.width - height: implicitHeight - spacing: 12 - Copy { - visible: !workshopPanel.busy && !workshopPanel.error && listingGrid.listing.count === 0 - text: workshopPanel.tab === "Installed" ? "No installed Workshop mods were found." : "No matching items. Try another search or filter." - } - PxButton { - Layout.alignment: Qt.AlignHCenter - text: "Load more" - visible: listingGrid.listing.hasMore - enabled: !workshopPanel.busy - onClicked: workshopPanel.loadMore() - } - Item { implicitHeight: 12 } - } + 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" + 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 + } + } + ListView { + id: installedList + objectName: "installedScroll" + readonly property bool active: workshopPanel.configured && workshopPanel.tab === "Installed" + visible: !workshopPanel.hasDetail && active + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: 6 + model: active ? catalogMods : null + ScrollBar.vertical: PxScrollBar {} + header: ListingHeader { width: installedList.width } + footer: ListingFooter { width: installedList.width; listing: catalogMods } + delegate: InstalledRow { + width: ListView.view.width - 12 + height: 56 + } + } ScrollView { id: detailScroll objectName: "detailScroll" diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/src/pxmodrim/ui/plugins/workshop/models.py index 13a712f..ca4ddcc 100644 --- a/src/pxmodrim/ui/plugins/workshop/models.py +++ b/src/pxmodrim/ui/plugins/workshop/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from collections.abc import Callable, Sequence from typing import Any from urllib.parse import urlsplit @@ -29,6 +30,34 @@ def safe_url(value: str) -> str: 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: list[str], game_version: str, incompatible: bool) -> str: + if not game_version: + return "" + 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], @@ -111,6 +140,10 @@ def item_row( "votes": votes, "votesUp": votes_up, "votesDown": votes_down, + "summary": summarize(item.description, item.description_format), + "compatLabel": _compat_label( + item.supported_versions, game_version, incompatible + ), } @@ -139,6 +172,8 @@ class CatalogListModel(QAbstractListModel): "votesUp", "votesDown", "queued", + "summary", + "compatLabel", ) def __init__(self, parent: QObject) -> None: diff --git a/tests/test_ui/test_workshop_models.py b/tests/test_ui/test_workshop_models.py index 4502e4b..04c2c0d 100644 --- a/tests/test_ui/test_workshop_models.py +++ b/tests/test_ui/test_workshop_models.py @@ -10,6 +10,7 @@ CatalogListModel, item_row, safe_url, + summarize, ) @@ -159,3 +160,36 @@ def test_collection_image_prefers_own_then_first_image_then_member_collage() -> 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 From b7892cec87f4bb26438d99459dc40390c76d3129 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:17:19 +0300 Subject: [PATCH 44/69] fix(workshop): bound index isolation, keep history on failed back, use modrim:// scheme --- CHANGELOG.md | 2 +- cf-workers/indexer.ts | 27 ++++++++++++++----- cf-workers/tests/catalog.test.ts | 2 +- src/pxmodrim/ui/context.py | 4 +-- src/pxmodrim/ui/navigation.py | 4 +-- src/pxmodrim/ui/plugins/workshop/view.py | 5 +++- tests/test_ui/test_main_window.py | 4 +-- tests/test_ui/test_navigation.py | 8 +++--- tests/test_ui/test_workshop_view.py | 33 ++++++++++++++++++++---- 9 files changed, 64 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d7304..f914000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Changed - 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 compact row list with Activate and Update actions. -- Workshop Back walks the detail history (a dependency's Back returns to the mod you came from) and is labelled with the previous page. Workshop pages and the rail views are addressable with `pxmodrim://` links, e.g. `pxmodrim://workshop/mod/` and `pxmodrim://downloads`. +- Workshop Back walks the detail history (a dependency's Back returns to the mod you came from) and is labelled with the previous page. Workshop pages and the rail views are addressable with `modrim://` links, e.g. `modrim://workshop/mod/` and `modrim://downloads`. ### Fixed - 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. diff --git a/cf-workers/indexer.ts b/cf-workers/indexer.ts index 77db302..fa7f0e2 100644 --- a/cf-workers/indexer.ts +++ b/cf-workers/indexer.ts @@ -1,5 +1,5 @@ import { queryFiles, refreshSteamItems } from './steam'; -import type { Env } from './types'; +import { HttpError, type Env } from './types'; const PAGES_PER_TICK = 4; const PAGE_SIZE = 100; @@ -28,20 +28,33 @@ async function saveState(db: D1Database, state: IndexState): Promise { ).bind(state.cursor, state.cycle_started_at, state.since, state.last_started_at, state.full_at, state.indexed, Date.now()).run(); } -/** Refreshes `ids`, bisecting on failure so one bad file cannot block its neighbours. */ -async function refreshIsolating(env: Env, ids: string[]): Promise { +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 instanceof Error ? error.message : error); + 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)); - const right = await refreshIsolating(env, ids.slice(middle)); + const left = await refreshIsolating(env, ids.slice(0, middle), budget); + const right = await refreshIsolating(env, ids.slice(middle), budget); return left && right; } @@ -63,7 +76,7 @@ async function verifyUnseenMods(env: Env, state: IndexState): Promise { 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); + 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]}`; diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index d39f3ed..39fa024 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -594,7 +594,7 @@ test('a persistently failing omitted file does not starve later ones or complete 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', 'http'); + 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); diff --git a/src/pxmodrim/ui/context.py b/src/pxmodrim/ui/context.py index 647ac13..b72beca 100644 --- a/src/pxmodrim/ui/context.py +++ b/src/pxmodrim/ui/context.py @@ -29,7 +29,7 @@ class RailView(QWidget): label: ClassVar[str] def open_route(self, path: tuple[str, ...]) -> None: - """Handle the part of a ``pxmodrim:///…`` link after the view id.""" + """Handle the part of a ``modrim:///…`` link after the view id.""" def __init__( self, @@ -95,7 +95,7 @@ def toasts(self, notifier: Notifier | None) -> None: # ── Navigation ──────────────────────────────────── def navigate(self, url: str) -> None: - """Open a ``pxmodrim://[/…]`` link (no-op if the view is unknown).""" + """Open a ``modrim://[/…]`` link (no-op if the view is unknown).""" if self._navigate is not None: self._navigate(url) diff --git a/src/pxmodrim/ui/navigation.py b/src/pxmodrim/ui/navigation.py index fd3d7b0..488cb24 100644 --- a/src/pxmodrim/ui/navigation.py +++ b/src/pxmodrim/ui/navigation.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from urllib.parse import quote, unquote, urlsplit -SCHEME = "pxmodrim" +SCHEME = "modrim" @dataclass(frozen=True, slots=True) @@ -22,7 +22,7 @@ def build_route(view_id: str, *path: str) -> str: def parse_route(url: str) -> Route | None: - """Parse ``pxmodrim://[/…]``; anything else is not a route.""" + """Parse ``modrim://[/…]``; anything else is not a route.""" try: parts = urlsplit(url) except ValueError: diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index 522243b..56219d2 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -519,8 +519,11 @@ async def back(self) -> None: if self._torn_down: return if self._trail: - kind, item_id, _ = self._trail.pop() + 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() diff --git a/tests/test_ui/test_main_window.py b/tests/test_ui/test_main_window.py index b5394f5..3f40828 100644 --- a/tests/test_ui/test_main_window.py +++ b/tests/test_ui/test_main_window.py @@ -67,8 +67,8 @@ def view(view_id: str) -> SimpleNamespace: _views=[view("mods"), view("workshop")], _select_view=selected.append ), ) - MainWindow._open_route(window, "pxmodrim://workshop/mod/42") - MainWindow._open_route(window, "pxmodrim://missing/x") + MainWindow._open_route(window, "modrim://workshop/mod/42") + MainWindow._open_route(window, "modrim://missing/x") MainWindow._open_route(window, "workshop") assert selected == [1] assert opened == [("workshop", "mod", "42")] diff --git a/tests/test_ui/test_navigation.py b/tests/test_ui/test_navigation.py index e4d61fa..be675b4 100644 --- a/tests/test_ui/test_navigation.py +++ b/tests/test_ui/test_navigation.py @@ -7,15 +7,15 @@ def test_route_round_trips_ids_with_reserved_characters() -> None: url = build_route("workshop", "collection", "picked:a/b") - assert url == "pxmodrim://workshop/collection/picked%3Aa%2Fb" + 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("pxmodrim://downloads") == Route("downloads") - assert parse_route("pxmodrim://downloads/") == Route("downloads") + assert parse_route("modrim://downloads") == Route("downloads") + assert parse_route("modrim://downloads/") == Route("downloads") -@pytest.mark.parametrize("url", ["downloads", "https://downloads", "pxmodrim://", ""]) +@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_workshop_view.py b/tests/test_ui/test_workshop_view.py index 33581d6..382062c 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -564,17 +564,40 @@ async def test_back_walks_detail_history_before_returning_to_listing( await view.selectTab("Mods") await view.open_item("1", "mod") await view.open_item("2", "mod") - assert view.url == "pxmodrim://workshop/mod/2" + 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 == "pxmodrim://workshop/mod/1" + assert view.url == "modrim://workshop/mod/1" assert view.backLabel == "Back to Mods" await view.back() assert not view.hasDetail - assert view.url == "pxmodrim://workshop/mods" + 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_and_reset_history( @@ -585,13 +608,13 @@ async def test_routes_open_tabs_and_items_and_reset_history( await view.open_item("2", "mod") await view.follow_route(("collection", "picked:test")) - assert view.url == "pxmodrim://workshop/collection/picked%3Atest" + assert view.url == "modrim://workshop/collection/picked%3Atest" await view.back() assert not view.hasDetail await view.follow_route(("installed",)) assert view.tab == "Installed" - assert view.url == "pxmodrim://workshop/installed" + assert view.url == "modrim://workshop/installed" async def test_back_preserves_scroll_position_and_active_filters( From b44e973045c1bcceabcc10df57a35f0bd605abaa Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:19:01 +0300 Subject: [PATCH 45/69] feat(workshop): installed tiles with leading active checkbox --- CHANGELOG.md | 2 +- .../ui/plugins/workshop/InstalledRow.qml | 41 ++++++++----------- src/pxmodrim/ui/plugins/workshop/Workshop.qml | 16 ++++---- 3 files changed, 26 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f914000..41ce661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Changed -- 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 compact row list with Activate and Update actions. +- 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. Workshop pages and the rail views are addressable with `modrim://` links, e.g. `modrim://workshop/mod/` and `modrim://downloads`. ### Fixed diff --git a/src/pxmodrim/ui/plugins/workshop/InstalledRow.qml b/src/pxmodrim/ui/plugins/workshop/InstalledRow.qml index e004c4b..8211678 100644 --- a/src/pxmodrim/ui/plugins/workshop/InstalledRow.qml +++ b/src/pxmodrim/ui/plugins/workshop/InstalledRow.qml @@ -18,7 +18,7 @@ Rectangle { color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 radius: Theme.radiusMd - border.color: Theme.border + 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 @@ -27,11 +27,21 @@ Rectangle { RowLayout { anchors.fill: parent - anchors.leftMargin: 8 - anchors.rightMargin: 12 + anchors.leftMargin: 12 + anchors.rightMargin: 10 anchors.topMargin: 6 anchors.bottomMargin: 6 - spacing: 12 + spacing: 10 + PxCheckBox { + id: activeBox + 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 @@ -52,28 +62,14 @@ Rectangle { } Text { Layout.fillWidth: true - text: row.author + (row.fileSize !== "Unknown" ? " · " + row.fileSize : "") + text: row.incompatible ? "Not for this game version" : row.author + (row.fileSize !== "Unknown" ? " · " + row.fileSize : "") textFormat: Text.PlainText - color: Theme.textDim + color: row.incompatible ? Theme.warning : Theme.textDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSm elide: Text.ElideRight } } - PxBadge { - visible: row.incompatible - compact: true - text: "INCOMPATIBLE" - textColor: Theme.warning - fillColor: Theme.warningBg - } - PxBadge { - visible: row.active - compact: true - text: "ACTIVE" - textColor: Theme.success - fillColor: Theme.successBg - } PxButton { visible: row.state === "outdated" text: "Update" @@ -81,10 +77,5 @@ Rectangle { enabled: !row.queued onClicked: workshopPanel.downloadItem(row.itemId, row.kind) } - PxButton { - text: row.active ? "Deactivate" : "Activate" - variant: row.active ? "danger" : "success" - onClicked: workshopPanel.toggleActivation(row.itemId) - } } } diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index a6b8ef6..1fd3102 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -310,22 +310,24 @@ Rectangle { height: GridView.view.cellHeight - 12 } } - ListView { - id: installedList + 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 - spacing: 6 + cellWidth: width / columns + cellHeight: 62 model: active ? catalogMods : null ScrollBar.vertical: PxScrollBar {} - header: ListingHeader { width: installedList.width } - footer: ListingFooter { width: installedList.width; listing: catalogMods } + header: ListingHeader { width: installedGrid.width } + footer: ListingFooter { width: installedGrid.width; listing: catalogMods } delegate: InstalledRow { - width: ListView.view.width - 12 - height: 56 + width: GridView.view.cellWidth - 8 + height: GridView.view.cellHeight - 8 } } ScrollView { From 21de37fb0c87964061838479381934f8d3e574b1 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:22:18 +0300 Subject: [PATCH 46/69] test(workshop): click the installed active checkbox end to end --- .../ui/plugins/workshop/InstalledRow.qml | 1 + tests/test_ui/test_workshop_view.py | 42 ++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/pxmodrim/ui/plugins/workshop/InstalledRow.qml b/src/pxmodrim/ui/plugins/workshop/InstalledRow.qml index 8211678..0189702 100644 --- a/src/pxmodrim/ui/plugins/workshop/InstalledRow.qml +++ b/src/pxmodrim/ui/plugins/workshop/InstalledRow.qml @@ -34,6 +34,7 @@ Rectangle { spacing: 10 PxCheckBox { id: activeBox + objectName: "activeToggle" checked: row.active Accessible.name: "Active" Accessible.description: "Enable or disable " + row.title diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index 382062c..d162c7d 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -7,9 +7,11 @@ import msgspec import pytest -from PySide6.QtCore import QObject, QPoint, QUrl +from PySide6.QtCore import QObject, QPoint, Qt, QUrl from PySide6.QtGui import QAccessible +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 @@ -502,6 +504,44 @@ async def test_download_never_activates_installed_copies( 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.selectTab("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_activation_does_nothing_without_an_installed_copy( panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], ) -> None: From ae468f4948054bd5eed4206bfdf0d65705c79fc9 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:29:54 +0300 Subject: [PATCH 47/69] feat(workshop): denormalised collection download size from the catalog server --- CHANGELOG.md | 1 + cf-workers/README.md | 1 + cf-workers/catalog.ts | 40 +++++++++++++++++-- .../migrations/0006_collection_sizes.sql | 24 +++++++++++ cf-workers/picks.ts | 1 + cf-workers/steam.ts | 1 + cf-workers/tests/catalog.test.ts | 30 +++++++++++++- cf-workers/types.ts | 2 + src/pxmodrim/core/workshop/types.py | 1 + .../ui/plugins/workshop/CatalogCard.qml | 5 ++- .../ui/plugins/workshop/CollectionBanner.qml | 3 +- src/pxmodrim/ui/plugins/workshop/Workshop.qml | 6 +++ src/pxmodrim/ui/plugins/workshop/models.py | 2 + tests/test_ui/test_workshop_models.py | 29 ++++++++++++++ tests/test_workshop/conftest.py | 1 + 15 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 cf-workers/migrations/0006_collection_sizes.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ce661..7fc4c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Changed +- Workshop collections show their total download size in MB (cards, banners and details) when the server knows it; the catalog stores it per collection once the members have been read, and hides it 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. Workshop pages and the rail views are addressable with `modrim://` links, e.g. `modrim://workshop/mod/` and `modrim://downloads`. diff --git a/cf-workers/README.md b/cf-workers/README.md index 9715d1c..a1badb6 100644 --- a/cf-workers/README.md +++ b/cf-workers/README.md @@ -170,6 +170,7 @@ JSON requests require `application/json` and are bounded to 64 KiB, including st - `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_sizes.sql`: `collection_sizes`, the denormalised total download size (bytes) of each collection, written when its details are read and only when every member is known and sized. Listings join it without reading members; triggers drop a row when the collection's members change or the collection is deleted. Collections without a row report `total_size: null`. The catalog cache is separate from `items`: the legacy resolver intentionally strips nested collection children in some paths; catalog resolution retains them. diff --git a/cf-workers/catalog.ts b/cf-workers/catalog.ts index bd5f695..a7000d0 100644 --- a/cf-workers/catalog.ts +++ b/cf-workers/catalog.ts @@ -145,12 +145,43 @@ async function collectionPage(env: Env, ctx: ExecutionContext, url: URL, query: key, steam: steam.next_cursor, picked: cursor.picked === null ? null : picked.next_offset, steam_total: steam.total, }; return { - items: await attachMemberPreviews(env, [...pickedItems, ...steam.items]), + items: await attachCollectionSizes(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), }; } +async function attachCollectionSizes(db: D1Database, collections: CatalogCollection[]): Promise { + const sizes = new Map(); + 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 FROM collection_sizes WHERE id IN (${chunk.map(() => '?').join(',')})`) + .bind(...chunk.map(collection => collection.id)).all<{ id: string; total_size: number }>(); + for (const row of rows.results) sizes.set(row.id, row.total_size); + } + return collections.map(collection => { + const size = sizes.get(collection.id); + return size === undefined ? collection : { ...collection, total_size: String(size) }; + }); +} + +// The total is stored only when every member is known and sized; otherwise it stays unknown. +async function recordCollectionSize(db: D1Database, collection: CatalogCollection, members: CatalogItem[], complete: boolean): Promise { + let total = 0; + let known = complete && members.length === collection.member_count; + for (const member of members) { + if (member.kind !== 'mod' || member.file_size === null) known = false; + else total += Number(member.file_size); + } + known &&= Number.isSafeInteger(total); + if (known) { + await db.prepare('INSERT INTO collection_sizes (id, total_size) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET total_size = excluded.total_size') + .bind(collection.id, total).run(); + } else { + await db.prepare('DELETE FROM collection_sizes WHERE id = ?').bind(collection.id).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[] = []; @@ -158,8 +189,11 @@ async function collectionDetails(env: Env, ctx: ExecutionContext, collection: Ca const item = items.get(id); if (item) members.push(item); } - const [withPreviews] = await attachMemberPreviews(env, [collection], items); - return json({ collection: withPreviews!, members, unavailable_ids, is_complete: unavailable_ids.length === 0 && collection.member_count === collection.member_ids.length }); + const isComplete = unavailable_ids.length === 0 && collection.member_count === collection.member_ids.length; + await recordCollectionSize(env.DB, collection, members, isComplete); + const [sized] = await attachCollectionSizes(env.DB, [collection]); + const [withPreviews] = await attachMemberPreviews(env, [sized!], items); + return json({ collection: withPreviews!, members, unavailable_ids, is_complete: isComplete }); } async function resolve(env: Env, body: Record): Promise { diff --git a/cf-workers/migrations/0006_collection_sizes.sql b/cf-workers/migrations/0006_collection_sizes.sql new file mode 100644 index 0000000..c580e6b --- /dev/null +++ b/cf-workers/migrations/0006_collection_sizes.sql @@ -0,0 +1,24 @@ +-- Denormalised total download size (bytes) of collections whose members are all known. +CREATE TABLE IF NOT EXISTS collection_sizes ( + id TEXT PRIMARY KEY, + total_size INTEGER NOT NULL +); + +CREATE TRIGGER IF NOT EXISTS collection_sizes_pick_au AFTER UPDATE OF member_ids ON picked_collections +WHEN old.member_ids IS NOT new.member_ids BEGIN + DELETE FROM collection_sizes WHERE id = 'picked:' || old.slug; +END; + +CREATE TRIGGER IF NOT EXISTS collection_sizes_pick_ad AFTER DELETE ON picked_collections BEGIN + DELETE FROM collection_sizes WHERE id = 'picked:' || old.slug; +END; + +CREATE TRIGGER IF NOT EXISTS collection_sizes_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_sizes WHERE id = 'steam:' || old.id; +END; + +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; diff --git a/cf-workers/picks.ts b/cf-workers/picks.ts index ae8b205..bb6a9d2 100644 --- a/cf-workers/picks.ts +++ b/cf-workers/picks.ts @@ -146,6 +146,7 @@ function toCollection(row: PickRow): CatalogCollection { member_ids: memberIds, member_count: memberIds.length, member_previews: [], + total_size: null, }; } diff --git a/cf-workers/steam.ts b/cf-workers/steam.ts index b5f63c2..4be8583 100644 --- a/cf-workers/steam.ts +++ b/cf-workers/steam.ts @@ -184,6 +184,7 @@ function normalized(file: SteamFile, authors: Map): CatalogItem member_ids: childIds, member_count: file.num_children ?? childIds.length, member_previews: [], + total_size: null, }; } const votes = file.vote_data; diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index 39fa024..1c2d951 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -91,7 +91,7 @@ function statements(sql: string): string[] { } 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']) { + for (const file of ['0001_dependencies.sql', '0002_catalog.sql', '0003_search.sql', '0004_query_cache.sql', '0005_catalog_eviction.sql', '0006_collection_sizes.sql']) { const sql = await readFile(resolvePath('migrations', file), 'utf8'); await database.batch(statements(sql).map(statement => database.prepare(statement))); } @@ -636,3 +636,31 @@ test('index verification checks at most 100 omitted mods per tick and persists i .bind('9000', '9100').first<{ n: number }>(); assert.equal(count?.n, 0); }); + +test('collection sizes 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.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'); + } +}); diff --git a/cf-workers/types.ts b/cf-workers/types.ts index 53f0fac..828ba5c 100644 --- a/cf-workers/types.ts +++ b/cf-workers/types.ts @@ -97,6 +97,8 @@ export interface CatalogCollection { member_ids: string[]; member_count: number; member_previews: string[]; + /** Total download size in bytes; null until every member is known. */ + total_size: string | null; } export type CatalogItem = CatalogMod | CatalogCollection; diff --git a/src/pxmodrim/core/workshop/types.py b/src/pxmodrim/core/workshop/types.py index 10d5e4e..873c111 100644 --- a/src/pxmodrim/core/workshop/types.py +++ b/src/pxmodrim/core/workshop/types.py @@ -68,6 +68,7 @@ class CatalogCollection( member_ids: list[str] member_count: int member_previews: list[str] = msgspec.field(default_factory=list) + total_size: str | None = None @property def kind(self) -> Literal["collection"]: diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml index d568139..825aea7 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml +++ b/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml @@ -24,9 +24,10 @@ Rectangle { readonly property bool isMod: kind === "mod" readonly property string meta: [ - isMod ? author : memberCount + " mods · " + author, + isMod ? author : memberCount + " mods", + isMod ? "" : author, isMod && votes !== "Unrated" ? votes + " liked" : "", - isMod && fileSize !== "Unknown" ? fileSize : "" + fileSize !== "Unknown" ? fileSize : "" ].filter(part => part.length > 0).join(" · ") color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 diff --git a/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml b/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml index 784c46e..f403a9c 100644 --- a/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml +++ b/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml @@ -17,6 +17,7 @@ Rectangle { required property bool incompatible required property bool queued required property int memberCount + required property string fileSize required property string actionLabel color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 @@ -57,7 +58,7 @@ Rectangle { } Text { Layout.fillWidth: true - text: banner.memberCount + " mods · " + banner.sourceLabel + " · " + banner.author + text: banner.memberCount + " mods" + (banner.fileSize !== "Unknown" ? " · " + banner.fileSize : "") + " · " + banner.sourceLabel + " · " + banner.author textFormat: Text.PlainText color: Theme.textDim font.family: Theme.fontFamily diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index 1fd3102..c55228f 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -453,6 +453,12 @@ Rectangle { 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 + " to download" } + } RowLayout { visible: !!root.detail.votesUp spacing: 8 diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/src/pxmodrim/ui/plugins/workshop/models.py index ca4ddcc..8217522 100644 --- a/src/pxmodrim/ui/plugins/workshop/models.py +++ b/src/pxmodrim/ui/plugins/workshop/models.py @@ -95,6 +95,8 @@ def item_row( ) 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" versions = set(item.supported_versions) return { "itemId": item.id, diff --git a/tests/test_ui/test_workshop_models.py b/tests/test_ui/test_workshop_models.py index 04c2c0d..2dbfc0f 100644 --- a/tests/test_ui/test_workshop_models.py +++ b/tests/test_ui/test_workshop_models.py @@ -193,3 +193,32 @@ def test_compat_label( _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" diff --git a/tests/test_workshop/conftest.py b/tests/test_workshop/conftest.py index cb824c1..d58fb9f 100644 --- a/tests/test_workshop/conftest.py +++ b/tests/test_workshop/conftest.py @@ -59,4 +59,5 @@ def collection_payload() -> dict[str, Any]: "member_ids": ["2009463077"], "member_count": 1, "member_previews": [], + "total_size": None, } From c4d341e9b3b73f7fa21f95afbc2c7118260500f5 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:33:24 +0300 Subject: [PATCH 48/69] feat(workshop): collection summaries with common versions, 64-bit sizes and expiry; fit banner meta --- CHANGELOG.md | 2 +- cf-workers/README.md | 2 +- cf-workers/catalog.ts | 62 ++++++++++++------- .../migrations/0006_collection_sizes.sql | 24 ------- .../migrations/0006_collection_summaries.sql | 27 ++++++++ cf-workers/tests/catalog.test.ts | 21 ++++++- .../ui/plugins/workshop/CollectionBanner.qml | 11 +++- src/pxmodrim/ui/plugins/workshop/Workshop.qml | 2 +- 8 files changed, 97 insertions(+), 54 deletions(-) delete mode 100644 cf-workers/migrations/0006_collection_sizes.sql create mode 100644 cf-workers/migrations/0006_collection_summaries.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc4c6b..9eabed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Changed -- Workshop collections show their total download size in MB (cards, banners and details) when the server knows it; the catalog stores it per collection once the members have been read, and hides it otherwise. +- 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. Workshop pages and the rail views are addressable with `modrim://` links, e.g. `modrim://workshop/mod/` and `modrim://downloads`. diff --git a/cf-workers/README.md b/cf-workers/README.md index a1badb6..4bbe3f4 100644 --- a/cf-workers/README.md +++ b/cf-workers/README.md @@ -170,7 +170,7 @@ JSON requests require `application/json` and are bounded to 64 KiB, including st - `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_sizes.sql`: `collection_sizes`, the denormalised total download size (bytes) of each collection, written when its details are read and only when every member is known and sized. Listings join it without reading members; triggers drop a row when the collection's members change or the collection is deleted. Collections without a row report `total_size: null`. +- `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 versions every member supports). 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. The catalog cache is separate from `items`: the legacy resolver intentionally strips nested collection children in some paths; catalog resolution retains them. diff --git a/cf-workers/catalog.ts b/cf-workers/catalog.ts index a7000d0..7cf191f 100644 --- a/cf-workers/catalog.ts +++ b/cf-workers/catalog.ts @@ -145,41 +145,55 @@ async function collectionPage(env: Env, ctx: ExecutionContext, url: URL, query: key, steam: steam.next_cursor, picked: cursor.picked === null ? null : picked.next_offset, steam_total: steam.total, }; return { - items: await attachCollectionSizes(env.DB, await attachMemberPreviews(env, [...pickedItems, ...steam.items])), + 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), }; } -async function attachCollectionSizes(db: D1Database, collections: CatalogCollection[]): Promise { - const sizes = new Map(); +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 } + +// 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 FROM collection_sizes WHERE id IN (${chunk.map(() => '?').join(',')})`) - .bind(...chunk.map(collection => collection.id)).all<{ id: string; total_size: number }>(); - for (const row of rows.results) sizes.set(row.id, row.total_size); + const rows = await db.prepare( + `SELECT id, total_size, supported_versions 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 size = sizes.get(collection.id); - return size === undefined ? collection : { ...collection, total_size: String(size) }; + const summary = summaries.get(collection.id); + if (!summary) return collection; + return { ...collection, total_size: summary.total_size, supported_versions: JSON.parse(summary.supported_versions) as string[] }; }); } -// The total is stored only when every member is known and sized; otherwise it stays unknown. -async function recordCollectionSize(db: D1Database, collection: CatalogCollection, members: CatalogItem[], complete: boolean): Promise { - let total = 0; - let known = complete && members.length === collection.member_count; - for (const member of members) { - if (member.kind !== 'mod' || member.file_size === null) known = false; - else total += Number(member.file_size); +// Stored only once every member is a known mod. The size stays null if any member size is unknown; +// the versions are those every member supports. +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; } - known &&= Number.isSafeInteger(total); - if (known) { - await db.prepare('INSERT INTO collection_sizes (id, total_size) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET total_size = excluded.total_size') - .bind(collection.id, total).run(); - } else { - await db.prepare('DELETE FROM collection_sizes WHERE id = ?').bind(collection.id).run(); + 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 versions = mods.length + ? mods.map(mod => mod.supported_versions).reduce((common, next) => common.filter(version => next.includes(version))) + : []; + await db.prepare( + `INSERT INTO collection_summaries (id, total_size, supported_versions, computed_at) VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET total_size = excluded.total_size, supported_versions = excluded.supported_versions, computed_at = excluded.computed_at`, + ).bind(collection.id, total === null ? null : total.toString(), JSON.stringify(versions), Date.now()).run(); } async function collectionDetails(env: Env, ctx: ExecutionContext, collection: CatalogCollection): Promise { @@ -190,9 +204,9 @@ async function collectionDetails(env: Env, ctx: ExecutionContext, collection: Ca if (item) members.push(item); } const isComplete = unavailable_ids.length === 0 && collection.member_count === collection.member_ids.length; - await recordCollectionSize(env.DB, collection, members, isComplete); - const [sized] = await attachCollectionSizes(env.DB, [collection]); - const [withPreviews] = await attachMemberPreviews(env, [sized!], items); + 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 }); } diff --git a/cf-workers/migrations/0006_collection_sizes.sql b/cf-workers/migrations/0006_collection_sizes.sql deleted file mode 100644 index c580e6b..0000000 --- a/cf-workers/migrations/0006_collection_sizes.sql +++ /dev/null @@ -1,24 +0,0 @@ --- Denormalised total download size (bytes) of collections whose members are all known. -CREATE TABLE IF NOT EXISTS collection_sizes ( - id TEXT PRIMARY KEY, - total_size INTEGER NOT NULL -); - -CREATE TRIGGER IF NOT EXISTS collection_sizes_pick_au AFTER UPDATE OF member_ids ON picked_collections -WHEN old.member_ids IS NOT new.member_ids BEGIN - DELETE FROM collection_sizes WHERE id = 'picked:' || old.slug; -END; - -CREATE TRIGGER IF NOT EXISTS collection_sizes_pick_ad AFTER DELETE ON picked_collections BEGIN - DELETE FROM collection_sizes WHERE id = 'picked:' || old.slug; -END; - -CREATE TRIGGER IF NOT EXISTS collection_sizes_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_sizes WHERE id = 'steam:' || old.id; -END; - -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; diff --git a/cf-workers/migrations/0006_collection_summaries.sql b/cf-workers/migrations/0006_collection_summaries.sql new file mode 100644 index 0000000..bba03ea --- /dev/null +++ b/cf-workers/migrations/0006_collection_summaries.sql @@ -0,0 +1,27 @@ +-- 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/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index 1c2d951..42acd67 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -91,7 +91,7 @@ function statements(sql: string): string[] { } 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_sizes.sql']) { + 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']) { const sql = await readFile(resolvePath('migrations', file), 'utf8'); await database.batch(statements(sql).map(statement => database.prepare(statement))); } @@ -637,7 +637,7 @@ test('index verification checks at most 100 omitted mods per tick and persists i assert.equal(count?.n, 0); }); -test('collection sizes are stored once members are known, and cleared when membership changes', async () => { +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'); @@ -646,6 +646,12 @@ test('collection sizes are stored once members are known, and cleared when membe 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((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']); @@ -664,3 +670,14 @@ test('collections whose total exceeds a safe integer have no stored size', async await request('/catalog/collections/picked/huge', 'DELETE', undefined, 'test-admin'); } }); + +test('a collection supports only the game versions every member supports', async () => { + await createPick('versions', ['10', '14']); + try { + const detail = await (await request('/catalog/collections/picked/versions')).json() as { collection: CatalogCollection }; + assert.deepEqual(detail.collection.supported_versions, []); + assert.equal(detail.collection.total_size, '8192'); + } finally { + await request('/catalog/collections/picked/versions', 'DELETE', undefined, 'test-admin'); + } +}); diff --git a/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml b/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml index f403a9c..c9eb1c5 100644 --- a/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml +++ b/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml @@ -58,7 +58,16 @@ Rectangle { } Text { Layout.fillWidth: true - text: banner.memberCount + " mods" + (banner.fileSize !== "Unknown" ? " · " + banner.fileSize : "") + " · " + banner.sourceLabel + " · " + banner.author + 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 diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index c55228f..fc7f86b 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -457,7 +457,7 @@ Rectangle { 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 + " to download" } + Copy { Layout.fillWidth: false; visible: root.detail.fileSize !== "Unknown"; text: "· " + root.detail.fileSize + " total" } } RowLayout { visible: !!root.detail.votesUp From 3e31679c280bde3f773a337d5e5b34575b564b98 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:36:09 +0300 Subject: [PATCH 49/69] feat(workshop): distinguish no common version from unstated; log catalog failure cause --- cf-workers/catalog.ts | 5 ++-- cf-workers/picks.ts | 1 + cf-workers/steam.ts | 1 + cf-workers/tests/catalog.test.ts | 2 ++ cf-workers/types.ts | 2 ++ src/pxmodrim/core/workshop/types.py | 1 + src/pxmodrim/ui/plugins/workshop/models.py | 10 +++++-- tests/test_ui/test_workshop_models.py | 32 ++++++++++++++++++++++ tests/test_workshop/conftest.py | 1 + 9 files changed, 51 insertions(+), 4 deletions(-) diff --git a/cf-workers/catalog.ts b/cf-workers/catalog.ts index 7cf191f..66937ad 100644 --- a/cf-workers/catalog.ts +++ b/cf-workers/catalog.ts @@ -170,7 +170,8 @@ async function attachCollectionSummaries(db: D1Database, collections: CatalogCol return collections.map(collection => { const summary = summaries.get(collection.id); if (!summary) return collection; - return { ...collection, total_size: summary.total_size, supported_versions: JSON.parse(summary.supported_versions) as string[] }; + const versions = JSON.parse(summary.supported_versions) as string[]; + return { ...collection, total_size: summary.total_size, supported_versions: versions, no_common_version: versions.length === 0 }; }); } @@ -358,7 +359,7 @@ export async function handleCatalogRequest(request: Request, env: Env, ctx: Exec 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'); + 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/picks.ts b/cf-workers/picks.ts index bb6a9d2..c90031e 100644 --- a/cf-workers/picks.ts +++ b/cf-workers/picks.ts @@ -147,6 +147,7 @@ function toCollection(row: PickRow): CatalogCollection { member_count: memberIds.length, member_previews: [], total_size: null, + no_common_version: false, }; } diff --git a/cf-workers/steam.ts b/cf-workers/steam.ts index 4be8583..23f138d 100644 --- a/cf-workers/steam.ts +++ b/cf-workers/steam.ts @@ -185,6 +185,7 @@ function normalized(file: SteamFile, authors: Map): CatalogItem member_count: file.num_children ?? childIds.length, member_previews: [], total_size: null, + no_common_version: false, }; } const votes = file.vote_data; diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index 42acd67..78ea34e 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -647,6 +647,7 @@ test('collection sizes and common versions are stored once members are known, an 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(); @@ -676,6 +677,7 @@ test('a collection supports only the game versions every member supports', async 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'); diff --git a/cf-workers/types.ts b/cf-workers/types.ts index 828ba5c..ece9452 100644 --- a/cf-workers/types.ts +++ b/cf-workers/types.ts @@ -99,6 +99,8 @@ export interface CatalogCollection { member_previews: string[]; /** Total download size in bytes; null until every member is known. */ total_size: string | null; + /** True when every member is known and no game version is supported by all of them. */ + no_common_version: boolean; } export type CatalogItem = CatalogMod | CatalogCollection; diff --git a/src/pxmodrim/core/workshop/types.py b/src/pxmodrim/core/workshop/types.py index 873c111..4522890 100644 --- a/src/pxmodrim/core/workshop/types.py +++ b/src/pxmodrim/core/workshop/types.py @@ -69,6 +69,7 @@ class CatalogCollection( 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"]: diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/src/pxmodrim/ui/plugins/workshop/models.py index 8217522..cb7159c 100644 --- a/src/pxmodrim/ui/plugins/workshop/models.py +++ b/src/pxmodrim/ui/plugins/workshop/models.py @@ -48,9 +48,13 @@ def summarize(text: str, description_format: str = "bbcode") -> str: return f"{cut}…" -def _compat_label(supported: list[str], game_version: str, incompatible: bool) -> str: +def _compat_label( + supported: list[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: @@ -98,6 +102,8 @@ def item_row( if item.total_size is not None: file_size = f"{int(item.total_size) / 1048576:.1f} MB" versions = set(item.supported_versions) + no_common = isinstance(item, CatalogCollection) and item.no_common_version + incompatible = incompatible or no_common return { "itemId": item.id, "kind": item.kind, @@ -144,7 +150,7 @@ def item_row( "votesDown": votes_down, "summary": summarize(item.description, item.description_format), "compatLabel": _compat_label( - item.supported_versions, game_version, incompatible + item.supported_versions, game_version, incompatible, no_common ), } diff --git a/tests/test_ui/test_workshop_models.py b/tests/test_ui/test_workshop_models.py index 2dbfc0f..ad62a4c 100644 --- a/tests/test_ui/test_workshop_models.py +++ b/tests/test_ui/test_workshop_models.py @@ -222,3 +222,35 @@ def test_collection_size_comes_from_the_server_and_is_hidden_when_unknown() -> N ) 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 diff --git a/tests/test_workshop/conftest.py b/tests/test_workshop/conftest.py index d58fb9f..525171e 100644 --- a/tests/test_workshop/conftest.py +++ b/tests/test_workshop/conftest.py @@ -60,4 +60,5 @@ def collection_payload() -> dict[str, Any]: "member_count": 1, "member_previews": [], "total_size": None, + "no_common_version": False, } From 0015c4e444d5daf238f98d0de13abfe0387350a2 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:36:27 +0300 Subject: [PATCH 50/69] fix(workshop): collections without a summary report null size and no version conflict --- cf-workers/catalog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cf-workers/catalog.ts b/cf-workers/catalog.ts index 66937ad..e815def 100644 --- a/cf-workers/catalog.ts +++ b/cf-workers/catalog.ts @@ -169,7 +169,7 @@ async function attachCollectionSummaries(db: D1Database, collections: CatalogCol } return collections.map(collection => { const summary = summaries.get(collection.id); - if (!summary) return collection; + if (!summary) return { ...collection, total_size: null, no_common_version: false }; const versions = JSON.parse(summary.supported_versions) as string[]; return { ...collection, total_size: summary.total_size, supported_versions: versions, no_common_version: versions.length === 0 }; }); From 7ee2a6b58510d11edb395fe7eb20d3bf909e6418 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:37:49 +0300 Subject: [PATCH 51/69] fix(workshop): migration 0006 cleans up objects of the earlier collection_sizes migration --- cf-workers/migrations/0006_collection_summaries.sql | 8 ++++++++ cf-workers/tests/catalog.test.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/cf-workers/migrations/0006_collection_summaries.sql b/cf-workers/migrations/0006_collection_summaries.sql index bba03ea..eb1c36d 100644 --- a/cf-workers/migrations/0006_collection_summaries.sql +++ b/cf-workers/migrations/0006_collection_summaries.sql @@ -1,3 +1,11 @@ +-- 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 ( diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index 78ea34e..8d5da1a 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -683,3 +683,15 @@ test('a collection supports only the game versions every member supports', async await request('/catalog/collections/picked/versions', '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()); + await migrate(db); + await db.prepare("DELETE FROM catalog_items WHERE id = 'old-trigger'").run(); +}); From 8d40bd17a860aa6d82c27b93db20cc9c2892dd32 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:41:24 +0300 Subject: [PATCH 52/69] refactor(ui): route every view switch and settings through modrim:// links --- src/pxmodrim/ui/navigation.py | 1 + src/pxmodrim/ui/window/main_window.py | 24 ++++++++++++------ tests/test_ui/test_main_window.py | 36 ++++++++++++++++++++++++--- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/pxmodrim/ui/navigation.py b/src/pxmodrim/ui/navigation.py index 488cb24..77822a9 100644 --- a/src/pxmodrim/ui/navigation.py +++ b/src/pxmodrim/ui/navigation.py @@ -4,6 +4,7 @@ from urllib.parse import quote, unquote, urlsplit SCHEME = "modrim" +SETTINGS_VIEW_ID = "settings" @dataclass(frozen=True, slots=True) diff --git a/src/pxmodrim/ui/window/main_window.py b/src/pxmodrim/ui/window/main_window.py index f808470..669cd67 100644 --- a/src/pxmodrim/ui/window/main_window.py +++ b/src/pxmodrim/ui/window/main_window.py @@ -52,7 +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 build_route, parse_route +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, @@ -224,7 +224,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, @@ -270,16 +272,22 @@ def _open_route(self, url: str) -> None: route = parse_route(url) if route is None: return + if route.view_id == SETTINGS_VIEW_ID: + self._open_settings() # pyright: ignore[reportUnusedCoroutine] + return for index, view in enumerate(self._views): if view.view_id == route.view_id: - self._select_view(index) + self._show_view(index) view.open_route(route.path) return 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() @@ -351,9 +359,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() ) diff --git a/tests/test_ui/test_main_window.py b/tests/test_ui/test_main_window.py index 3f40828..1553946 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 @@ -53,7 +67,7 @@ def _on_rail_tab_changed(self, index: int) -> None: def test_route_selects_the_view_then_hands_it_the_path() -> None: opened: list[tuple[str, ...]] = [] - selected: list[int] = [] + shown: list[int] = [] def view(view_id: str) -> SimpleNamespace: return SimpleNamespace( @@ -64,16 +78,32 @@ def view(view_id: str) -> SimpleNamespace: window = cast( MainWindow, SimpleNamespace( - _views=[view("mods"), view("workshop")], _select_view=selected.append + _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 selected == [1] + 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, + _open_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 From e50399fc396ea3bfdd8676fde9607413c8eaabd8 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:53:39 +0300 Subject: [PATCH 53/69] fix(ui): schedule settings from its route as a tracked task --- src/pxmodrim/ui/window/main_window.py | 9 +++++++-- tests/test_ui/test_main_window.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pxmodrim/ui/window/main_window.py b/src/pxmodrim/ui/window/main_window.py index 669cd67..77a4dbe 100644 --- a/src/pxmodrim/ui/window/main_window.py +++ b/src/pxmodrim/ui/window/main_window.py @@ -152,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() @@ -273,7 +274,7 @@ def _open_route(self, url: str) -> None: if route is None: return if route.view_id == SETTINGS_VIEW_ID: - self._open_settings() # pyright: ignore[reportUnusedCoroutine] + self._start_settings() return for index, view in enumerate(self._views): if view.view_id == route.view_id: @@ -591,7 +592,11 @@ 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 diff --git a/tests/test_ui/test_main_window.py b/tests/test_ui/test_main_window.py index 1553946..6c28f50 100644 --- a/tests/test_ui/test_main_window.py +++ b/tests/test_ui/test_main_window.py @@ -96,7 +96,7 @@ def test_settings_route_opens_settings_without_switching_views() -> None: SimpleNamespace( _views=[], _show_view=shown.append, - _open_settings=lambda: opened.append(True), + _start_settings=lambda: opened.append(True), ), ) MainWindow._open_route(window, "modrim://settings") From a0e50d661dd89bd8c2076f4e232250ff986d2b91 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:57:19 +0300 Subject: [PATCH 54/69] refactor(workshop): tab, item and settings clicks navigate through modrim:// links --- CHANGELOG.md | 2 +- src/pxmodrim/ui/plugins/workshop/view.py | 41 +++++++------- tests/test_ui/test_workshop_view.py | 70 +++++++++++++++++++----- 3 files changed, 77 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eabed5..d41565a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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. Workshop pages and the rail views are addressable with `modrim://` links, e.g. `modrim://workshop/mod/` and `modrim://downloads`. +- 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. ### Fixed - 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. diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index 56219d2..52ed1f2 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -11,7 +11,6 @@ from PySide6.QtCore import Property, QUrl, Signal, Slot from PySide6.QtGui import QColor, QDesktopServices from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QDialog from qasync import asyncSlot from pxmodrim.core.downloads import download_manager @@ -26,10 +25,8 @@ WorkshopCatalog, ) from pxmodrim.core.workshop.catalog import FRESH_SECONDS -from pxmodrim.ui.components.dialogs import await_dialog from pxmodrim.ui.components.mod_activation import apply_activation -from pxmodrim.ui.navigation import build_route -from pxmodrim.ui.panels.settings_panel import SettingsPanel +from pxmodrim.ui.navigation import SETTINGS_VIEW_ID, build_route from pxmodrim.ui.plugins.workshop.details import ( Detail, fetch_detail, @@ -435,8 +432,11 @@ def _recount(self) -> None: async def refresh(self) -> None: await self.load() - @asyncSlot(str) - async def selectTab(self, tab: str) -> None: + @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() @@ -470,9 +470,15 @@ async def retry(self) -> None: if self._retry: await self._retry() - @asyncSlot(str, str) - async def openItem(self, item_id: str, kind: str) -> None: - await self.open_item(item_id, kind) + @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() @@ -509,9 +515,8 @@ async def follow_route(self, path: tuple[str, ...]) -> None: if self._torn_down: return if len(path) == 1 and path[0].title() in _TABS: - await self.selectTab(path[0].title()) + await self.select_tab(path[0].title()) elif len(path) == 2 and path[0] in ("mod", "collection"): - self._clear_detail() await self.open_item(path[1], path[0]) @asyncSlot() @@ -622,16 +627,10 @@ def openLink(self, url: str) -> None: if safe_url(url): QDesktopServices.openUrl(QUrl(url)) - @asyncSlot() - async def openSettings(self) -> None: - if self._qml_engine is None: - return - result, dialog = await await_dialog( - SettingsPanel, self._ctx, self._qml_engine, self - ) - if result == QDialog.DialogCode.Accepted: - self._ctx.update_config(dialog.get_config()) - dialog.deleteLater() + @Slot() + def openSettings(self) -> None: + if self._app_ctx is not None: + self._app_ctx.navigate(build_route(SETTINGS_VIEW_ID)) # -- downloads -------------------------------------------------------- diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index d162c7d..d2c5f9d 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import AsyncIterator from pathlib import Path +from types import SimpleNamespace from typing import Any, cast import msgspec @@ -41,6 +42,8 @@ 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.view import WorkshopViewPanel from pxmodrim.ui.theme.qml_theme import Theme @@ -263,7 +266,7 @@ async def test_view_paging_filters_and_catalog_errors( ) -> None: view, catalog, _ = panel await asyncio.sleep(0) - await view.selectTab("Mods") + 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 @@ -375,7 +378,7 @@ async def installed() -> list[CatalogMod]: return mods catalog.installed_with_updates = installed # type: ignore[method-assign] - await view.selectTab("Installed") + await view.select_tab("Installed") assert view.mods_model.count == 3 and fetches == 1 await view.filter("hugs", "", "all", "popular", "") @@ -404,9 +407,9 @@ async def installed() -> list[CatalogMod]: return [items[pid] for pid in catalog._installed()] catalog.installed_with_updates = installed # type: ignore[method-assign] - await view.selectTab("Installed") - await view.selectTab("Mods") - await view.selectTab("Installed") + 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) @@ -426,8 +429,8 @@ async def installed() -> list[CatalogMod]: assert view.mods_model._rows[0]["itemId"] == "2" catalog._responses.clear() - await view.selectTab("Mods") - await view.selectTab("Installed") + await view.select_tab("Mods") + await view.select_tab("Installed") assert fetches == 3 and view.mods_model._rows[0]["itemId"] == "2" @@ -524,7 +527,7 @@ async def test_installed_checkbox_click_toggles_activation_without_opening_detai view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) view.window().resize(1100, 800) view.window().show() - await view.selectTab("Installed") + await view.select_tab("Installed") await asyncio.sleep(0.5) view._qml.grabFramebuffer() await asyncio.sleep(0.2) @@ -542,6 +545,45 @@ async def test_installed_checkbox_click_toggles_activation_without_opening_detai 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: @@ -593,7 +635,7 @@ async def test_known_tag_options_accumulate_loaded_tags_but_not_version_tags( assert view.tagOptions == ["All tags", "Curated", "Utility"] await view.open_item("1", "mod") assert view.tagOptions == ["All tags", "Curated", "Dependency", "Utility"] - await view.selectTab("Installed") + await view.select_tab("Installed") assert view.searchQuery == "utility" @@ -601,7 +643,7 @@ async def test_back_walks_detail_history_before_returning_to_listing( panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], ) -> None: view, catalog, _ = panel - await view.selectTab("Mods") + await view.select_tab("Mods") await view.open_item("1", "mod") await view.open_item("2", "mod") assert view.url == "modrim://workshop/mod/2" @@ -640,7 +682,7 @@ async def unavailable(id: str) -> CatalogMod | None: assert view.url == "modrim://workshop/mod/1" -async def test_routes_open_tabs_and_items_and_reset_history( +async def test_routes_open_tabs_and_items_like_links( panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], ) -> None: view, _, _ = panel @@ -650,7 +692,7 @@ async def test_routes_open_tabs_and_items_and_reset_history( await view.follow_route(("collection", "picked:test")) assert view.url == "modrim://workshop/collection/picked%3Atest" await view.back() - assert not view.hasDetail + assert view.url == "modrim://workshop/mod/2" await view.follow_route(("installed",)) assert view.tab == "Installed" @@ -885,7 +927,7 @@ async def installed() -> list[CatalogMod]: return [items[pid] for pid in catalog._installed()] monkeypatch.setattr(catalog, "installed_with_updates", installed) - await view.selectTab("Installed") + await view.select_tab("Installed") await view.open_item("1", "mod") view._ctx.load(installed_mods, []) catalog._installed_index = None @@ -943,7 +985,7 @@ async def installed() -> list[CatalogMod]: catalog.installed_with_updates = installed # type: ignore[method-assign] for tab in ("Installed", "Mods", "Installed"): - await view.selectTab(tab) + await view.select_tab(tab) assert fetches == 1 and view.mods_model.count == 1 From 1588cb7c80b025b56f16f006d8814254f54dc196 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 16:58:44 +0300 Subject: [PATCH 55/69] feat(ui): log modrim:// navigation and unhandled routes --- src/pxmodrim/ui/plugins/workshop/view.py | 4 ++++ src/pxmodrim/ui/window/main_window.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index 52ed1f2..d0dc881 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -518,6 +518,10 @@ async def follow_route(self, path: tuple[str, ...]) -> None: 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: diff --git a/src/pxmodrim/ui/window/main_window.py b/src/pxmodrim/ui/window/main_window.py index 77a4dbe..bc8e71d 100644 --- a/src/pxmodrim/ui/window/main_window.py +++ b/src/pxmodrim/ui/window/main_window.py @@ -272,7 +272,9 @@ def _toggle_fullscreen(self) -> 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 @@ -281,6 +283,7 @@ def _open_route(self, url: str) -> None: self._show_view(index) view.open_route(route.path) return + logger.warning("main_window: no view handles {}", url) def _select_view(self, index: int) -> None: if 0 <= index < self._stack.count(): From 97dc12ce92200f913b906b4c39a0dd5e469ced3b Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 17:02:11 +0300 Subject: [PATCH 56/69] fix(workshop): refresh unchanged pages in place and accept bare view links --- src/pxmodrim/ui/plugins/workshop/models.py | 7 +++++ src/pxmodrim/ui/plugins/workshop/view.py | 2 +- tests/test_ui/test_workshop_models.py | 30 ++++++++++++++++++++++ tests/test_ui/test_workshop_view.py | 3 +++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/src/pxmodrim/ui/plugins/workshop/models.py index cb7159c..c1dba26 100644 --- a/src/pxmodrim/ui/plugins/workshop/models.py +++ b/src/pxmodrim/ui/plugins/workshop/models.py @@ -211,6 +211,13 @@ def set_page( ) 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) diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index d0dc881..2332bea 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -512,7 +512,7 @@ def open_route(self, path: tuple[str, ...]) -> None: task.add_done_callback(self._route_tasks.discard) async def follow_route(self, path: tuple[str, ...]) -> None: - if self._torn_down: + 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()) diff --git a/tests/test_ui/test_workshop_models.py b/tests/test_ui/test_workshop_models.py index ad62a4c..1fdc32c 100644 --- a/tests/test_ui/test_workshop_models.py +++ b/tests/test_ui/test_workshop_models.py @@ -85,6 +85,36 @@ def test_roles_paging_append_and_reset(qapp: object) -> 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"] diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index d2c5f9d..81781c8 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -698,6 +698,9 @@ async def test_routes_open_tabs_and_items_like_links( assert view.tab == "Installed" assert view.url == "modrim://workshop/installed" + await view.follow_route(()) + assert view.url == "modrim://workshop/installed" + async def test_back_preserves_scroll_position_and_active_filters( panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], From 06a1b9b3a80468bc01bebb1e9f5d7f99003a5a98 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 17:13:54 +0300 Subject: [PATCH 57/69] refactor(plugins): plugin-owned TOML settings and settings sections; shell no longer names plugins --- AGENTS.md | 7 + CHANGELOG.md | 3 + pyproject.toml | 1 + scripts/check-deps.py | 10 +- src/pxmodrim/_app.py | 13 +- src/pxmodrim/core/config.py | 35 +++- src/pxmodrim/core/downloads/steam.py | 42 ++-- src/pxmodrim/core/plugin_config.py | 75 +++++++ src/pxmodrim/core/workshop/__init__.py | 3 +- src/pxmodrim/core/workshop/catalog.py | 36 +++- src/pxmodrim/ui/context.py | 16 ++ src/pxmodrim/ui/panels/Settings.qml | 189 +----------------- src/pxmodrim/ui/panels/settings_panel.py | 43 ++-- src/pxmodrim/ui/plugins/downloads/view.py | 1 + .../steam_downloader/SteamSettings.qml | 114 +++++++++++ .../ui/plugins/steam_downloader/__init__.py | 5 + .../ui/plugins/steam_downloader/plugin.py | 33 +++ .../ui/plugins/workshop/CatalogSettings.qml | 36 ++++ src/pxmodrim/ui/plugins/workshop/plugin.py | 18 +- src/pxmodrim/ui/settings_section.py | 57 ++++++ src/pxmodrim/ui/window/main_window.py | 17 +- tests/test_downloads/test_steam.py | 19 +- tests/test_ui/test_workshop_view.py | 39 ++-- tests/test_workshop/test_catalog.py | 53 +++-- tests/test_workshop/test_client.py | 3 - tests/test_workshop/test_types.py | 12 +- uv.lock | 11 + 27 files changed, 595 insertions(+), 296 deletions(-) create mode 100644 src/pxmodrim/core/plugin_config.py create mode 100644 src/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml create mode 100644 src/pxmodrim/ui/plugins/steam_downloader/__init__.py create mode 100644 src/pxmodrim/ui/plugins/steam_downloader/plugin.py create mode 100644 src/pxmodrim/ui/plugins/workshop/CatalogSettings.qml create mode 100644 src/pxmodrim/ui/settings_section.py diff --git a/AGENTS.md b/AGENTS.md index 61ea438..4c257ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,13 @@ Downloads are plugins; the UI only talks to `core/downloads/manager.py` (`Downlo - 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/.toml`; subscribe to `.changed`. +- Settings UI: a UI plugin calls `app_ctx.add_settings_section(factory)`; 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d41565a..5475700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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/.toml` (`steam_downloader.toml`, `workshop_catalog.toml`), seeded once from the old `config.json` keys; plugins add their own Settings sections, 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. + ### Fixed - 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. diff --git a/pyproject.toml b/pyproject.toml index 36e1251..274fe3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "psutil>=7.2.2", "pyside6>=6.11.1", "qasync>=0.28.0", + "tomli-w>=1.2.0", "toposort>=1.10", "ttimer>=0.0.2", ] diff --git a/scripts/check-deps.py b/scripts/check-deps.py index 29c3024..45ec865 100644 --- a/scripts/check-deps.py +++ b/scripts/check-deps.py @@ -37,6 +37,7 @@ "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"}, @@ -49,6 +50,7 @@ "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"}, @@ -75,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": { @@ -101,12 +104,14 @@ "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", @@ -127,7 +132,8 @@ "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"}, @@ -145,8 +151,10 @@ "ui.panels", "ui.mod_selection", "ui.navigation", + "ui.settings_section", }, "ui.panels": { + "ui.settings_section", "ui.theme", "ui.components", "ui.models", diff --git a/src/pxmodrim/_app.py b/src/pxmodrim/_app.py index 474fef7..8a0e676 100644 --- a/src/pxmodrim/_app.py +++ b/src/pxmodrim/_app.py @@ -221,6 +221,11 @@ 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.steam_workshop import SteamWorkshopUiPlugin @@ -236,7 +241,7 @@ def _setup( if downloaders and "workshop_catalog" not in disabled: from pxmodrim.core.workshop import WorkshopCatalog - self._ctx.register_plugin(WorkshopCatalog(lambda: ctx.config)) + self._ctx.register_plugin(WorkshopCatalog()) if "workshop_ui" not in disabled: from pxmodrim.ui.plugins.workshop import WorkshopUiPlugin @@ -296,7 +301,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 89a3cff..a9ae292 100644 --- a/src/pxmodrim/core/config.py +++ b/src/pxmodrim/core/config.py @@ -9,6 +9,7 @@ from typing import Any, TypeVar import msgspec +import msgspec.toml from loguru import logger from pxmodrim.core.constants import RIMWORLD_STEAM_APP_ID, AfterLaunch @@ -66,13 +67,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 - workshop_catalog_url: str = "https://api.modrim.pyxiion.dev" log_upload_endpoint: str = "https://paste.rs/" launch_args: str = "" launch_wrapper: str = "" @@ -157,6 +151,33 @@ def save(self, filename: str, data: msgspec.Struct) -> None: encoded = msgspec.json.encode(raw) self._write(path, encoded) + def load_toml(self, filename: str, struct_type: type[StructT]) -> StructT | None: + """Decode *filename*; ``None`` if it does not exist or cannot be read.""" + path = self._config_dir / filename + try: + return msgspec.toml.decode(path.read_bytes(), type=struct_type) + except FileNotFoundError: + return None + except (OSError, msgspec.DecodeError) as e: + logger.warning(f"Failed to load {filename}: {e}") + return None + + def save_toml(self, filename: str, data: msgspec.Struct) -> None: + path = self._config_dir / filename + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_bytes(msgspec.toml.encode(data)) + os.replace(tmp, path) + + 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/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..c67b484 --- /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/.toml``. + + *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}.toml" + stored = ( + None + if service is None + else service.load_toml(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_toml(self._filename, value) + except OSError as exc: + logger.warning("Cannot save {}: {}", self._filename, exc) diff --git a/src/pxmodrim/core/workshop/__init__.py b/src/pxmodrim/core/workshop/__init__.py index 9644a3d..730c9b3 100644 --- a/src/pxmodrim/core/workshop/__init__.py +++ b/src/pxmodrim/core/workshop/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from pxmodrim.core.workshop.catalog import WorkshopCatalog +from pxmodrim.core.workshop.catalog import CatalogSettings, WorkshopCatalog from pxmodrim.core.workshop.client import CatalogClient, CatalogError from pxmodrim.core.workshop.types import ( Author, @@ -25,6 +25,7 @@ "CatalogMod", "CatalogPage", "CatalogQuery", + "CatalogSettings", "CollectionDetail", "Discover", "DownloadPlan", diff --git a/src/pxmodrim/core/workshop/catalog.py b/src/pxmodrim/core/workshop/catalog.py index 98899ee..b71243e 100644 --- a/src/pxmodrim/core/workshop/catalog.py +++ b/src/pxmodrim/core/workshop/catalog.py @@ -12,6 +12,7 @@ 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.types import ( CatalogCollection, @@ -67,13 +68,19 @@ def _write_stored(directory: Path, path: Path, entry: tuple[float, Any]) -> None 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, - config_accessor: Callable[[], AppConfig], client_factory: Callable[[str], CatalogClient] = CatalogClient, ) -> None: self.installed_changed: Event[None] = Event() @@ -83,9 +90,10 @@ def __init__( self._runner: asyncio.Future[None] | None = None self._idle = asyncio.Event() self.catalog_url_changed: Event[str] = Event() - self._config = config_accessor + self.settings = PluginConfig(None, self.name, CatalogSettings) + self.settings.changed.connect(self._on_settings_changed) self._client_factory = client_factory - self._base_url = config_accessor().workshop_catalog_url.strip().rstrip("/") + self._base_url = self.settings.value.url.strip().rstrip("/") self._client: CatalogClient | None = None self._clients: list[CatalogClient] = [] self._ctx: CoreContext | None = None @@ -97,6 +105,14 @@ def __init__( 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._manager = download_manager(ctx) ctx.mod_service.mods_changed.connect(self._on_installed_changed) @@ -112,11 +128,13 @@ def _on_download_finished(self, _: DownloadResult) -> None: self._installed_index = None self.installed_changed.emit(None) - def _on_config_changed(self, config: AppConfig) -> None: + def _on_config_changed(self, _config: AppConfig) -> None: self._installed_index = None - self._change_url(config.workshop_catalog_url) 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: @@ -126,17 +144,13 @@ def _change_url(self, url: str) -> None: self.catalog_url_changed.emit(url) def set_base_url(self, url: str) -> None: - self._config().workshop_catalog_url = url.strip().rstrip("/") - self._change_url(url) - if self._ctx is not None and self._ctx.has_config_service: - self._ctx.config_service.save("config.json", self._config()) + self.settings.update(CatalogSettings(url.strip().rstrip("/"))) @property def configured(self) -> bool: - return bool(self._config().workshop_catalog_url.strip()) + return bool(self._base_url) def _catalog_client(self) -> CatalogClient: - self._change_url(self._config().workshop_catalog_url) if not self.configured: raise CatalogError("Workshop catalog is disabled.") if self._client is None: diff --git a/src/pxmodrim/ui/context.py b/src/pxmodrim/ui/context.py index b72beca..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,9 @@ 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.""" @@ -47,6 +51,7 @@ class AppContext: "_navigate", "_plugins", "_rail_views", + "_settings_sections", "_toasts", "_ui_prefs", ) @@ -55,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() @@ -82,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 diff --git a/src/pxmodrim/ui/panels/Settings.qml b/src/pxmodrim/ui/panels/Settings.qml index 6739623..ffd8b74 100644 --- a/src/pxmodrim/ui/panels/Settings.qml +++ b/src/pxmodrim/ui/panels/Settings.qml @@ -19,13 +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(), - catalogUrl: catalogUrl.text.trim(), - connectTimeout: connectTimeout.value, - stallTimeout: stallTimeout.value, launchArgs: launchArgs.text.trim(), launchWrapper: launchWrapper.text.trim(), afterLaunch: afterLaunch.currentValue, @@ -307,182 +300,14 @@ Rectangle { } } - Group { - title: "Workshop catalog" - RowLayout { - Layout.fillWidth: true - Text { - Layout.preferredWidth: 150 - text: "Workshop catalog URL" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } - PxTextField { - id: catalogUrl + Repeater { + model: settings.sections + delegate: Group { + required property var modelData + title: modelData.title + Loader { Layout.fillWidth: true - text: root.initial.catalogUrl - placeholderText: "https://api.modrim.pyxiion.dev" - Accessible.name: "Workshop catalog URL" - } - } - 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 - } - } - - 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." + Component.onCompleted: setSource(modelData.source, { section: modelData.section }) } } } diff --git a/src/pxmodrim/ui/panels/settings_panel.py b/src/pxmodrim/ui/panels/settings_panel.py index 739c840..5d73b15 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 @@ -21,9 +22,9 @@ from pxmodrim.core.context import CoreContext from pxmodrim.core.loading import LoadingState from pxmodrim.core.sort.community_service import CommunityRulesService -from pxmodrim.core.workshop import WorkshopCatalog 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" @@ -52,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, @@ -63,20 +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, - "catalogUrl": cfg.workshop_catalog_url, - "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, @@ -98,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 @@ -195,6 +202,7 @@ def __init__( ctx: CoreContext, qml_engine: QQmlEngine, parent: QWidget, + sections: Sequence[SettingsSectionFactory] = (), ) -> None: super().__init__(parent) self.setObjectName("settingsPanel") @@ -205,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) @@ -236,13 +245,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_catalog_url=values["catalogUrl"].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"])), @@ -250,9 +252,8 @@ def _save(self, values: dict[str, Any]) -> None: launch_confirm_unsaved=bool(values["confirmUnsaved"]), launch_confirm_running=bool(values["confirmRunning"]), ) - catalog = self._ctx.plugins.get("workshop_catalog") - if isinstance(catalog, WorkshopCatalog): - catalog.set_base_url(self._config.workshop_catalog_url) + for section in self._sections: + section.apply() self.accept() def get_config(self) -> AppConfig: 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/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/plugin.py b/src/pxmodrim/ui/plugins/workshop/plugin.py index 0c652de..2d29648 100644 --- a/src/pxmodrim/ui/plugins/workshop/plugin.py +++ b/src/pxmodrim/ui/plugins/workshop/plugin.py @@ -1,17 +1,33 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +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/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 bc8e71d..b1fd429 100644 --- a/src/pxmodrim/ui/window/main_window.py +++ b/src/pxmodrim/ui/window/main_window.py @@ -213,9 +213,7 @@ 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._app_ctx.navigate(build_route("downloads")) - ) + 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) @@ -285,6 +283,11 @@ def _open_route(self, url: str) -> None: 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._app_ctx.navigate(build_route(self._views[index].view_id)) @@ -602,7 +605,11 @@ def _start_settings(self) -> None: 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 @@ -881,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/test_downloads/test_steam.py b/tests/test_downloads/test_steam.py index ecdc229..45b0ed9 100644 --- a/tests/test_downloads/test_steam.py +++ b/tests/test_downloads/test_steam.py @@ -18,6 +18,7 @@ from pxmodrim.core.downloads.steam import ( ClientOptions, SteamDownloader, + SteamSettings, WorkshopSyncState, ) from pxmodrim.core.mod_service import ModService @@ -114,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)] @@ -367,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") @@ -388,9 +389,9 @@ async def test_cancelled_manual_batch_does_not_discard_queue_after_auto_update( ctx = svc._ctx ctx._mod_service = ModService(ctx, []) _load(svc, [_mod_at(tmp_path / "Mods" / "1", "1")]) - ctx.config.workshop_auto_update_hours = 6 + svc.settings.update(SteamSettings(auto_update_hours=6)) manager = download_manager(ctx) - catalog = WorkshopCatalog(lambda: ctx.config) + catalog = WorkshopCatalog() catalog.setup(ctx) tasks: list[asyncio.Task[Any]] = [] try: @@ -498,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_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index 81781c8..fabe9e1 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -9,7 +9,7 @@ import msgspec import pytest from PySide6.QtCore import QObject, QPoint, Qt, QUrl -from PySide6.QtGui import QAccessible +from PySide6.QtGui import QAccessible, QKeySequence from PySide6.QtQuick import QQuickItem from PySide6.QtQuickWidgets import QQuickWidget from PySide6.QtTest import QTest @@ -45,6 +45,7 @@ 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 @@ -75,7 +76,7 @@ def _mod(pid: str, dependencies: list[str] | None = None) -> CatalogMod: class FakeCatalog(WorkshopCatalog): def __init__(self, ctx: CoreContext) -> None: - super().__init__(lambda: ctx.config) + super().__init__() self.mod_item = _mod("1", ["2"]) self.dependency = _mod("2") self.collection_item = CatalogCollection( @@ -337,7 +338,7 @@ async def test_unset_catalog_never_fetches( ) -> None: view, catalog, _ = panel await asyncio.sleep(0) - view._ctx.update_config(AppConfig(workshop_catalog_url="")) + catalog.set_base_url("") await view.load() assert not view.configured and not catalog.queries @@ -347,16 +348,30 @@ async def test_settings_saves_and_applies_catalog_url( ) -> None: from pxmodrim.ui.panels.settings_panel import SettingsPanel - view, catalog, _ = panel - await asyncio.sleep(0) - view._ctx.plugins.register(catalog) - settings = SettingsPanel(view._ctx, view._qml.engine(), view) - values = dict(cast("dict[str, Any]", settings._backend.initial)) - values["catalogUrl"] = " https://catalog.example.test/ " - settings._save(values) - assert settings.get_config().workshop_catalog_url == "https://catalog.example.test/" - assert view._ctx.config.workshop_catalog_url == "https://catalog.example.test" + 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" + assert warnings == [] settings.deleteLater() diff --git a/tests/test_workshop/test_catalog.py b/tests/test_workshop/test_catalog.py index bce1d62..7a17fd4 100644 --- a/tests/test_workshop/test_catalog.py +++ b/tests/test_workshop/test_catalog.py @@ -19,6 +19,7 @@ CatalogClient, CatalogError, CatalogMod, + CatalogSettings, DownloadPlan, WorkshopCatalog, ) @@ -57,7 +58,7 @@ 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(lambda: ctx.config) + 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" @@ -102,7 +103,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) catalog = WorkshopCatalog( - lambda: ctx.config, lambda url: CatalogClient(url, httpx.MockTransport(handler)) + lambda url: CatalogClient(url, httpx.MockTransport(handler)) ) catalog.setup(ctx) try: @@ -141,7 +142,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) catalog = WorkshopCatalog( - lambda: ctx.config, lambda url: CatalogClient(url, httpx.MockTransport(handler)) + lambda url: CatalogClient(url, httpx.MockTransport(handler)) ) catalog.setup(ctx) try: @@ -156,7 +157,7 @@ async def test_download_delegates_without_activation( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: ctx, _ = make_context(tmp_path) - catalog = WorkshopCatalog(lambda: ctx.config) + catalog = WorkshopCatalog() catalog.setup(ctx) calls: list[list[str]] = [] result = DownloadResult(succeeded=["2"], failed=[]) @@ -178,15 +179,15 @@ async def download(ids: list[str]) -> DownloadResult: async def test_empty_url_disabled_without_constructing_client(tmp_path: Path) -> None: ctx, _ = make_context(tmp_path) - ctx.config.workshop_catalog_url = "" clients: list[str] = [] def factory(url: str) -> CatalogClient: clients.append(url) raise AssertionError("Disabled catalog must not create a client") - catalog = WorkshopCatalog(lambda: ctx.config, factory) + catalog = WorkshopCatalog(factory) catalog.setup(ctx) + catalog.set_base_url("") try: assert not catalog.configured for operation in [ @@ -216,7 +217,7 @@ def factory(url: str) -> CatalogClient: transports.append(client._http) return client - catalog = WorkshopCatalog(lambda: ctx.config, factory) + catalog = WorkshopCatalog(factory) catalog.setup(ctx) changed: list[str] = [] installed: list[None] = [] @@ -224,11 +225,11 @@ def factory(url: str) -> CatalogClient: catalog.installed_changed.connect(installed.append) await catalog.mod("2009463077") catalog.set_base_url(" https://other.example/ ") - assert ctx.config.workshop_catalog_url == "https://other.example" - assert ( - ConfigService(tmp_path).load("config.json", AppConfig).workshop_catalog_url - == "https://other.example" + assert catalog.settings.value.url == "https://other.example" + stored = ConfigService(tmp_path).load_toml( + "plugins/workshop_catalog.toml", CatalogSettings ) + assert stored == CatalogSettings("https://other.example") await catalog.mod("2009463077") catalog.set_base_url("") assert not catalog.configured @@ -249,7 +250,7 @@ async def test_cached_serves_remembered_value_before_refreshing( tmp_path: Path, ) -> None: ctx, _ = make_context(tmp_path) - catalog = WorkshopCatalog(lambda: ctx.config) + catalog = WorkshopCatalog() catalog.setup(ctx) values = iter(["first", "second", "second"]) calls = 0 @@ -275,7 +276,7 @@ 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(lambda: ctx.config) + catalog = WorkshopCatalog() catalog.setup(ctx) manager = download_manager(ctx) manager.register(downloader) @@ -313,13 +314,13 @@ async def produce() -> list[int]: async def must_not_run() -> list[int]: raise AssertionError("A fresh value on disk must not hit the catalog") - first = WorkshopCatalog(lambda: ctx.config) + first = WorkshopCatalog() first.setup(ctx) assert [v async for v in first.cached("k", produce, value_type=list[int])] == [ [1, 2] ] - second = WorkshopCatalog(lambda: ctx.config) + second = WorkshopCatalog() second.setup(ctx) assert [ v async for v in second.cached("k", must_not_run, value_type=list[int]) @@ -327,7 +328,7 @@ async def must_not_run() -> list[int]: for file in (tmp_path / "workshop-cache").glob("*.json"): file.write_text("{not json") - third = WorkshopCatalog(lambda: ctx.config) + third = WorkshopCatalog() third.setup(ctx) assert [v async for v in third.cached("k", produce, value_type=list[int])] == [ [1, 2] @@ -338,7 +339,7 @@ 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(lambda: ctx.config) + catalog = WorkshopCatalog() catalog.setup(ctx) calls = 0 @@ -370,9 +371,9 @@ async def produce() -> list[CatalogMod]: async def must_not_run() -> list[CatalogMod]: raise AssertionError("A fresh cached response must not hit the catalog") - first = WorkshopCatalog(lambda: ctx.config) + first = WorkshopCatalog() first.setup(ctx) - second = WorkshopCatalog(lambda: ctx.config) + second = WorkshopCatalog() second.setup(ctx) try: await anext(first.cached("installed", produce, value_type=list[CatalogMod])) @@ -388,3 +389,17 @@ async def must_not_run() -> list[CatalogMod]: 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_toml( + "plugins/workshop_catalog.toml", CatalogSettings + ) + assert stored == CatalogSettings("http://localhost:8787") diff --git a/tests/test_workshop/test_client.py b/tests/test_workshop/test_client.py index 1ff8292..dca6154 100644 --- a/tests/test_workshop/test_client.py +++ b/tests/test_workshop/test_client.py @@ -6,7 +6,6 @@ import msgspec import pytest -from pxmodrim.core.config import AppConfig from pxmodrim.core.workshop import ( CatalogClient, CatalogCollection, @@ -241,9 +240,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == "/catalog/mods/818773962" return httpx.Response(404, json={"error": {"message": "Item unavailable"}}) - config = AppConfig() catalog = WorkshopCatalog( - lambda: config, lambda url: CatalogClient(url, httpx.MockTransport(handler)), ) try: diff --git a/tests/test_workshop/test_types.py b/tests/test_workshop/test_types.py index 3138d8c..5104d39 100644 --- a/tests/test_workshop/test_types.py +++ b/tests/test_workshop/test_types.py @@ -70,15 +70,13 @@ 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,"workshop_parallel_items":3}' - ) + (tmp_path / "config.json").write_bytes(b'{"schema_version":1,"max_snapshots":3}') config = service.load("config.json", AppConfig) - assert config.workshop_catalog_url == "https://api.modrim.pyxiion.dev" - assert config.workshop_parallel_items == 3 - config.workshop_catalog_url = "" + 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).workshop_catalog_url == "" + assert service.load("config.json", AppConfig).log_upload_endpoint == "" def test_query_defaults_and_frozen_contract() -> None: diff --git a/uv.lock b/uv.lock index 32cdad9..2ffab84 100644 --- a/uv.lock +++ b/uv.lock @@ -235,6 +235,7 @@ dependencies = [ { name = "psutil" }, { name = "pyside6" }, { name = "qasync" }, + { name = "tomli-w" }, { name = "toposort" }, { name = "ttimer" }, ] @@ -268,6 +269,7 @@ requires-dist = [ { name = "pxsteamdl", marker = "extra == 'steam'", git = "https://github.com/PyXiion/PxSteamDL" }, { name = "pyside6", specifier = ">=6.11.1" }, { name = "qasync", specifier = ">=0.28.0" }, + { name = "tomli-w", specifier = ">=1.2.0" }, { name = "toposort", specifier = ">=1.10" }, { name = "ttimer", specifier = ">=0.0.2" }, ] @@ -502,6 +504,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + [[package]] name = "toposort" version = "1.10" From 7fc3c5aca5ed2be0111b82ca796e478357d48cf4 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 17:16:15 +0300 Subject: [PATCH 58/69] refactor(plugins): store plugin settings as JSON --- AGENTS.md | 2 +- CHANGELOG.md | 2 +- pyproject.toml | 1 - src/pxmodrim/core/config.py | 23 ++++++----------------- src/pxmodrim/core/plugin_config.py | 8 ++++---- tests/test_workshop/test_catalog.py | 8 ++++---- uv.lock | 11 ----------- 7 files changed, 16 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4c257ba..21d1355 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Downloads are plugins; the UI only talks to `core/downloads/manager.py` (`Downlo ## 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/.toml`; subscribe to `.changed`. +- 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)`; 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5475700..d2b0a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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/.toml` (`steam_downloader.toml`, `workshop_catalog.toml`), seeded once from the old `config.json` keys; plugins add their own Settings sections, so the Settings dialog and the main window no longer refer to specific plugins. +- 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 sections, 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. ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 274fe3c..36e1251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ dependencies = [ "psutil>=7.2.2", "pyside6>=6.11.1", "qasync>=0.28.0", - "tomli-w>=1.2.0", "toposort>=1.10", "ttimer>=0.0.2", ] diff --git a/src/pxmodrim/core/config.py b/src/pxmodrim/core/config.py index a9ae292..1ca6574 100644 --- a/src/pxmodrim/core/config.py +++ b/src/pxmodrim/core/config.py @@ -9,7 +9,6 @@ from typing import Any, TypeVar import msgspec -import msgspec.toml from loguru import logger from pxmodrim.core.constants import RIMWORLD_STEAM_APP_ID, AfterLaunch @@ -151,23 +150,13 @@ def save(self, filename: str, data: msgspec.Struct) -> None: encoded = msgspec.json.encode(raw) self._write(path, encoded) - def load_toml(self, filename: str, struct_type: type[StructT]) -> StructT | None: - """Decode *filename*; ``None`` if it does not exist or cannot be read.""" - path = self._config_dir / filename - try: - return msgspec.toml.decode(path.read_bytes(), type=struct_type) - except FileNotFoundError: - return None - except (OSError, msgspec.DecodeError) as e: - logger.warning(f"Failed to load {filename}: {e}") + 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 - - def save_toml(self, filename: str, data: msgspec.Struct) -> None: - path = self._config_dir / filename - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_bytes(msgspec.toml.encode(data)) - os.replace(tmp, path) + return self.load(filename, struct_type) def load_raw(self, filename: str) -> dict[str, Any]: """The undecoded top-level JSON object in *filename*, or ``{}``.""" diff --git a/src/pxmodrim/core/plugin_config.py b/src/pxmodrim/core/plugin_config.py index c67b484..b86f98b 100644 --- a/src/pxmodrim/core/plugin_config.py +++ b/src/pxmodrim/core/plugin_config.py @@ -13,7 +13,7 @@ class PluginConfig[T: msgspec.Struct]: - """A plugin's own settings, kept in ``plugins/.toml``. + """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 @@ -31,11 +31,11 @@ def __init__( ) -> None: self.changed: Event[T] = Event() self._service = service - self._filename = f"plugins/{name}.toml" + self._filename = f"plugins/{name}.json" stored = ( None if service is None - else service.load_toml(self._filename, settings_type) + else service.load_existing(self._filename, settings_type) ) if stored is None: stored = self._seed(settings_type, legacy or {}) @@ -70,6 +70,6 @@ def _store(self, value: T) -> None: if self._service is None: return try: - self._service.save_toml(self._filename, value) + self._service.save(self._filename, value) except OSError as exc: logger.warning("Cannot save {}: {}", self._filename, exc) diff --git a/tests/test_workshop/test_catalog.py b/tests/test_workshop/test_catalog.py index 7a17fd4..e28d4f6 100644 --- a/tests/test_workshop/test_catalog.py +++ b/tests/test_workshop/test_catalog.py @@ -226,8 +226,8 @@ def factory(url: str) -> CatalogClient: await catalog.mod("2009463077") catalog.set_base_url(" https://other.example/ ") assert catalog.settings.value.url == "https://other.example" - stored = ConfigService(tmp_path).load_toml( - "plugins/workshop_catalog.toml", CatalogSettings + stored = ConfigService(tmp_path).load_existing( + "plugins/workshop_catalog.json", CatalogSettings ) assert stored == CatalogSettings("https://other.example") await catalog.mod("2009463077") @@ -399,7 +399,7 @@ def test_catalog_url_moves_out_of_the_old_shared_config(tmp_path: Path) -> None: catalog = WorkshopCatalog() catalog.setup(ctx) assert catalog.settings.value.url == "http://localhost:8787" - stored = ConfigService(tmp_path).load_toml( - "plugins/workshop_catalog.toml", CatalogSettings + stored = ConfigService(tmp_path).load_existing( + "plugins/workshop_catalog.json", CatalogSettings ) assert stored == CatalogSettings("http://localhost:8787") diff --git a/uv.lock b/uv.lock index 2ffab84..32cdad9 100644 --- a/uv.lock +++ b/uv.lock @@ -235,7 +235,6 @@ dependencies = [ { name = "psutil" }, { name = "pyside6" }, { name = "qasync" }, - { name = "tomli-w" }, { name = "toposort" }, { name = "ttimer" }, ] @@ -269,7 +268,6 @@ requires-dist = [ { name = "pxsteamdl", marker = "extra == 'steam'", git = "https://github.com/PyXiion/PxSteamDL" }, { name = "pyside6", specifier = ">=6.11.1" }, { name = "qasync", specifier = ">=0.28.0" }, - { name = "tomli-w", specifier = ">=1.2.0" }, { name = "toposort", specifier = ">=1.10" }, { name = "ttimer", specifier = ">=0.0.2" }, ] @@ -504,15 +502,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, ] -[[package]] -name = "tomli-w" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, -] - [[package]] name = "toposort" version = "1.10" From 54d8584f4d52cc1518f3f9e2a9239b227c430837 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 17:21:52 +0300 Subject: [PATCH 59/69] fix(ui): plugin settings as tabs, Wayland popup parents, clean Settings teardown --- AGENTS.md | 2 +- CHANGELOG.md | 4 +- src/pxmodrim/_app.py | 2 + src/pxmodrim/ui/components/popup_parent.py | 18 +++++++ src/pxmodrim/ui/panels/Settings.qml | 57 +++++++++++++++------- src/pxmodrim/ui/panels/settings_panel.py | 6 +++ tests/test_ui/test_workshop_view.py | 2 +- 7 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 src/pxmodrim/ui/components/popup_parent.py diff --git a/AGENTS.md b/AGENTS.md index 21d1355..700f0be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ Downloads are plugins; the UI only talks to `core/downloads/manager.py` (`Downlo ## 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)`; 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. +- 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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index d2b0a48..0383202 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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 sections, so the Settings dialog and the main window no longer refer to specific plugins. +- 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. ### Fixed +- 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. - 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. diff --git a/src/pxmodrim/_app.py b/src/pxmodrim/_app.py index 8a0e676..a7be82a 100644 --- a/src/pxmodrim/_app.py +++ b/src/pxmodrim/_app.py @@ -41,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 @@ -145,6 +146,7 @@ def __init__(self) -> None: 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")) 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/panels/Settings.qml b/src/pxmodrim/ui/panels/Settings.qml index ffd8b74..5214c1a 100644 --- a/src/pxmodrim/ui/panels/Settings.qml +++ b/src/pxmodrim/ui/panels/Settings.qml @@ -109,8 +109,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 @@ -139,7 +139,7 @@ Rectangle { cursorShape: Qt.PointingHandCursor Accessible.role: Accessible.PageTab Accessible.name: tabItem.label - onClicked: root.tab = tabItem.index + onClicked: root.tab = tabItem.page } } @@ -154,8 +154,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 @@ -299,18 +308,6 @@ Rectangle { checked: root.initial.compact } } - - Repeater { - model: settings.sections - delegate: Group { - required property var modelData - title: modelData.title - Loader { - Layout.fillWidth: true - Component.onCompleted: setSource(modelData.source, { section: modelData.section }) - } - } - } } } @@ -380,6 +377,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/settings_panel.py b/src/pxmodrim/ui/panels/settings_panel.py index 5d73b15..8ab7f57 100644 --- a/src/pxmodrim/ui/panels/settings_panel.py +++ b/src/pxmodrim/ui/panels/settings_panel.py @@ -227,6 +227,12 @@ 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. + self.finished.connect(self._unload) + + def _unload(self) -> None: + self._qml.setSource(QUrl()) def _save(self, values: dict[str, Any]) -> None: self._config = msgspec.structs.replace( diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index fabe9e1..f1cc69f 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -371,8 +371,8 @@ async def test_settings_saves_and_applies_catalog_url( 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" + delete(settings) assert warnings == [] - settings.deleteLater() async def test_installed_search_filters_loaded_mods_without_refetching( From 2a3e9106b900b1093137f1e68c61de6771eb2339 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 17:31:06 +0300 Subject: [PATCH 60/69] fix(workshop): collection download buttons show preparing/queued/downloading state --- .../workshop/{InstalledRow.qml => ModRow.qml} | 0 src/pxmodrim/ui/plugins/workshop/view.py | 36 ++++++++++++++--- tests/test_ui/test_workshop_view.py | 40 +++++++++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) rename src/pxmodrim/ui/plugins/workshop/{InstalledRow.qml => ModRow.qml} (100%) diff --git a/src/pxmodrim/ui/plugins/workshop/InstalledRow.qml b/src/pxmodrim/ui/plugins/workshop/ModRow.qml similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/InstalledRow.qml rename to src/pxmodrim/ui/plugins/workshop/ModRow.qml diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index 2332bea..3264665 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -94,6 +94,8 @@ def __init__( 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._active_ids: set[str] = set() @@ -159,13 +161,27 @@ def _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, CatalogMod) and row["state"] != "installed": - running = item.id in self._downloads.active_ids - if running or item.id in self._catalog.queued_ids: - row["queued"] = True - row["actionLabel"] = "Downloading" if running else "Queued" + if 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 = 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]: return [self._row(item) for item in items] @@ -192,7 +208,8 @@ def _detail_row(self, detail: Detail) -> dict[str, Any]: and self._catalog.install_state(member) != "missing" for member in detail.members ) - row["actionLabel"] = "Complete download" if started else "Download all" + 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) @@ -647,6 +664,9 @@ async def _download(self, ids: list[str], collections: list[str]) -> None: 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) @@ -656,8 +676,12 @@ async def _download(self, ids: list[str], collections: list[str]) -> None: 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: diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index f1cc69f..e6905df 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -153,6 +153,8 @@ async def plan(self, mod_ids: list[str], collection_ids: list[str]) -> DownloadP 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 @@ -333,6 +335,44 @@ async def test_collection_detail_uses_member_images_when_preview_metadata_is_abs assert detail["collage"] == ["https://images.test/member.png"] +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: From 976010d0b866d2d23e07c27c140e2ceb2f69e095 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 17:33:08 +0300 Subject: [PATCH 61/69] feat(workshop): collection and dependency members use the Installed mod row --- src/pxmodrim/ui/plugins/workshop/ModRow.qml | 31 ++++++---- src/pxmodrim/ui/plugins/workshop/Workshop.qml | 58 ++++++++----------- 2 files changed, 43 insertions(+), 46 deletions(-) diff --git a/src/pxmodrim/ui/plugins/workshop/ModRow.qml b/src/pxmodrim/ui/plugins/workshop/ModRow.qml index 0189702..5be102d 100644 --- a/src/pxmodrim/ui/plugins/workshop/ModRow.qml +++ b/src/pxmodrim/ui/plugins/workshop/ModRow.qml @@ -11,6 +11,7 @@ Rectangle { 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 @@ -32,15 +33,21 @@ Rectangle { anchors.topMargin: 6 anchors.bottomMargin: 6 spacing: 10 - PxCheckBox { - id: activeBox - objectName: "activeToggle" - checked: row.active - Accessible.name: "Active" - Accessible.description: "Enable or disable " + row.title - onToggled: { - workshopPanel.toggleActivation(row.itemId) - checked = Qt.binding(() => row.active) + 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 { @@ -72,9 +79,9 @@ Rectangle { } } PxButton { - visible: row.state === "outdated" - text: "Update" - variant: "warning" + 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/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index fc7f86b..fffdfa1 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -325,7 +325,7 @@ Rectangle { ScrollBar.vertical: PxScrollBar {} header: ListingHeader { width: installedGrid.width } footer: ListingFooter { width: installedGrid.width; listing: catalogMods } - delegate: InstalledRow { + delegate: ModRow { width: GridView.view.cellWidth - 8 height: GridView.view.cellHeight - 8 } @@ -478,10 +478,16 @@ Rectangle { } } } - RowLayout { + GridLayout { + id: detailBody + readonly property bool pack: root.detail.kind === "collection" Layout.fillWidth: true - spacing: 16 + columns: pack ? 1 : 2 + columnSpacing: 16 + rowSpacing: 16 Rectangle { + Layout.row: detailBody.pack ? 1 : 0 + Layout.column: 0 Layout.fillWidth: true Layout.preferredWidth: 3 Layout.alignment: Qt.AlignTop @@ -503,6 +509,8 @@ Rectangle { } } Rectangle { + Layout.row: 0 + Layout.column: detailBody.pack ? 0 : 1 Layout.fillWidth: true Layout.preferredWidth: 2 Layout.alignment: Qt.AlignTop @@ -515,38 +523,20 @@ Rectangle { anchors.fill: parent anchors.margins: 16 spacing: 8 - Heading { text: root.detail.kind === "collection" ? "Collection members · " + catalogMembers.count : "Required mods · " + catalogMembers.count } + Heading { text: (detailBody.pack ? "Collection members · " : "Required mods · ") + catalogMembers.count } Copy { visible: catalogMembers.count === 0; text: "None listed." } - Repeater { - model: catalogMembers - Rectangle { - id: member - required property string itemId - required property string kind - required property string title - required property string versions - required property string stateLabel - required property string state - required property string actionLabel - required property bool incompatible - required property bool queued - Layout.fillWidth: true - implicitHeight: memberRow.implicitHeight + 20 - radius: Theme.radiusSm - color: Theme.elevate3 - RowLayout { - id: memberRow - anchors.fill: parent - anchors.margins: 10 - spacing: 8 - ColumnLayout { - Layout.fillWidth: true - spacing: 2 - Copy { Layout.fillWidth: true; text: member.title; color: Theme.textMain; elide: Text.ElideRight; maximumLineCount: 1 } - Copy { text: member.stateLabel + (member.incompatible ? " · incompatible" : ""); font.pixelSize: Theme.fontSizeSm; color: member.incompatible ? Theme.warning : Theme.textMuted } - } - PxButton { text: "Details"; variant: "ghost"; onClicked: workshopPanel.openItem(member.itemId, member.kind) } - PxButton { visible: member.state !== "installed"; text: member.actionLabel; variant: member.state === "outdated" ? "warning" : "primary"; enabled: !member.queued; onClicked: workshopPanel.downloadItem(member.itemId, member.kind) } + GridLayout { + objectName: "membersGrid" + Layout.fillWidth: true + columns: detailBody.pack ? Math.max(1, Math.floor((Math.min(1200, detailScroll.availableWidth) - 32) / 340)) : 1 + columnSpacing: 8 + rowSpacing: 8 + Repeater { + model: catalogMembers + ModRow { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: 54 } } } From 4a828f298c2cc45cb9aa1385df3dc1942a7e2a90 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 17:33:49 +0300 Subject: [PATCH 62/69] style(settings): drop card box around setting groups --- src/pxmodrim/core/workshop/catalog.py | 3 +++ src/pxmodrim/ui/panels/Settings.qml | 5 +---- src/pxmodrim/ui/plugins/workshop/models.py | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pxmodrim/core/workshop/catalog.py b/src/pxmodrim/core/workshop/catalog.py index b71243e..d1ca96e 100644 --- a/src/pxmodrim/core/workshop/catalog.py +++ b/src/pxmodrim/core/workshop/catalog.py @@ -14,6 +14,7 @@ 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, @@ -92,6 +93,7 @@ def __init__( 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 @@ -114,6 +116,7 @@ def setup(self, ctx: CoreContext) -> None: 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) diff --git a/src/pxmodrim/ui/panels/Settings.qml b/src/pxmodrim/ui/panels/Settings.qml index 5214c1a..b5e9a19 100644 --- a/src/pxmodrim/ui/panels/Settings.qml +++ b/src/pxmodrim/ui/panels/Settings.qml @@ -51,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 diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/src/pxmodrim/ui/plugins/workshop/models.py index c1dba26..f1aeda2 100644 --- a/src/pxmodrim/ui/plugins/workshop/models.py +++ b/src/pxmodrim/ui/plugins/workshop/models.py @@ -132,6 +132,7 @@ def item_row( "state": state, "active": False, "queued": False, + "favourite": False, "stateLabel": { "missing": "Not installed", "installed": "Installed", @@ -180,6 +181,7 @@ class CatalogListModel(QAbstractListModel): "votesUp", "votesDown", "queued", + "favourite", "summary", "compatLabel", ) From e025412a417e486bcae0ffc1cf7759f45efd5fad Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 17:37:12 +0300 Subject: [PATCH 63/69] feat(workshop): favourite collections with a Favourites tab --- CHANGELOG.md | 3 + src/pxmodrim/core/workshop/favourites.py | 60 ++++++++++++++++ src/pxmodrim/ui/components/icons.py | 19 +++++ .../ui/plugins/workshop/CollectionBanner.qml | 9 +++ src/pxmodrim/ui/plugins/workshop/Workshop.qml | 17 ++++- src/pxmodrim/ui/plugins/workshop/view.py | 37 +++++++++- tests/test_ui/test_workshop_view.py | 38 ++++++++++ tests/test_workshop/test_favourites.py | 71 +++++++++++++++++++ 8 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 src/pxmodrim/core/workshop/favourites.py create mode 100644 tests/test_workshop/test_favourites.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0383202..39d9c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. ### Fixed - 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. - 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. @@ -34,6 +36,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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. 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/ui/components/icons.py b/src/pxmodrim/ui/components/icons.py index b5e130a..39b69ca 100644 --- a/src/pxmodrim/ui/components/icons.py +++ b/src/pxmodrim/ui/components/icons.py @@ -42,6 +42,8 @@ "donut", "bars", "grip", + "star", + "star-filled", ] from PySide6.QtCore import QRectF @@ -51,6 +53,11 @@ 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] = { @@ -368,6 +375,18 @@ '' "" ), + "star": ( + '' + f'' + "" + ), + "star-filled": ( + '' + f'' + "" + ), } diff --git a/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml b/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml index c9eb1c5..246fa3a 100644 --- a/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml +++ b/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml @@ -19,6 +19,7 @@ Rectangle { required property int memberCount required property string fileSize required property string actionLabel + required property bool favourite color: hover.hovered ? Qt.lighter(Theme.elevate2, 1.08) : Theme.elevate2 radius: Theme.radiusMd @@ -96,6 +97,14 @@ Rectangle { 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) + } PxButton { text: banner.actionLabel variant: "primary" diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index fffdfa1..00214e0 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -63,7 +63,9 @@ Rectangle { spacing: 12 Copy { visible: !workshopPanel.busy && !workshopPanel.error && listing.count === 0 - text: workshopPanel.tab === "Installed" ? "No installed Workshop mods were found." : "No matching items. Try another search or filter." + 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 @@ -113,7 +115,7 @@ Rectangle { Layout.fillWidth: true spacing: 4 Repeater { - model: ["Discover", "Mods", "Collections", "Installed"] + model: ["Discover", "Mods", "Collections", "Favourites", "Installed"] Item { id: tabItem required property string modelData @@ -293,7 +295,7 @@ Rectangle { GridView { id: collectionsGrid objectName: "collectionsScroll" - readonly property bool listingTab: workshopPanel.configured && workshopPanel.tab === "Collections" + 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 @@ -440,6 +442,15 @@ Rectangle { 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 { diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index 3264665..d6d2892 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -46,7 +46,7 @@ from pxmodrim.ui.context import AppContext _FAILURES = (CatalogError, RuntimeError, ValueError) -_TABS = ("Discover", "Mods", "Collections", "Installed") +_TABS = ("Discover", "Mods", "Collections", "Favourites", "Installed") def _message(exc: Exception) -> str: @@ -121,6 +121,8 @@ def __init__( 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()) @@ -134,6 +136,7 @@ def teardown(self) -> None: 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 @@ -161,6 +164,9 @@ def _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) and item.id in self._favourites: + row["favourite"] = True + self._favourites.remember(item) if row["state"] != "installed": self._mark_progress(item, row) return row @@ -357,6 +363,10 @@ async def load(self, *, append: bool = False) -> None: 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) @@ -371,6 +381,14 @@ def apply(result: Discover) -> 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 @@ -555,7 +573,9 @@ async def back(self) -> None: self._clear_detail() self._error, self._busy = "", False self.changed.emit() - if self._tab == "Installed" and self._installed_dirty: + if self._tab == "Favourites": + self._show_favourites() + elif self._tab == "Installed" and self._installed_dirty: await self.load() @asyncSlot(str, str) @@ -653,6 +673,12 @@ 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: @@ -703,6 +729,13 @@ 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: diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index e6905df..232bae2 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -757,6 +757,44 @@ async def test_routes_open_tabs_and_items_like_links( 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: 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" + ) From 1b7e010ce4d182b8c274d805bced70b3cf00f769 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 17:45:06 +0300 Subject: [PATCH 64/69] fix(settings): unload QML after the click handler that closes the dialog returns --- src/pxmodrim/ui/panels/settings_panel.py | 5 ++- tests/test_ui/test_settings_panel.py | 52 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 tests/test_ui/test_settings_panel.py diff --git a/src/pxmodrim/ui/panels/settings_panel.py b/src/pxmodrim/ui/panels/settings_panel.py index 8ab7f57..35c58a8 100644 --- a/src/pxmodrim/ui/panels/settings_panel.py +++ b/src/pxmodrim/ui/panels/settings_panel.py @@ -228,8 +228,9 @@ def __init__( 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. - self.finished.connect(self._unload) + # 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()) 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() From a2902e412ce7949d1707a857976e738ad952771c Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 17:50:27 +0300 Subject: [PATCH 65/69] fix(workshop): collections keep their declared game version; unknown member versions don't conflict --- cf-workers/README.md | 5 +- cf-workers/catalog.ts | 26 ++++----- .../0007_collection_compatibility.sql | 4 ++ cf-workers/tests/catalog.test.ts | 54 ++++++++++++++++--- cf-workers/types.ts | 2 +- src/pxmodrim/ui/plugins/workshop/models.py | 22 ++++---- tests/test_ui/test_workshop_models.py | 37 +++++++++++++ 7 files changed, 118 insertions(+), 32 deletions(-) create mode 100644 cf-workers/migrations/0007_collection_compatibility.sql diff --git a/cf-workers/README.md b/cf-workers/README.md index 4bbe3f4..9b22734 100644 --- a/cf-workers/README.md +++ b/cf-workers/README.md @@ -107,7 +107,7 @@ Batch request: `{ "ids": ["2009463077", "818773962"] }`, at most 100 IDs. Metada 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. 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 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. @@ -170,7 +170,8 @@ JSON requests require `application/json` and are bounded to 64 KiB, including st - `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 versions every member supports). 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. +- `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. The catalog cache is separate from `items`: the legacy resolver intentionally strips nested collection children in some paths; catalog resolution retains them. diff --git a/cf-workers/catalog.ts b/cf-workers/catalog.ts index e815def..5aaa683 100644 --- a/cf-workers/catalog.ts +++ b/cf-workers/catalog.ts @@ -154,7 +154,7 @@ async function collectionPage(env: Env, ctx: ExecutionContext, url: URL, query: 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 } +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 { @@ -163,20 +163,20 @@ async function attachCollectionSummaries(db: D1Database, collections: CatalogCol 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 FROM collection_summaries WHERE computed_at > ? AND id IN (${chunk.map(() => '?').join(',')})`, + `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, total_size: null, no_common_version: false }; - const versions = JSON.parse(summary.supported_versions) as string[]; - return { ...collection, total_size: summary.total_size, supported_versions: versions, no_common_version: versions.length === 0 }; + 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. The size stays null if any member size is unknown; -// the versions are those every member supports. +// 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) { @@ -188,13 +188,15 @@ async function recordCollectionSummary(db: D1Database, collection: CatalogCollec 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 versions = mods.length - ? mods.map(mod => mod.supported_versions).reduce((common, next) => common.filter(version => next.includes(version))) + 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, computed_at) VALUES (?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET total_size = excluded.total_size, supported_versions = excluded.supported_versions, computed_at = excluded.computed_at`, - ).bind(collection.id, total === null ? null : total.toString(), JSON.stringify(versions), Date.now()).run(); + `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 { 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/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index 8d5da1a..1bac768 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -91,7 +91,7 @@ function statements(sql: string): string[] { } 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']) { + 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))); } @@ -293,13 +293,14 @@ test('a transient Steam failure is retried instead of failing the browse request assert.equal(flakyCalls, 2); }); -test('idempotent migrations and the existing /deps response preserve populated dependency data', async () => { +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', ''), ]); - await migrate(db); + 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); @@ -672,8 +673,8 @@ test('collections whose total exceeds a safe integer have no stored size', async } }); -test('a collection supports only the game versions every member supports', async () => { - await createPick('versions', ['10', '14']); +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, []); @@ -684,6 +685,46 @@ test('a collection supports only the game versions every member supports', async } }); +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)'), @@ -692,6 +733,7 @@ test('migration 0006 removes the objects of the earlier collection_sizes migrati ]); 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()); - await migrate(db); + 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/types.ts b/cf-workers/types.ts index ece9452..f1a7a05 100644 --- a/cf-workers/types.ts +++ b/cf-workers/types.ts @@ -99,7 +99,7 @@ export interface CatalogCollection { member_previews: string[]; /** Total download size in bytes; null until every member is known. */ total_size: string | null; - /** True when every member is known and no game version is supported by all of them. */ + /** True when declared member versions conflict and the collection declares no supported version itself. */ no_common_version: boolean; } diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/src/pxmodrim/ui/plugins/workshop/models.py index f1aeda2..dd094dd 100644 --- a/src/pxmodrim/ui/plugins/workshop/models.py +++ b/src/pxmodrim/ui/plugins/workshop/models.py @@ -49,7 +49,7 @@ def summarize(text: str, description_format: str = "bbcode") -> str: def _compat_label( - supported: list[str], game_version: str, incompatible: bool, no_common: bool + supported: set[str], game_version: str, incompatible: bool, no_common: bool ) -> str: if not game_version: return "" @@ -69,11 +69,12 @@ def item_row( *, detail: bool = False, ) -> dict[str, Any]: - incompatible = bool( - game_version - and item.supported_versions - and game_version not in item.supported_versions - ) + 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): @@ -101,8 +102,9 @@ def item_row( member_count = item.member_count if item.total_size is not None: file_size = f"{int(item.total_size) / 1048576:.1f} MB" - versions = set(item.supported_versions) - no_common = isinstance(item, CatalogCollection) and item.no_common_version + no_common = ( + isinstance(item, CatalogCollection) and item.no_common_version and not versions + ) incompatible = incompatible or no_common return { "itemId": item.id, @@ -150,9 +152,7 @@ def item_row( "votesUp": votes_up, "votesDown": votes_down, "summary": summarize(item.description, item.description_format), - "compatLabel": _compat_label( - item.supported_versions, game_version, incompatible, no_common - ), + "compatLabel": _compat_label(versions, game_version, incompatible, no_common), } diff --git a/tests/test_ui/test_workshop_models.py b/tests/test_ui/test_workshop_models.py index 1fdc32c..1a6592d 100644 --- a/tests/test_ui/test_workshop_models.py +++ b/tests/test_ui/test_workshop_models.py @@ -284,3 +284,40 @@ def test_collection_without_a_common_version_is_incompatible_not_unstated() -> N ) 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" From 023fdc868dded8df104ea3c80919db53f776bec5 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 17:50:27 +0300 Subject: [PATCH 66/69] feat(workshop): installed state on collection banners; unboxed detail with description first and jump links --- CHANGELOG.md | 4 + .../ui/plugins/workshop/CollectionBanner.qml | 9 + src/pxmodrim/ui/plugins/workshop/Workshop.qml | 106 ++++--- src/pxmodrim/ui/plugins/workshop/view.py | 42 ++- tests/test_ui/test_workshop_view.py | 274 ++++++++++++++++++ 5 files changed, 381 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d9c9c..2d3ee21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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 - 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. diff --git a/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml b/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml index 246fa3a..bdaab60 100644 --- a/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml +++ b/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml @@ -20,6 +20,7 @@ Rectangle { 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 @@ -105,7 +106,15 @@ Rectangle { 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 diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/pxmodrim/ui/plugins/workshop/Workshop.qml index 00214e0..8528bc8 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/pxmodrim/ui/plugins/workshop/Workshop.qml @@ -340,6 +340,11 @@ Rectangle { 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 { @@ -413,9 +418,13 @@ Rectangle { } Heading { Layout.fillWidth: true; text: root.detail.title || ""; font.pixelSize: 26; wrapMode: Text.Wrap } Copy { text: "by " + (root.detail.author || "") } - Copy { Layout.fillWidth: true; text: "Supports " + (root.detail.versions || "") } - Copy { Layout.fillWidth: true; visible: !!root.detail.tags; text: root.detail.tags || "" } - Copy { Layout.fillWidth: true; visible: !!root.detail.incompatible; text: "Does not list your game version. Downloading does not make it compatible."; color: Theme.warning } + 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 @@ -496,59 +505,70 @@ Rectangle { columns: pack ? 1 : 2 columnSpacing: 16 rowSpacing: 16 - Rectangle { - Layout.row: detailBody.pack ? 1 : 0 + ColumnLayout { + id: descColumn + objectName: "detailDescription" + Layout.row: 0 Layout.column: 0 Layout.fillWidth: true Layout.preferredWidth: 3 Layout.alignment: Qt.AlignTop - implicitHeight: descColumn.implicitHeight + 32 - color: Theme.elevate2 - border.color: Theme.border - radius: Theme.radiusMd - ColumnLayout { - id: descColumn - anchors.fill: parent - anchors.margins: 16 - spacing: 10 + spacing: 10 + readonly property bool tall: descriptionText.implicitHeight > detailScroll.availableHeight + RowLayout { + Layout.fillWidth: true Heading { text: "Description" } - ModDescription { - Layout.fillWidth: true - description: root.detail.description || "" - onLinkActivated: link => workshopPanel.openLink(link) + 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) + } } - Rectangle { - Layout.row: 0 + 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 - implicitHeight: membersColumn.implicitHeight + 32 - color: Theme.elevate2 - border.color: Theme.border - radius: Theme.radiusMd - ColumnLayout { - id: membersColumn - anchors.fill: parent - anchors.margins: 16 - spacing: 8 + spacing: 8 + RowLayout { + id: membersHeading + objectName: "membersHeading" + Layout.fillWidth: true Heading { text: (detailBody.pack ? "Collection members · " : "Required mods · ") + catalogMembers.count } - 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) - 32) / 340)) : 1 - columnSpacing: 8 - rowSpacing: 8 - Repeater { - model: catalogMembers - ModRow { - Layout.fillWidth: true - Layout.preferredWidth: 1 - Layout.preferredHeight: 54 - } + 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 } } } diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py index d6d2892..03dfad8 100644 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ b/src/pxmodrim/ui/plugins/workshop/view.py @@ -98,9 +98,10 @@ def __init__( 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_active_ids() + self._refresh_mod_ids() self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] self._qml.setObjectName("workshopView") self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) @@ -146,8 +147,13 @@ def _game_version(self) -> str: # -- rows ------------------------------------------------------------- - def _refresh_active_ids(self) -> None: + 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 @@ -164,10 +170,20 @@ def _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) and item.id in self._favourites: - row["favourite"] = True - self._favourites.remember(item) - if row["state"] != "installed": + 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 @@ -180,7 +196,9 @@ def _mark_progress( if isinstance(item, CatalogMod): ids: frozenset[str] = frozenset((item.id,)) else: - ids = self._collection_mods.get(item.id, frozenset()) + 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): @@ -189,6 +207,7 @@ def _mark_progress( 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]: @@ -219,8 +238,9 @@ def _detail_row(self, detail: Detail) -> dict[str, Any]: copies = self._pack_copies() if copies is not None: active = set(self._ctx.active_uuids) - row["state"] = "installed" - row["actionLabel"] = "Installed" + 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 @@ -721,7 +741,7 @@ async def _download(self, ids: list[str], collections: list[str]) -> None: # -- catalog events --------------------------------------------------- def _on_active_state_changed(self, _uuids: tuple[str, ...]) -> None: - self._refresh_active_ids() + self._refresh_mod_ids() self._refresh_rows() self.changed.emit() @@ -753,7 +773,7 @@ async def _on_installed_changed(self, _value: None) -> None: if self._torn_down: return self._installed_dirty = True - self._refresh_active_ids() + self._refresh_mod_ids() self._recount() self._refresh_rows() self.changed.emit() diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py index 232bae2..bdbed93 100644 --- a/tests/test_ui/test_workshop_view.py +++ b/tests/test_ui/test_workshop_view.py @@ -335,6 +335,144 @@ async def test_collection_detail_uses_member_images_when_preview_metadata_is_abs 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, @@ -411,6 +549,7 @@ async def test_settings_saves_and_applies_catalog_url( 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 == [] @@ -1167,3 +1306,138 @@ def tooltip() -> QObject | None: 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] From 9e5522ffba544c403596f78d017562c43146aa1c Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 18:17:45 +0300 Subject: [PATCH 67/69] fix(catalog): return stored featured ranks for picked collections --- CHANGELOG.md | 1 + cf-workers/README.md | 2 ++ cf-workers/picks.ts | 1 + cf-workers/tests/catalog.test.ts | 49 ++++++++++++++++++++++++++++++++ cf-workers/types.ts | 2 ++ 5 files changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d3ee21..35d3fdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. diff --git a/cf-workers/README.md b/cf-workers/README.md index 9b22734..9b503a9 100644 --- a/cf-workers/README.md +++ b/cf-workers/README.md @@ -155,6 +155,8 @@ The client downloads `mod_ids` and decides how to present an incomplete plan. Re 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`. + 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. ### Errors and request limits diff --git a/cf-workers/picks.ts b/cf-workers/picks.ts index c90031e..84b9351 100644 --- a/cf-workers/picks.ts +++ b/cf-workers/picks.ts @@ -141,6 +141,7 @@ function toCollection(row: PickRow): CatalogCollection { 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, diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index 1bac768..4644839 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -178,6 +178,7 @@ test('Steam collection details retain ordered members, including nested collecti 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'); @@ -196,6 +197,7 @@ test('picked collection mutations require authentication and validate members be 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); @@ -214,6 +216,49 @@ test('picked search treats wildcard text literally, combines tag/version filters 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 }); @@ -226,6 +271,10 @@ test('mixed collection pagination retains both sources without duplicates or que 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); }); diff --git a/cf-workers/types.ts b/cf-workers/types.ts index f1a7a05..a0af2c2 100644 --- a/cf-workers/types.ts +++ b/cf-workers/types.ts @@ -92,6 +92,8 @@ export interface CatalogCollection { 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[]; From f4341506bc3ac7d9a6b88ab442458a7cb11ba0fb Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 19:04:43 +0300 Subject: [PATCH 68/69] chore(release): prepare v0.3.0 --- CHANGELOG.md | 2 ++ packaging/linux/pxmodrim.spec | 2 +- packaging/windows/installer.nsi | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d3fdb..32baae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ 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. 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/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" }, From eb64d8a56731d61474a0266c783bb0bd5e518691 Mon Sep 17 00:00:00 2001 From: PyXiion Date: Tue, 6 Oct 2026 19:04:43 +0300 Subject: [PATCH 69/69] fix(ci): exclude binary artwork from Codacy SARIF inputs --- .codacy.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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