diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a02f39d..2a1f012 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -5,18 +5,19 @@ {"id":"openforge_catalog-5gb","title":"Frontend: Admin camera position controls UI","description":"Build admin interface to preview and set default camera angles for models. Shows live preview of each angle, allows selecting which is default. Integrates with admin set camera position API. Consider batch operations for setting defaults across similar models.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-21T09:58:45.023938263-07:00","updated_at":"2025-12-21T09:58:45.023938263-07:00","dependencies":[{"issue_id":"openforge_catalog-5gb","depends_on_id":"openforge_catalog-bll","type":"parent-child","created_at":"2025-12-21T09:59:19.862145404-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-5gb","depends_on_id":"openforge_catalog-hqe","type":"blocks","created_at":"2025-12-21T09:59:36.561390068-07:00","created_by":"daemon"}]} {"id":"openforge_catalog-5na","title":"Phased rollout: Test subset before bulk backfill","description":"Implement safe migration strategy: (1) Generate sprite sheets for small test set (~10-20 diverse models). (2) Deploy frontend with fallback to legacy single thumbnails. (3) Validate with users - test interactive controls, image quality, performance. (4) Iterate on camera positions/layout based on feedback. (5) Only after validation, run bulk backfill for all 8000+ STL files. Include ability to pause/resume bulk processing. Track which models have sprite sheets vs legacy thumbnails.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-21T10:13:57.517770238-07:00","updated_at":"2025-12-21T10:13:57.517770238-07:00","dependencies":[{"issue_id":"openforge_catalog-5na","depends_on_id":"openforge_catalog-bll","type":"parent-child","created_at":"2025-12-21T10:14:18.760726353-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-5na","depends_on_id":"openforge_catalog-4ji","type":"blocks","created_at":"2025-12-21T10:14:23.673079682-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-5na","depends_on_id":"openforge_catalog-3cx","type":"blocks","created_at":"2025-12-21T10:14:24.071569003-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-5na","depends_on_id":"openforge_catalog-dd4","type":"blocks","created_at":"2025-12-21T10:14:24.494018624-07:00","created_by":"daemon"}]} {"id":"openforge_catalog-5tg","title":"Backend API: Get thumbnail variants endpoint","description":"Create Flask endpoint to return sprite sheet info for a model. Returns: (1) Sprite sheet URL, (2) Grid layout (rows, cols, tile dimensions), (3) Angle metadata (name, position in grid, camera coords), (4) Default angle index. Frontend uses this to display correct region from sprite sheet. Handles models with legacy single thumbnail gracefully.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-21T09:58:32.287223899-07:00","updated_at":"2025-12-21T14:00:25.231595972-07:00","closed_at":"2025-12-21T14:00:25.231595972-07:00","close_reason":"Completed: Created GET /api/blueprints/\u003cblueprint_id\u003e/thumbnail-variants endpoint. Returns sprite sheet URL, grid layout (rows/cols/tile_size), angle metadata with camera positions, and default angle index. Gracefully handles legacy single thumbnails. All 319 tests passing.","dependencies":[{"issue_id":"openforge_catalog-5tg","depends_on_id":"openforge_catalog-bll","type":"parent-child","created_at":"2025-12-21T09:59:18.52350897-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-5tg","depends_on_id":"openforge_catalog-ubp","type":"blocks","created_at":"2025-12-21T09:59:35.26824895-07:00","created_by":"daemon"}]} -{"id":"openforge_catalog-750","title":"Postgres migration: thingiverse sync-state tables","description":"New schema version adding: thingiverse_thing (blueprint_id \u003c-\u003e thing_id, status, published_at, last_synced_at) and thingiverse_file (thing ref, catalog file ref + local hash, remote file id + remote hash, last_synced_at) — the diff ledger for the sync engine. Follow existing openforge/db/schema/version_NN.py migration pattern. If the HAR ticket proves the remote hash is SHA-256 (catalog stores MD5), include storage for the second hash algorithm.","notes":"SCOPE NOTES: (1) thingiverse_file must carry a file-type/role discriminator (model/image/zip/other) since things contain mixed file types. (2) This runs against the LOCAL db; prod gets a subset via the fixture path (see openforge_catalog-8yb) — keep the mapping (blueprint_id, thing_id, public_url) cleanly separable from local-only operational state (hash ledger, sync timestamps).","status":"open","priority":2,"issue_type":"task","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:42:25.104764326-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T11:55:49.425508213-06:00","dependencies":[{"issue_id":"openforge_catalog-750","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:42:52.476138903-06:00","created_by":"Devon Jones"}]} +{"id":"openforge_catalog-750","title":"Postgres migration: thingiverse sync-state tables","description":"New schema version adding: thingiverse_thing (blueprint_id \u003c-\u003e thing_id, status, published_at, last_synced_at) and thingiverse_file (thing ref, catalog file ref + local hash, remote file id + remote hash, last_synced_at) — the diff ledger for the sync engine. Follow existing openforge/db/schema/version_NN.py migration pattern. If the HAR ticket proves the remote hash is SHA-256 (catalog stores MD5), include storage for the second hash algorithm.","notes":"SCOPE NOTES: (1) thingiverse_file must carry a file-type/role discriminator (model/image/zip/other) since things contain mixed file types. (2) This runs against the LOCAL db; prod gets a subset via the fixture path (see openforge_catalog-8yb) — keep the mapping (blueprint_id, thing_id, public_url) cleanly separable from local-only operational state (hash ledger, sync timestamps).","status":"in_progress","priority":2,"issue_type":"task","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:42:25.104764326-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T14:40:56.467812707-06:00","dependencies":[{"issue_id":"openforge_catalog-750","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:42:52.476138903-06:00","created_by":"Devon Jones"}]} {"id":"openforge_catalog-7dg","title":"Auth/token manager for Thingiverse v2 (login + refresh)","description":"Python module: interactive 'login' does POST /v2/auth/login {usernameOrEmail, password} -\u003e AuthTokensResponse (handle 202 2FA via /v2/auth/2fa/login), persists refresh token securely (env file/keyring, never in repo); runtime path auto-refreshes access JWT via POST /v2/auth/refresh. Password is never stored. Buildable and testable today against the live API — verify a Devon-scoped JWT via GET /v2/users/me. Note: THINGIVERSE_APP_TOKEN in ~/.profile.d/099.api_keys.sh is v1-only and scoped to the wrong account (Joaquin Munguia, id 5577639) — do not use.","status":"closed","priority":1,"issue_type":"feature","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:42:24.708129858-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T14:39:30.409750275-06:00","closed_at":"2026-07-06T14:39:30.409750275-06:00","close_reason":"Merged in PR #224 to test. TokenManager (login/2FA/refresh/atomic 0600 persistence/server-side logout) + tv_auth CLI + 53 tests at 100% coverage. Live-verified: devonjones (id 47139) login, status, and refresh rotation against the real v2 API. Unblocks gn2 together with hnr (HAR capture).","dependencies":[{"issue_id":"openforge_catalog-7dg","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:42:52.071225891-06:00","created_by":"Devon Jones"}]} -{"id":"openforge_catalog-8yb","title":"Propagate thingiverse sync data to AWS prod (fixture + API upsert path)","description":"The tool runs against the LOCAL Postgres, but prod (AWS Lambda + serverless Postgres) is populated by replaying fixture JSON through the Flask API (bin/upload_fixture with OPENFORGE_BASE_URL, same pattern as blueprint fixtures). Thingiverse data (blueprint_id \u003c-\u003e thing_id mapping, public thing URLs) must ride that path so the app can render Thingiverse links in prod and support deeper integration later. Deliverables: fixture format for thingiverse mappings (exported from the local sync tables), an authenticated API upsert endpoint in the Flask app, and upload_fixture support. Note: prod likely only needs the mapping/links, not the per-file hash ledger — that is local operational state; decide the split when designing the fixture.","status":"open","priority":2,"issue_type":"feature","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:55:33.474857151-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T11:55:33.474857151-06:00","dependencies":[{"issue_id":"openforge_catalog-8yb","depends_on_id":"openforge_catalog-750","type":"blocks","created_at":"2026-07-06T11:55:47.512062985-06:00","created_by":"Devon Jones"},{"issue_id":"openforge_catalog-8yb","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:55:47.882392741-06:00","created_by":"Devon Jones"}]} +{"id":"openforge_catalog-8yb","title":"Propagate thingiverse sync data to AWS prod (fixture + API upsert path)","description":"The tool runs against the LOCAL Postgres, but prod (AWS Lambda + serverless Postgres) is populated by replaying fixture JSON through the Flask API (bin/upload_fixture with OPENFORGE_BASE_URL, same pattern as blueprint fixtures). Thingiverse data (blueprint_id \u003c-\u003e thing_id mapping, public thing URLs) must ride that path so the app can render Thingiverse links in prod and support deeper integration later. Deliverables: fixture format for thingiverse mappings (exported from the local sync tables), an authenticated API upsert endpoint in the Flask app, and upload_fixture support. Note: prod likely only needs the mapping/links, not the per-file hash ledger — that is local operational state; decide the split when designing the fixture.","notes":"DESIGN NOTE from PR #225 review: the schema allows one blueprint to appear in MULTIPLE things (partial unique index is per-thing only, and test_same_source_allowed_in_different_things pins that behavior). The prod fixture mapping is therefore 1:N — (blueprint_id, thing_id, public_url) derived per thingiverse_files row, not a unique-per-blueprint map. The app's Thingiverse links UI must handle a blueprint linking to several things.","status":"open","priority":2,"issue_type":"feature","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:55:33.474857151-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T14:51:24.318740515-06:00","dependencies":[{"issue_id":"openforge_catalog-8yb","depends_on_id":"openforge_catalog-750","type":"blocks","created_at":"2026-07-06T11:55:47.512062985-06:00","created_by":"Devon Jones"},{"issue_id":"openforge_catalog-8yb","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:55:47.882392741-06:00","created_by":"Devon Jones"}]} {"id":"openforge_catalog-ad5","title":"Sync engine: diff local files vs remote hashes, reconcile","description":"For each managed thing: diff manifest-directed local files against remote files using hash comparison (via thingiverse_file ledger) -\u003e upload new, replace changed, delete removed, patch drifted metadata. Idempotent; dry-run mode showing planned actions before mutating. Depends on hash-algorithm verification: if remote is SHA-256, compute+store SHA-256 alongside catalog MD5s.","notes":"PRIOR ART (old v1 tool): tv_update_description did bulk find/replace of boilerplate across all things matching a name filter, with --dry-run (desc_update.json shows real mass migrations happened). tv_tag_dl mirrored all things to local JSON with 3s sleeps (self-rate-limiting; v1 had practical rate limits). Lessons: (1) metadata drift detection should cover the shared-boilerplate case so a boilerplate change re-syncs every managed thing's description; (2) throttle API calls; (3) dry-run is a first-class requirement, already in scope.","status":"open","priority":1,"issue_type":"feature","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:42:26.401028262-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T12:00:34.191543122-06:00","dependencies":[{"issue_id":"openforge_catalog-ad5","depends_on_id":"openforge_catalog-gn2","type":"blocks","created_at":"2026-07-06T11:42:43.166238543-06:00","created_by":"Devon Jones"},{"issue_id":"openforge_catalog-ad5","depends_on_id":"openforge_catalog-750","type":"blocks","created_at":"2026-07-06T11:42:43.606420026-06:00","created_by":"Devon Jones"},{"issue_id":"openforge_catalog-ad5","depends_on_id":"openforge_catalog-d7a","type":"blocks","created_at":"2026-07-06T11:42:44.00974978-06:00","created_by":"Devon Jones"},{"issue_id":"openforge_catalog-ad5","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:42:53.860069002-06:00","created_by":"Devon Jones"}]} {"id":"openforge_catalog-bll","title":"Multi-Angle Thumbnail Viewing System","description":"Enable users to view STL models from 10 isometric camera angles with interactive controls. Admins can set default viewing angles per model. System generates 10 isometric-perspective angles (6 primary directions + 4 diagonal corners) during file scan using stl-thumb --cam-pos, combined into single sprite sheet PNG. Frontend has drag-to-rotate and keyboard navigation. All angles maintain 3/4 view for depth, not flat orthographic.","status":"open","priority":1,"issue_type":"epic","created_at":"2025-12-21T09:57:50.607001989-07:00","updated_at":"2025-12-21T10:09:20.457266019-07:00"} -{"id":"openforge_catalog-d7a","title":"Manifest schema + metadata assembler","description":"Define per-thing manifest format (YAML): explicit file list for the thing plus field overrides. Assembler builds the thing payload with defaults pulled from catalog Postgres blueprint records (name, description, tag hierarchy -\u003e Thingiverse tags, category, license, images) and applies manifest overrides on top. Zero-manual-entry defaults, curated where it matters. Payload field names/shape finalized against the HAR contract.","notes":"PRIOR ART (old v1 tool): bin/new.*.json templates were per-product-line defaults (openforge2, openlock, construction_kit, miniature, tools): license, category, tags, boilerplate description. Key design lesson: descriptions = per-thing content + SHARED BOILERPLATE FOOTER (Patreon/tutorials/catalog links) that changes over time. Model boilerplate as a shared block composed at assembly time (stored once, referenced by manifests), not baked into each manifest — enables bulk boilerplate updates via re-sync. Old templates: ~/Dropbox/projects/Hardware/objects/OpenForgeDevelopment/openforge/bin/new.*.json","status":"open","priority":2,"issue_type":"feature","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:42:25.496132115-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T12:00:33.768875881-06:00","dependencies":[{"issue_id":"openforge_catalog-d7a","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:42:52.892986073-06:00","created_by":"Devon Jones"}]} +{"id":"openforge_catalog-d7a","title":"Manifest schema + metadata assembler","description":"Define per-thing manifest format (YAML): explicit file list for the thing plus field overrides. Assembler builds the thing payload with defaults pulled from catalog Postgres blueprint records (name, description, tag hierarchy -\u003e Thingiverse tags, category, license, images) and applies manifest overrides on top. Zero-manual-entry defaults, curated where it matters. Payload field names/shape finalized against the HAR contract.","notes":"PRIOR ART (old v1 tool): bin/new.*.json templates were per-product-line defaults (openforge2, openlock, construction_kit, miniature, tools): license, category, tags, boilerplate description. Key design lesson: descriptions = per-thing content + SHARED BOILERPLATE FOOTER (Patreon/tutorials/catalog links) that changes over time. Model boilerplate as a shared block composed at assembly time (stored once, referenced by manifests), not baked into each manifest — enables bulk boilerplate updates via re-sync. Old templates: ~/Dropbox/projects/Hardware/objects/OpenForgeDevelopment/openforge/bin/new.*.json","status":"in_progress","priority":2,"issue_type":"feature","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:42:25.496132115-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T15:00:55.159469636-06:00","dependencies":[{"issue_id":"openforge_catalog-d7a","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:42:52.892986073-06:00","created_by":"Devon Jones"}]} {"id":"openforge_catalog-dd4","title":"Integration: Wire scanner pipeline for multi-angle generation","description":"Wire multi-angle sprite sheet generation into file scanning pipeline, BUT ONLY AFTER phased rollout validation. Scanner should: (1) Support both legacy single thumbnails AND new sprite sheets during transition. (2) Have feature flag to enable/disable sprite generation. (3) Track which models have sprites vs legacy. (4) Handle failures gracefully - don't block scan if sprite generation fails. (5) Be pausable/resumable for bulk backfill. Only enable bulk processing after small test set is validated with users.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-21T09:58:49.223198843-07:00","updated_at":"2025-12-21T14:35:20.810908751-07:00","closed_at":"2025-12-21T14:35:20.810908751-07:00","close_reason":"Completed: Integrated sprite sheet generation into scanner pipeline with feature flag (ENABLE_SPRITE_THUMBNAILS). Created create_and_upload_thumbnail() helper that handles both sprite sheets and legacy thumbnails. Updated scanner.py and incremental.py to use new function. Gracefully handles failures without blocking scans. Supports transition period with both formats. All 319 tests passing.","dependencies":[{"issue_id":"openforge_catalog-dd4","depends_on_id":"openforge_catalog-bll","type":"parent-child","created_at":"2025-12-21T09:59:20.290625315-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-dd4","depends_on_id":"openforge_catalog-ubp","type":"blocks","created_at":"2025-12-21T09:59:37.011721674-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-dd4","depends_on_id":"openforge_catalog-5tg","type":"blocks","created_at":"2025-12-21T09:59:37.450897079-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-dd4","depends_on_id":"openforge_catalog-hqe","type":"blocks","created_at":"2025-12-21T09:59:37.840969584-07:00","created_by":"daemon"}]} {"id":"openforge_catalog-gn2","title":"Thingiverse v2 API client (write endpoints from HAR contract)","description":"Python client wrapping the v2 API using the auth/token manager: create thing, patch metadata, upload model file, replace file, delete file, list files w/ hashes, publish. Read endpoints (things/{id}/complete etc.) from the public spec (docs/thingiverse-api-v2.md); write endpoints implemented per the HAR-derived contract. Stub the write calls if started before the HAR ticket completes.","status":"open","priority":1,"issue_type":"feature","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:42:25.975705436-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T11:42:25.975705436-06:00","dependencies":[{"issue_id":"openforge_catalog-gn2","depends_on_id":"openforge_catalog-hnr","type":"blocks","created_at":"2026-07-06T11:42:42.234078586-06:00","created_by":"Devon Jones"},{"issue_id":"openforge_catalog-gn2","depends_on_id":"openforge_catalog-7dg","type":"blocks","created_at":"2026-07-06T11:42:42.679151017-06:00","created_by":"Devon Jones"},{"issue_id":"openforge_catalog-gn2","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:42:53.406992128-06:00","created_by":"Devon Jones"}]} {"id":"openforge_catalog-hnr","title":"Capture HAR of Thingiverse SPA create/upload/edit flow and extract API contract","description":"Devon captures a HAR (Network tab, preserve log, Fetch/XHR, 'Save all as HAR with content') of a complete flow: create draft thing, set name/description/tags/category/license, upload image, upload STL, replace the STL, delete a file, publish. HAR goes to scratchpad only — contains live JWT/refresh/cookies, never commit; rotate tokens after. From the HAR, extract and document: create-thing endpoint+payload, model-file upload mechanism (direct POST vs presigned URL — matters for Lambda), file object shape incl. hash field, metadata patch, and file delete. Also verify hash algorithm: hash the exact uploaded STL locally with MD5 and SHA-256 and match against the hash Thingiverse reports (Devon guesses SHA-256). Output: docs/thingiverse-api-v2-private.md contract doc.","notes":"SCOPE ADDITION: things will contain photos, zip files, and other non-STL files. During the HAR capture, upload at least one photo, one zip, and one STL, and capture whether they all go through the same upload endpoint/mechanism or different ones (the public spec only shows an image upload route). Verify whether the hash field is reported for ALL file types or only models.","status":"open","priority":1,"issue_type":"task","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:42:24.225732535-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T11:55:48.284424468-06:00","dependencies":[{"issue_id":"openforge_catalog-hnr","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:42:51.659042706-06:00","created_by":"Devon Jones"}]} {"id":"openforge_catalog-hqe","title":"Backend API: Admin set default camera position","description":"Create Flask endpoint for admins to set/update the default camera position for individual models. Accepts model_id and camera position (x,y,z). Requires admin authentication. Triggers thumbnail regeneration if needed.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-21T09:58:36.544944445-07:00","updated_at":"2025-12-21T14:01:55.202764217-07:00","closed_at":"2025-12-21T14:01:55.202764217-07:00","close_reason":"Completed: Created PATCH /api/blueprints/\u003cblueprint_id\u003e/thumbnail-variants/default-angle endpoint. Admin-only, requires authentication and CSRF protection. Validates angle index (0-9), updates sprite_metadata.default_angle in database. Returns error for legacy single thumbnails. All 319 tests passing.","dependencies":[{"issue_id":"openforge_catalog-hqe","depends_on_id":"openforge_catalog-bll","type":"parent-child","created_at":"2025-12-21T09:59:18.965408581-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-hqe","depends_on_id":"openforge_catalog-jnl","type":"blocks","created_at":"2025-12-21T09:59:35.746577657-07:00","created_by":"daemon"}]} {"id":"openforge_catalog-hr4","title":"Admin UI: Manually trigger sprite generation per model","description":"Add admin interface to manually trigger sprite sheet generation for individual models. Useful for: (1) Testing different camera positions on specific models. (2) Regenerating sprites after adjusting defaults. (3) Fixing failed generations. Shows progress, preview of result, ability to approve/reject before replacing existing thumbnail. Can mark models as 'sprite sheet validated' to track rollout progress.","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-21T10:14:01.648534686-07:00","updated_at":"2025-12-21T10:14:01.648534686-07:00","dependencies":[{"issue_id":"openforge_catalog-hr4","depends_on_id":"openforge_catalog-bll","type":"parent-child","created_at":"2025-12-21T10:14:19.184588659-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-hr4","depends_on_id":"openforge_catalog-4ji","type":"blocks","created_at":"2025-12-21T10:14:24.909562746-07:00","created_by":"daemon"}]} {"id":"openforge_catalog-jnl","title":"Database schema for camera positions and thumbnail variants","description":"Add database schema to store: (1) Sprite sheet metadata - grid dimensions (rows, cols), tile size, angle positions in grid. (2) Camera position per angle (x,y,z coords). (3) Default angle index per model. (4) Sprite sheet file path/URL in R2. Consider if this extends existing thumbnail tables or needs new camera_angles table. Must support models with legacy single thumbnail during migration.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-21T09:58:19.570114017-07:00","updated_at":"2025-12-21T13:07:55.263321459-07:00","closed_at":"2025-12-21T13:07:55.263321459-07:00","close_reason":"Completed: Added sprite_metadata JSONB column to images table with GIN index. Migration version 16 created and tested. All tests passing.","dependencies":[{"issue_id":"openforge_catalog-jnl","depends_on_id":"openforge_catalog-bll","type":"parent-child","created_at":"2025-12-21T09:59:17.171165836-07:00","created_by":"daemon"}]} +{"id":"openforge_catalog-li2","title":"setup.py packaging: declare openforge subpackages and thingiverse template data","description":"From PR #226 review (importlib-resources-reviewer, P3): setup.py's packages=['openforge'] never lists subpackages (no find_packages()) and package_data only declares openforge/db/fixtures and openforge/openapi. openforge/thingiverse/templates/*.yaml (and the thingiverse subpackage generally) ship today only because Dockerfile.api.deploy raw-copies the source tree before setup.py install. If deploy ever moves to a built wheel/sdist, package data would 404 in production only. Fix: find_packages() + package_data entry for openforge/thingiverse/templates/*.yaml. Pre-existing gap, not introduced by #226. Original comment: gh api /repos/MasterworkTools/openforge-catalog/pulls/226/comments","status":"open","priority":3,"issue_type":"task","owner":"devon.jones@gmail.com","created_at":"2026-07-06T15:13:07.131836689-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T15:13:07.131836689-06:00"} {"id":"openforge_catalog-lpe","title":"CLI commands: thingiverse create/sync/status","description":"CLI surface in openforge/: 'thingiverse login' (auth manager), 'thingiverse create \u003cmanifest\u003e' (create + populate + upload files + record in sync tables), 'thingiverse sync [thing|--all]' (run sync engine), 'thingiverse status' (show managed things, pending diffs, last sync). Follow existing openforge CLI patterns.","status":"open","priority":2,"issue_type":"feature","owner":"devon.jones@gmail.com","created_at":"2026-07-06T11:42:26.871182106-06:00","created_by":"Devon Jones","updated_at":"2026-07-06T11:42:26.871182106-06:00","dependencies":[{"issue_id":"openforge_catalog-lpe","depends_on_id":"openforge_catalog-ad5","type":"blocks","created_at":"2026-07-06T11:42:44.422774979-06:00","created_by":"Devon Jones"},{"issue_id":"openforge_catalog-lpe","depends_on_id":"openforge_catalog-d7a","type":"blocks","created_at":"2026-07-06T11:42:44.898169896-06:00","created_by":"Devon Jones"},{"issue_id":"openforge_catalog-lpe","depends_on_id":"openforge_catalog-4kx","type":"parent-child","created_at":"2026-07-06T11:42:54.325118338-06:00","created_by":"Devon Jones"}]} {"id":"openforge_catalog-qjn","title":"Testing and documentation for multi-angle thumbnails","description":"Write tests: backend API tests, thumbnail generation tests, frontend component tests. Document: camera angle coordinate system, API endpoints, how to add new angles, admin workflow for setting defaults. Migration guide for existing thumbnails.","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-21T09:58:53.460986877-07:00","updated_at":"2025-12-21T09:58:53.460986877-07:00","dependencies":[{"issue_id":"openforge_catalog-qjn","depends_on_id":"openforge_catalog-bll","type":"parent-child","created_at":"2025-12-21T09:59:20.738830826-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-qjn","depends_on_id":"openforge_catalog-dd4","type":"blocks","created_at":"2025-12-21T09:59:38.217939989-07:00","created_by":"daemon"}]} {"id":"openforge_catalog-szh","title":"Design R2 storage structure for multi-angle thumbnails","description":"Define R2 storage using SPRITE SHEET approach: Generate single PNG containing all 10 isometric-perspective angles in a grid layout (5x2 or 2x5 grid). Angles: 6 primary isometric views (front-iso, back-iso, left-iso, right-iso, top-iso, bottom-iso) + 4 diagonal corners (NE, NW, SE, SW). All maintain 3/4 perspective for depth, not flat orthographic. File naming: {model_id}_angles.png or {md5}_angles.png. Each angle occupies equal-sized tile. Store grid layout metadata in database. One file per model instead of 10 separate files.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-21T09:58:23.809557729-07:00","updated_at":"2025-12-21T13:09:23.10795275-07:00","closed_at":"2025-12-21T13:09:23.10795275-07:00","close_reason":"Completed: Created comprehensive design document (docs/sprite-sheet-storage-design.md) specifying R2 storage structure, sprite layout (2x5 grid, 512x512 tiles), 10 camera angles, path structure (sprites/{md5[:6]}/{md5}.png), and integration strategy.","dependencies":[{"issue_id":"openforge_catalog-szh","depends_on_id":"openforge_catalog-bll","type":"parent-child","created_at":"2025-12-21T09:59:17.627098048-07:00","created_by":"daemon"},{"issue_id":"openforge_catalog-szh","depends_on_id":"openforge_catalog-jnl","type":"blocks","created_at":"2025-12-21T09:59:34.44071374-07:00","created_by":"daemon"}]} diff --git a/.beads/last-touched b/.beads/last-touched index bc266d0..cc0b14f 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -openforge_catalog-7dg +openforge_catalog-li2 diff --git a/openforge/thingiverse/assembler.py b/openforge/thingiverse/assembler.py new file mode 100644 index 0000000..c66192f --- /dev/null +++ b/openforge/thingiverse/assembler.py @@ -0,0 +1,261 @@ +"""Assemble a Thingiverse thing payload from the catalog plus a manifest. + +Defaults come from the catalog DB (blueprint records selected by tag +query or explicit reference) and a named template (license, category, +base tags, description boilerplate); the manifest curates on top. + +The output is API-neutral: field names are finalized against the +HAR-derived contract (openforge_catalog-hnr) by the API client, not here. +""" + +import hashlib +import json +import logging +from importlib import resources as impresources +from pathlib import Path +from typing import Dict, List + +from psycopg import cursor +from yaml import safe_load + +import openforge.db.sql.blueprints as blueprint_sql +import openforge.db.sql.tags as tag_sql + +logger = logging.getLogger(__name__) + +# The catalog holds ~1,400 designs; any real selector returns far fewer. +# tag_search_blueprints has no unlimited mode, so use a ceiling and fail +# fast if it's ever reached (which would mean silent truncation). +SELECT_LIMIT = 10000 + + +class AssemblyError(Exception): + """The manifest references files or templates that can't be resolved.""" + + +def load_template(name: str) -> Dict: + """Load a named metadata template bundled with the package. + + Raises: + AssemblyError: If no template with that name ships in + openforge/thingiverse/templates/ + """ + resource = impresources.files("openforge.thingiverse.templates").joinpath( + f"{name}.yaml" + ) + try: + template = safe_load(resource.read_text()) + except FileNotFoundError as e: + raise AssemblyError(f"unknown template: {name!r}") from e + return template + + +def assemble_thing(curs: cursor, manifest: Dict) -> Dict: + """Build the full thing payload a create/sync run needs. + + Args: + curs: Catalog DB cursor + manifest: A validated manifest (see manifest.load_manifest) + + Returns: + { + "metadata": {name, license, category, tags, description}, + "files": { + "models": [blueprint rows w/ file_md5, file_name, ...], + "images"/"zips"/"others": [{"path": absolute path}, ...], + }, + "metadata_hash": sha256 of the canonical metadata JSON — + stored as thingiverse_things.remote_metadata_hash after a + push so metadata drift is detectable, + } + + Raises: + AssemblyError: On unresolvable templates, selectors matching + nothing, missing explicit references, or missing local files + """ + template = load_template(manifest["template"]) + metadata = _assemble_metadata(manifest, template) + files = { + "models": _resolve_models(curs, manifest), + "images": _resolve_local_files(manifest, "images"), + "zips": _resolve_local_files(manifest, "zips"), + "others": _resolve_local_files(manifest, "others"), + } + return { + "metadata": metadata, + "files": files, + "metadata_hash": metadata_hash(metadata), + } + + +def metadata_hash(metadata: Dict) -> str: + """Canonical hash of assembled metadata for drift detection.""" + canonical = json.dumps(metadata, sort_keys=True, ensure_ascii=True) + return hashlib.sha256(canonical.encode()).hexdigest() + + +def _assemble_metadata(manifest: Dict, template: Dict) -> Dict: + description = _compose_description( + manifest["description"], template.get("description_boilerplate", "") + ) + return { + "name": manifest["name"], + "license": manifest["license"] or template.get("license"), + "category": manifest["category"] or template.get("category"), + "tags": _merge_tags(template.get("tags", []), manifest["tags"]), + "description": description, + } + + +def _compose_description(per_thing: str, boilerplate: str) -> str: + """Per-thing prose first, shared boilerplate footer after. + + The boilerplate is stored once in the template so a copy change + re-syncs every managed thing's description (the old + tv_update_description bulk-edit workflow, automated). + """ + parts = [p.strip() for p in (per_thing, boilerplate) if p and p.strip()] + return "\n\n".join(parts) + + +def _merge_tags(template_tags: List[str], manifest_tags: List[str]) -> List[str]: + """Union, order-preserving, case-insensitive dedupe.""" + merged = [] + seen = set() + for tag in [*template_tags, *manifest_tags]: + key = tag.lower() + if key not in seen: + seen.add(key) + merged.append(tag) + return merged + + +# Fields every resolved model row carries, regardless of resolution path. +# The underlying accessors project different column sets (SELECT * vs +# explicit lists); this keeps the payload shape stable for the sync engine. +MODEL_FIELDS = ( + "id", + "blueprint_name", + "full_name", + "file_name", + "file_md5", + "file_size", + "storage_address", + "file_changed_at", + "file_modified_at", +) + + +def _resolve_models(curs: cursor, manifest: Dict) -> List[Dict]: + """Resolve every model entry to blueprint rows, deduped by md5.""" + resolved = [] + for entry in manifest["files"]["models"]: + if "select" in entry: + resolved.extend(_resolve_select(curs, entry["select"])) + elif "md5" in entry: + resolved.append(_resolve_md5(curs, entry["md5"])) + else: + resolved.append(_resolve_full_name(curs, entry["full_name"])) + return _dedupe_models([_project_model(row) for row in resolved]) + + +def _resolve_select(curs: cursor, select: Dict) -> List[Dict]: + """Run a selector: require = exact ALL, accept = prefix ANY, deny = exclude. + + The underlying engine ANDs multiple accept entries together (each adds + an independent join), so ANY-of-subtrees is implemented here by running + one query per accept entry and unioning the results: + (require AND accept_1) OR (require AND accept_2) == require AND (any accept). + """ + accepts = select["accept"] + accept_batches = [[a] for a in accepts] if accepts else [[]] + rows = [] + for accept_batch in accept_batches: + rows.extend(_run_tag_search(curs, accept_batch, select)) + if not rows: + raise AssemblyError(f"selector matched no models: {select}") + logger.info("selector %s matched %d rows (pre-dedupe)", select, len(rows)) + return rows + + +def _run_tag_search(curs: cursor, accept: List[str], select: Dict) -> List[Dict]: + # tag_search_blueprints takes {"tag": ...} dicts (the blueprint-config + # parts shape), not bare strings — bare strings silently no-op. + rows = tag_sql.tag_search_blueprints( + curs, + accept=[{"tag": t} for t in accept], + require=[{"tag": t} for t in select["require"]], + deny=[{"tag": t} for t in select["deny"]], + limit=SELECT_LIMIT, + models=True, + blueprints=False, + ) + if len(rows) >= SELECT_LIMIT: + raise AssemblyError( + f"selector hit the {SELECT_LIMIT}-row ceiling (silent truncation): {select}" + ) + return rows + + +def _resolve_md5(curs: cursor, md5: str) -> Dict: + rows = blueprint_sql.get_blueprints_by_md5(curs, md5) + if not rows: + raise AssemblyError(f"no blueprint with md5 {md5}") + return _require_model(rows[0], f"md5 {md5}") + + +def _resolve_full_name(curs: cursor, full_name: str) -> Dict: + rows = blueprint_sql.get_blueprints_by_full_name(curs, full_name) + if not rows: + raise AssemblyError(f"no blueprint with full_name {full_name!r}") + if len(rows) > 1: + raise AssemblyError( + f"full_name {full_name!r} is ambiguous ({len(rows)} blueprints); " + "reference it by md5 instead" + ) + return _require_model(rows[0], f"full_name {full_name!r}") + + +def _require_model(row: Dict, ref: str) -> Dict: + """Explicit refs must resolve to models, matching the selector filter.""" + if row.get("blueprint_type") != "model": + raise AssemblyError( + f"{ref} resolves to a {row.get('blueprint_type')!r} blueprint, not a model" + ) + return row + + +def _project_model(row: Dict) -> Dict: + """Project a blueprint row to the stable payload shape.""" + if not row.get("file_md5"): + raise AssemblyError( + f"model {row.get('blueprint_name')!r} has no file_md5; the sync " + "diff engine can't track it" + ) + return {field: row.get(field) for field in MODEL_FIELDS} + + +def _dedupe_models(rows: List[Dict]) -> List[Dict]: + """Order-preserving dedupe by file_md5 (a file may match two entries).""" + deduped = [] + seen = set() + for row in rows: + key = row["file_md5"] + if key not in seen: + seen.add(key) + deduped.append(row) + return deduped + + +def _resolve_local_files(manifest: Dict, section: str) -> List[Dict]: + """Resolve image/zip/other paths relative to the manifest, fail fast.""" + base = Path(manifest["manifest_dir"]) + resolved = [] + for entry in manifest["files"][section]: + path = Path(entry["path"]) + if not path.is_absolute(): + path = base / path + if not path.is_file(): + raise AssemblyError(f"{section} file not found: {path}") + resolved.append({"path": str(path)}) + return resolved diff --git a/openforge/thingiverse/manifest.py b/openforge/thingiverse/manifest.py new file mode 100644 index 0000000..0b6d716 --- /dev/null +++ b/openforge/thingiverse/manifest.py @@ -0,0 +1,192 @@ +"""Per-thing manifest loading and validation. + +A manifest is a YAML file describing one Thingiverse thing: its metadata +overrides and the set of files that belong in it. Metadata defaults come +from the catalog and a named template (see templates.py); the manifest +curates on top — per the project's zero-manual-entry philosophy, most +fields are optional. + +Example: + + name: OpenForge 2.0 Cave Floors + template: openforge2 + description: | + Cave floors for the OpenForge 2.0 system. + tags: [cave] + files: + models: + - select: + require: ["texture|cave", "shape|floor"] + deny: ["build|s2w"] + - full_name: tiles/cave/floors/special_floor.stl + - md5: 0805ce8aae75eb8b2c8b95be3eab15d9 + images: + - path: photos/cave_floors.jpg + zips: + - path: out/cave_floors.zip + +Model entries are either a tag `select` query (resilient: newly scanned +files matching the query flow into the thing on the next sync) or an +explicit reference by `full_name` or `md5`. + +Select semantics (matching the catalog's tag search engine): +- `require`: model must carry EVERY listed tag, matched exactly + ("shape|floor" does not match a model tagged "shape|floor|corner") +- `accept`: model must carry at least one tag in the listed subtree, + matched by hierarchical prefix ("shape|floor" matches + "shape|floor|corner") +- `deny`: excludes models carrying any listed tag (exact match) +""" + +from pathlib import Path +from typing import Dict, List + +from yaml import YAMLError, safe_load + +TOP_LEVEL_KEYS = { + "name", + "template", + "description", + "category", + "license", + "tags", + "files", +} +FILE_SECTION_KEYS = {"models", "images", "zips", "others"} +MODEL_ENTRY_KEYS = {"select", "full_name", "md5"} +SELECT_KEYS = {"accept", "require", "deny"} + +DEFAULT_TEMPLATE = "openforge2" + + +class ManifestError(Exception): + """The manifest file is missing, malformed, or fails validation.""" + + +def load_manifest(path: Path) -> Dict: + """Load and validate a thing manifest. + + Args: + path: Path to the manifest YAML file + + Returns: + Validated manifest dict with defaults applied; includes + "manifest_dir" (for resolving relative file paths) and + "manifest_path" + + Raises: + ManifestError: On missing file, bad YAML, or validation failure + """ + path = Path(path) + try: + raw = safe_load(path.read_text()) + except FileNotFoundError as e: + raise ManifestError(f"manifest not found: {path}") from e + except (YAMLError, UnicodeDecodeError) as e: + # narrow on purpose: PermissionError/IsADirectoryError etc. keep + # their real type instead of being mislabeled as YAML problems + raise ManifestError(f"manifest {path} is not valid YAML: {e}") from e + if not isinstance(raw, dict): + raise ManifestError(f"manifest {path} must be a YAML mapping") + + _validate_top_level(raw, path) + manifest = { + "name": raw["name"], + "template": raw.get("template", DEFAULT_TEMPLATE), + "description": raw.get("description", ""), + "category": raw.get("category"), + "license": raw.get("license"), + "tags": _validate_string_list(raw.get("tags", []), "tags", path), + "files": _validate_files(raw.get("files", {}), path), + "manifest_path": str(path), + "manifest_dir": str(path.parent), + } + return manifest + + +def _validate_top_level(raw: Dict, path: Path): + unknown = set(raw) - TOP_LEVEL_KEYS + if unknown: + raise ManifestError( + f"manifest {path} has unknown keys: {sorted(unknown)} " + f"(allowed: {sorted(TOP_LEVEL_KEYS)})" + ) + name = raw.get("name") + if not name or not isinstance(name, str): + raise ManifestError(f"manifest {path} requires a non-empty string 'name'") + + +def _validate_string_list(value, field: str, path: Path) -> List[str]: + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise ManifestError(f"manifest {path}: '{field}' must be a list of strings") + return value + + +def _validate_files(files, path: Path) -> Dict: + if not isinstance(files, dict): + raise ManifestError(f"manifest {path}: 'files' must be a mapping") + unknown = set(files) - FILE_SECTION_KEYS + if unknown: + raise ManifestError( + f"manifest {path}: unknown file sections {sorted(unknown)} " + f"(allowed: {sorted(FILE_SECTION_KEYS)})" + ) + validated = { + "models": [_validate_model_entry(e, path) for e in files.get("models", [])], + "images": [ + _validate_path_entry(e, "images", path) for e in files.get("images", []) + ], + "zips": [_validate_path_entry(e, "zips", path) for e in files.get("zips", [])], + "others": [ + _validate_path_entry(e, "others", path) for e in files.get("others", []) + ], + } + return validated + + +def _validate_model_entry(entry, path: Path) -> Dict: + if not isinstance(entry, dict): + raise ManifestError(f"manifest {path}: model entries must be mappings") + keys = set(entry) & MODEL_ENTRY_KEYS + if len(keys) != 1 or set(entry) - MODEL_ENTRY_KEYS: + raise ManifestError( + f"manifest {path}: each model entry needs exactly one of " + f"{sorted(MODEL_ENTRY_KEYS)}, got {sorted(entry)}" + ) + if "select" in entry: + return {"select": _validate_select(entry["select"], path)} + return dict(entry) + + +def _validate_select(select, path: Path) -> Dict: + if not isinstance(select, dict): + raise ManifestError(f"manifest {path}: 'select' must be a mapping") + unknown = set(select) - SELECT_KEYS + if unknown: + raise ManifestError( + f"manifest {path}: unknown select keys {sorted(unknown)} " + f"(allowed: {sorted(SELECT_KEYS)})" + ) + validated = { + key: _validate_string_list(select.get(key, []), f"select.{key}", path) + for key in SELECT_KEYS + } + if not (validated["accept"] or validated["require"]): + raise ManifestError( + f"manifest {path}: a select query needs at least one " + "'accept' or 'require' tag" + ) + return validated + + +def _validate_path_entry(entry, section: str, path: Path) -> Dict: + if not isinstance(entry, dict) or set(entry) != {"path"}: + raise ManifestError( + f"manifest {path}: '{section}' entries must be mappings " + "with exactly a 'path' key" + ) + if not isinstance(entry["path"], str) or not entry["path"]: + raise ManifestError( + f"manifest {path}: '{section}' path must be a non-empty string" + ) + return dict(entry) diff --git a/openforge/thingiverse/templates/openforge2.yaml b/openforge/thingiverse/templates/openforge2.yaml new file mode 100644 index 0000000..5f508d6 --- /dev/null +++ b/openforge/thingiverse/templates/openforge2.yaml @@ -0,0 +1,31 @@ +# Default thing metadata for OpenForge 2.0 releases. +# Ported from the original v1 tooling's bin/new.openforge2.json +# (~/Dropbox/.../OpenForgeDevelopment). NOTE: the boilerplate copy is the +# original text and predates current channels — review before first +# production publish. +license: "Creative Commons - Attribution - Share Alike" +category: "Toy & Game Accessories" +tags: + - dnd + - dnd_tiles + - dungeon + - miniatures + - OpenForge + - OpenForge2 + - pathfinder + - pathfinder_tiles + - rpg + - rpg_tiles + - tabletop + - terrain + - tile +description_boilerplate: | + For instructions on how to get started with OpenForge, we have a set of [tutorials](https://github.com/devonjones/openforge-tutorials/blob/master/README.md). Want to sell OpenForge prints? Want to use OpenForge designs in your product, check out the [license](https://github.com/devonjones/openforge-tutorials/blob/master/license.md) FAQ. + + OpenForge is now a [patron](https://www.patreon.com/masterworktools) supported project. We use the resources that our patrons give us in order to get the resources to let us create more sophisticated tiles with a greater level of detail. Please, support us, and we'll deliver an ongoing stream of awesome gaming terrain. You can find more about OpenForge and Masterwork Tools at our: + + * [Online catalog and backer site](http://openforge.masterwork-tools.com/) + * [Website](http://masterwork-tools.com) + * [Forums](https://reddit.com/r/OpenForge) (Mostly we use facebook now) + * [Twitter](https://twitter.com/MasterworkTools) + * [Facebook](https://facebook.com/masterworktools/) diff --git a/tests/test_thingiverse_assembler.py b/tests/test_thingiverse_assembler.py new file mode 100644 index 0000000..99c5817 --- /dev/null +++ b/tests/test_thingiverse_assembler.py @@ -0,0 +1,341 @@ +"""Tests for openforge.thingiverse.assembler.""" + +import pytest +from psycopg.rows import dict_row + +import openforge.db.sql.blueprints as blueprint_sql +import openforge.db.sql.tags as tag_sql +from openforge.thingiverse.assembler import ( + AssemblyError, + assemble_thing, + load_template, + metadata_hash, +) +from openforge.thingiverse.manifest import load_manifest + +from .test_helpers import create_test_blueprint + + +def make_manifest(tmp_path, content): + path = tmp_path / "thing.yaml" + path.write_text(content) + return load_manifest(path) + + +def insert_model(curs, name, md5, tags, full_name=None): + data = create_test_blueprint( + blueprint_name=name, + blueprint_type="model", + file_md5=md5, + full_name=full_name or f"tiles/test/{name}.stl", + file_name=f"{name}.stl", + ) + row = blueprint_sql.insert_blueprint(curs, data) + for tag in tags: + tag_sql.insert_tag(curs, row["id"], tag) + return row + + +class TestTemplates: + def test_openforge2_template_loads(self): + template = load_template("openforge2") + assert template["license"] == "Creative Commons - Attribution - Share Alike" + assert template["category"] == "Toy & Game Accessories" + assert "OpenForge" in template["tags"] + assert "patreon.com/masterworktools" in template["description_boilerplate"] + + def test_unknown_template_raises(self): + with pytest.raises(AssemblyError, match="unknown template"): + load_template("does-not-exist") + + +class TestMetadataAssembly: + def test_defaults_from_template(self, test_db, tmp_path): + manifest = make_manifest(tmp_path, "name: Cave Floors\n") + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + thing = assemble_thing(curs, manifest) + metadata = thing["metadata"] + assert metadata["name"] == "Cave Floors" + assert metadata["license"] == "Creative Commons - Attribution - Share Alike" + assert metadata["category"] == "Toy & Game Accessories" + assert "dnd" in metadata["tags"] + # boilerplate footer present even with no per-thing description + assert "patreon.com/masterworktools" in metadata["description"] + + def test_manifest_overrides_template(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nlicense: CC0\ncategory: Other\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + thing = assemble_thing(curs, manifest) + assert thing["metadata"]["license"] == "CC0" + assert thing["metadata"]["category"] == "Other" + + def test_description_composes_prose_then_boilerplate(self, test_db, tmp_path): + manifest = make_manifest(tmp_path, "name: X\ndescription: Per-thing prose.\n") + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + thing = assemble_thing(curs, manifest) + description = thing["metadata"]["description"] + assert description.startswith("Per-thing prose.") + assert description.index("Per-thing prose.") < description.index("patreon") + + def test_tags_merge_dedupes_case_insensitively(self, test_db, tmp_path): + manifest = make_manifest(tmp_path, "name: X\ntags: [cave, openforge, DND]\n") + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + thing = assemble_thing(curs, manifest) + tags = thing["metadata"]["tags"] + assert "cave" in tags + # template already has OpenForge and dnd; no duplicates added + assert len([t for t in tags if t.lower() == "openforge"]) == 1 + assert len([t for t in tags if t.lower() == "dnd"]) == 1 + + def test_metadata_hash_is_stable_and_sensitive(self): + a = {"name": "X", "tags": ["a", "b"]} + assert metadata_hash(a) == metadata_hash({"tags": ["a", "b"], "name": "X"}) + assert metadata_hash(a) != metadata_hash({"name": "Y", "tags": ["a", "b"]}) + + +class TestModelResolution: + def test_select_by_tags(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n" + " - select:\n require: ['texture|cave', 'shape|floor']\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + insert_model( + curs, "cave_floor_1", "md5a", ["texture|cave", "shape|floor"] + ) + insert_model( + curs, "cave_floor_2", "md5b", ["texture|cave", "shape|floor"] + ) + insert_model(curs, "cave_wall", "md5c", ["texture|cave", "shape|wall"]) + thing = assemble_thing(curs, manifest) + md5s = {m["file_md5"] for m in thing["files"]["models"]} + assert md5s == {"md5a", "md5b"} + + def test_select_deny_excludes(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n" + " - select:\n" + " require: ['texture|cave']\n" + " deny: ['build|s2w']\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + insert_model(curs, "plain", "md5a", ["texture|cave"]) + insert_model(curs, "s2w", "md5b", ["texture|cave", "build|s2w"]) + thing = assemble_thing(curs, manifest) + md5s = {m["file_md5"] for m in thing["files"]["models"]} + assert md5s == {"md5a"} + + def test_select_matching_nothing_raises(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n" + " - select:\n require: ['texture|unobtainium']\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + with pytest.raises(AssemblyError, match="matched no models"): + assemble_thing(curs, manifest) + + def test_explicit_md5_and_missing_md5(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n - md5: md5a\n", + ) + missing = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n - md5: nope\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + insert_model(curs, "one", "md5a", []) + thing = assemble_thing(curs, manifest) + assert [m["file_md5"] for m in thing["files"]["models"]] == ["md5a"] + with pytest.raises(AssemblyError, match="no blueprint with md5"): + assemble_thing(curs, missing) + + def test_explicit_full_name(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n - full_name: tiles/test/special.stl\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + insert_model( + curs, "special", "md5s", [], full_name="tiles/test/special.stl" + ) + thing = assemble_thing(curs, manifest) + assert [m["file_md5"] for m in thing["files"]["models"]] == ["md5s"] + + def test_missing_full_name_raises(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n - full_name: tiles/nope.stl\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + with pytest.raises(AssemblyError, match="no blueprint with full_name"): + assemble_thing(curs, manifest) + + def test_accept_matches_by_hierarchical_prefix(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n" + " - select:\n accept: ['shape|floor']\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + insert_model(curs, "corner", "md5a", ["shape|floor|corner"]) + insert_model(curs, "plain", "md5b", ["shape|floor"]) + insert_model(curs, "wall", "md5c", ["shape|wall"]) + thing = assemble_thing(curs, manifest) + md5s = {m["file_md5"] for m in thing["files"]["models"]} + assert md5s == {"md5a", "md5b"} + + def test_multiple_accepts_are_any_not_all(self, test_db, tmp_path): + # each model carries only ONE of the two accept subtrees; ANY + # semantics must match both (the engine ANDs accepts natively — + # the assembler unions per-accept queries to get ANY) + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n" + " - select:\n" + " accept: ['texture|cave', 'texture|dungeon_stone']\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + insert_model(curs, "cave", "md5a", ["texture|cave|smooth"]) + insert_model(curs, "stone", "md5b", ["texture|dungeon_stone"]) + insert_model(curs, "other", "md5c", ["texture|tudor"]) + thing = assemble_thing(curs, manifest) + md5s = {m["file_md5"] for m in thing["files"]["models"]} + assert md5s == {"md5a", "md5b"} + + def test_model_rows_have_stable_shape_across_paths(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n" + " - select:\n require: ['texture|cave']\n" + " - md5: md5x\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + insert_model(curs, "selected", "md5a", ["texture|cave"]) + insert_model(curs, "explicit", "md5x", []) + thing = assemble_thing(curs, manifest) + models = thing["files"]["models"] + assert len(models) == 2 + assert set(models[0].keys()) == set(models[1].keys()) + assert "file_md5" in models[0] and "storage_address" in models[0] + + def test_explicit_ref_to_non_model_raises(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n - md5: md5comp\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + data = create_test_blueprint( + blueprint_name="composite", + blueprint_type="blueprint", + file_md5="md5comp", + ) + blueprint_sql.insert_blueprint(curs, data) + with pytest.raises(AssemblyError, match="not a model"): + assemble_thing(curs, manifest) + + def test_model_without_md5_raises(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n - full_name: tiles/test/no_md5.stl\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + data = create_test_blueprint( + blueprint_name="no_md5", + blueprint_type="model", + full_name="tiles/test/no_md5.stl", + ) + # the helper generates an md5 when given None; force NULL + data["file_md5"] = None + blueprint_sql.insert_blueprint(curs, data) + with pytest.raises(AssemblyError, match="has no file_md5"): + assemble_thing(curs, manifest) + + def test_ambiguous_full_name_raises(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n - full_name: tiles/test/dupe.stl\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + dupe = "tiles/test/dupe.stl" + insert_model(curs, "dupe_a", "md5a", [], full_name=dupe) + insert_model(curs, "dupe_b", "md5b", [], full_name=dupe) + with pytest.raises(AssemblyError, match="ambiguous"): + assemble_thing(curs, manifest) + + def test_select_limit_ceiling_raises(self, test_db, tmp_path, monkeypatch): + import openforge.thingiverse.assembler as assembler_mod + + monkeypatch.setattr(assembler_mod, "SELECT_LIMIT", 2) + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n" + " - select:\n require: ['texture|cave']\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + insert_model(curs, "one", "md5a", ["texture|cave"]) + insert_model(curs, "two", "md5b", ["texture|cave"]) + with pytest.raises(AssemblyError, match="ceiling"): + assemble_thing(curs, manifest) + + def test_overlapping_entries_dedupe_by_md5(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n models:\n" + " - select:\n require: ['texture|cave']\n" + " - md5: md5a\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + insert_model(curs, "cave_floor", "md5a", ["texture|cave"]) + thing = assemble_thing(curs, manifest) + assert [m["file_md5"] for m in thing["files"]["models"]] == ["md5a"] + + +class TestLocalFiles: + def test_relative_paths_resolve_against_manifest_dir(self, test_db, tmp_path): + (tmp_path / "photos").mkdir() + (tmp_path / "photos" / "shot.jpg").write_bytes(b"jpg") + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n images:\n - path: photos/shot.jpg\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + thing = assemble_thing(curs, manifest) + assert thing["files"]["images"] == [ + {"path": str(tmp_path / "photos" / "shot.jpg")} + ] + + def test_missing_local_file_raises(self, test_db, tmp_path): + manifest = make_manifest( + tmp_path, + "name: X\nfiles:\n zips:\n - path: out/nope.zip\n", + ) + with test_db.connection() as conn: + with conn.cursor(row_factory=dict_row) as curs: + with pytest.raises(AssemblyError, match="zips file not found"): + assemble_thing(curs, manifest) diff --git a/tests/test_thingiverse_manifest.py b/tests/test_thingiverse_manifest.py new file mode 100644 index 0000000..baf594d --- /dev/null +++ b/tests/test_thingiverse_manifest.py @@ -0,0 +1,146 @@ +"""Tests for openforge.thingiverse.manifest (loading and validation).""" + +import pytest + +from openforge.thingiverse.manifest import ManifestError, load_manifest + + +def write_manifest(tmp_path, content): + path = tmp_path / "thing.yaml" + path.write_text(content) + return path + + +MINIMAL = """ +name: Cave Floors +""" + +FULL = """ +name: Cave Floors +template: openforge2 +description: | + Cave floors for OpenForge 2.0. +category: Custom Category +license: CC-BY-SA +tags: [cave, floors] +files: + models: + - select: + require: ["texture|cave", "shape|floor"] + deny: ["build|s2w"] + - md5: 0805ce8aae75eb8b2c8b95be3eab15d9 + - full_name: tiles/cave/floors/special.stl + images: + - path: photos/shot.jpg + zips: + - path: out/set.zip +""" + + +def test_minimal_manifest_gets_defaults(tmp_path): + manifest = load_manifest(write_manifest(tmp_path, MINIMAL)) + assert manifest["name"] == "Cave Floors" + assert manifest["template"] == "openforge2" + assert manifest["description"] == "" + assert manifest["tags"] == [] + assert manifest["files"] == { + "models": [], + "images": [], + "zips": [], + "others": [], + } + assert manifest["manifest_dir"] == str(tmp_path) + + +def test_full_manifest_round_trip(tmp_path): + manifest = load_manifest(write_manifest(tmp_path, FULL)) + assert manifest["category"] == "Custom Category" + assert manifest["license"] == "CC-BY-SA" + assert manifest["tags"] == ["cave", "floors"] + models = manifest["files"]["models"] + assert models[0] == { + "select": { + "accept": [], + "require": ["texture|cave", "shape|floor"], + "deny": ["build|s2w"], + } + } + assert models[1] == {"md5": "0805ce8aae75eb8b2c8b95be3eab15d9"} + assert models[2] == {"full_name": "tiles/cave/floors/special.stl"} + assert manifest["files"]["images"] == [{"path": "photos/shot.jpg"}] + + +def test_missing_file_raises(tmp_path): + with pytest.raises(ManifestError, match="not found"): + load_manifest(tmp_path / "nope.yaml") + + +def test_invalid_yaml_raises(tmp_path): + path = write_manifest(tmp_path, "name: [unclosed") + with pytest.raises(ManifestError, match="not valid YAML"): + load_manifest(path) + + +def test_non_mapping_raises(tmp_path): + path = write_manifest(tmp_path, "- just\n- a\n- list\n") + with pytest.raises(ManifestError, match="must be a YAML mapping"): + load_manifest(path) + + +def test_missing_name_raises(tmp_path): + path = write_manifest(tmp_path, "tags: [cave]\n") + with pytest.raises(ManifestError, match="requires a non-empty string 'name'"): + load_manifest(path) + + +def test_unknown_top_level_key_raises(tmp_path): + path = write_manifest(tmp_path, "name: X\ndescriptoin: typo\n") + with pytest.raises(ManifestError, match="unknown keys.*descriptoin"): + load_manifest(path) + + +def test_unknown_file_section_raises(tmp_path): + path = write_manifest(tmp_path, "name: X\nfiles:\n sculptures: []\n") + with pytest.raises(ManifestError, match="unknown file sections"): + load_manifest(path) + + +def test_model_entry_needs_exactly_one_kind(tmp_path): + path = write_manifest( + tmp_path, + "name: X\nfiles:\n models:\n - md5: abc\n full_name: def\n", + ) + with pytest.raises(ManifestError, match="exactly one of"): + load_manifest(path) + + +def test_empty_select_raises(tmp_path): + path = write_manifest( + tmp_path, + "name: X\nfiles:\n models:\n - select:\n deny: [a]\n", + ) + with pytest.raises(ManifestError, match="at least one 'accept' or 'require'"): + load_manifest(path) + + +def test_select_unknown_key_raises(tmp_path): + path = write_manifest( + tmp_path, + "name: X\nfiles:\n models:\n - select:\n needs: [a]\n", + ) + with pytest.raises(ManifestError, match="unknown select keys"): + load_manifest(path) + + +def test_image_entry_must_be_path_mapping(tmp_path): + path = write_manifest( + tmp_path, "name: X\nfiles:\n images:\n - photos/shot.jpg\n" + ) + with pytest.raises(ManifestError, match="exactly a 'path' key"): + load_manifest(path) + + +def test_tags_must_be_strings(tmp_path): + path = write_manifest(tmp_path, "name: X\ntags: [1, 2]\n") + with pytest.raises(ManifestError, match="must be a list of strings"): + load_manifest(path)