diff --git a/docs/TODO.md b/docs/TODO.md index db52cb8..8d0d821 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -60,3 +60,82 @@ This is the ordered implementation roadmap following the accepted exhibit model. - [x] Instantiate and solve a cloned level without modifying the template or relying on hard-coded case behavior. - [x] Turn the successful solve path into an end-to-end acceptance test. - [x] Perform a manual playability and visual-polish pass before production deployment. + +## Milestone 5: claim-driven case report + +The purpose of this milestone is the gameplay loop, not a knowledge graph: the player connects two exhibits, explains that one specific thread with a luggage-tag Claim, and later discovers that their accumulated explanations have become a nearly complete case report. + +### 5.1 Lock the gameplay and temporal rules +- [ ] Define a Claim as an entity owned by exactly one investigative thread; a thread has zero or one Claim. (The text associated with a claim can prove multiple points, handed by the text. +- [ ] Keep untagged threads as ordinary connections that do not appear in the report. +- [ ] Use the same Claim text on the luggage tag and in the report. Editing either presentation updates the same database value. +- [ ] Derive the Claim date from the earliest non-null temporal date of its two endpoint exhibits; never use `created_at` or the current time. +- [ ] Use Event `occurred_at` and a Document's primary timeline date as direct endpoint dates. For a Folder, use the earliest dated contained Document. Leave a Claim undated when neither endpoint supplies a date. +- [ ] Place undated Claims after dated Claims in the initial report order while keeping them fully editable and reorderable. +- [ ] Treat the derived date as the initial chronological suggestion only. Manual report ordering must not rewrite exhibit or Claim dates. +### 5.2 Add normalized persistence + +- [ ] Add a `claims` table with a unique foreign key to `exhibit_connections`, text, tag style, position percentage, lateral offset, and timestamps. +- [ ] Move luggage-tag-specific text and placement fields out of `exhibit_connections`; retain curve tightness and endpoints on the connection. +- [ ] Add one level-owned `case_report` and normalized `case_report_claims` rows with explicit `sort_order`. +- [ ] Assign stable, level-local display numbers to cite exhibits as `Exhibit 3` independently of board position, z-index, or report order. +- [ ] Enforce same-board ownership for the Claim's connection, both endpoint exhibits, report, and report membership. +- [ ] Delete a Claim and its report membership transactionally when its luggage tag is removed, while retaining the now-untagged thread. +- [ ] Delete both the Claim and connection when the thread itself is removed. +- [ ] Clone board-owned Claims with fresh IDs during template creation and instantiation; rebuild level report membership against the cloned Claim IDs. +- [ ] Make reset discard player-created Claims and restore exactly the Claims present in the source template version. +- [ ] Keep uploaded binary evidence in MinIO. Reports and Claims reference Document exhibits and asset metadata; they never duplicate or embed asset bytes. + +### 5.3 Expose a focused API contract + +- [ ] Add a typed Claim DTO to an investigative connection instead of exposing a free-form connection `label`. +- [ ] Return each Claim's derived date and its two stable exhibit citations in the report response. +- [ ] Add granular operations to create, edit, and remove a Claim without replacing the complete board state. +- [ ] Add a report endpoint that returns ordered Claim rows and an atomic reorder operation. +- [ ] Reject empty Claim text, invalid connection ownership, duplicate Claims on one connection, and report orders containing foreign or duplicate Claim IDs. +- [ ] Protect Claim and report writes with the board revision so concurrent saves cannot silently overwrite reasoning. + +### 5.4 Turn luggage tags into Claim widgets + +- [ ] Change the thread prompt from “Add tag” to “Explain this connection,” with a secondary option to leave the thread untagged. +- [ ] Create the Claim and its luggage-tag presentation in one interaction, preserving the current tightening animation. +- [ ] Keep both `LUGGAGE` and `COMPACT` as visual presentations of the same Claim entity while playtesting them. +- [ ] Preserve draggable percentage and tension-constrained lateral offset as Claim presentation state. +- [ ] Rename tag-oriented frontend types and commands to Claim terminology without changing the established visual design. +- [ ] Give a newly created Claim a subtle “added to report” ink animation or badge without interrupting board work. + +### 5.5 Build the typewriter case report +The end goal is that a case report is prepopulated by the claims the player made during the investigation so that a skeleon of the case solution is present. The player must simply edit the case report and submit it. + +- [ ] Add **Case Report** as a primary menu item and implement it as a persistent board view, separate from exhibits and the timeline. +- [ ] Provide an empty state that explains that explaining red threads will create the report, without revealing a solution or forcing a tutorial. +- [ ] Initially arrange Claim rows chronologically by derived date, using the order parameters on the claim as tie-breaker and undated Claims last, below a horizontal rule that says (missing date) +- [ ] Render each row as a typewritten Claim with its date and endpoint citations, for example: `14.10.1987 — Maria Voss redirected the shipment. Exhibits 4 and 7.` +- [ ] It needs to be possible to add free text before and after the claims. Coloured inline text (use span elements) have a specific class and id can be edited +- [ ] -Make Claim text editable inline. Persist through the Claim API so the luggage tag updates immediately. +- [ ] Support pointer and keyboard reordering of Claim rows and persist the resulting explicit report order into order column of the claim. +- [ ] Clicking a Claim must minimize the report as appropriate, center its thread, and briefly illuminate the curve and luggage tag. +- [ ] Clicking an exhibit citation must locate and highlight that exhibit using the existing tray/board locator treatment. +- [ ] Ensure the report is legible and operable on portrait mobile layouts as well as desktop. +- [ ] Add restrained typewriter, paper, and ink feedback while respecting reduced-motion preferences. + +### 5.6 Test the reasoning loop + +- [ ] Unit-test endpoint date resolution for Event, Document, Folder, partially dated, fully undated, and invalid-date cases. +- [ ] Migration-test the one-Claim-per-thread constraint, cascading behavior, same-board enforcement, and report ordering constraints. +- [ ] Integration-test create/edit/remove Claim, two-way text synchronization, report reorder, reload persistence, reset, and template cloning. +- [ ] Verify that Claims citing uploaded Document exhibits survive cloning while their immutable assets remain shared through MinIO. +- [ ] Add a browser test that creates several connections out of chronological order, explains them, opens the report, edits and reorders the Claims, then returns to each highlighted thread. +- [ ] Play the Glass Harbor mystery using Claims as the primary reasoning mechanism and record whether the generated report makes the conclusion emerge naturally. +- [ ] Revise prompts, animation, initial ordering, and report typography based on that playtest before adding automated evaluation. + +### 5.7 Deliberately deferred + +- [ ] Design a hidden, template-versioned solution rubric only after the deterministic Claim/report loop is enjoyable and dependable. +- [ ] Add LLM report evaluation as a later milestone with structured citations, calibrated uncertainty, and reproducible evaluator output. +- [ ] Do not add general-purpose semantic edge roles, Claim hubs, Claim-to-Claim links, or a knowledge-graph ontology as part of this milestone. + +### Milestone 5 definition of done +A player can connect exhibits, explain each connection with a luggage-tag Claim, discover those exact words in a chronological typewriter report, improve and reorder the Claims, follow every citation back to the board, reload without loss, and reset safely to the template. No LLM is required for this experience to work. + + diff --git a/migrations/013_board_views.sql b/migrations/013_board_views.sql new file mode 100644 index 0000000..6d8de41 --- /dev/null +++ b/migrations/013_board_views.sql @@ -0,0 +1,58 @@ +CREATE TABLE osint.board_view_types ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); +INSERT INTO osint.board_view_types (id,name) VALUES ('timeline','Timeline'); + +CREATE TABLE osint.board_views ( + id UUID PRIMARY KEY, + board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE, + view_type_id TEXT NOT NULL REFERENCES osint.board_view_types(id), + origin_view_id UUID REFERENCES osint.board_views(id) ON DELETE SET NULL, + placement_mode TEXT NOT NULL DEFAULT 'docked' CHECK (placement_mode IN ('docked','canvas','window')), + dock_edge TEXT CHECK (dock_edge IN ('top','right','bottom','left')), + xpos DOUBLE PRECISION, + ypos DOUBLE PRECISION, + width DOUBLE PRECISION, + height DOUBLE PRECISION NOT NULL DEFAULT 112 CHECK (height > 0), + z_index INTEGER NOT NULL DEFAULT 0, + visible BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (board_id,view_type_id), + CHECK ( + (placement_mode = 'docked' AND dock_edge IS NOT NULL) + OR (placement_mode IN ('canvas','window') AND xpos IS NOT NULL AND ypos IS NOT NULL AND width IS NOT NULL) + ) +); +CREATE INDEX board_views_board_idx ON osint.board_views (board_id,z_index,created_at); + +CREATE TABLE osint.timeline_views ( + view_id UUID PRIMARY KEY REFERENCES osint.board_views(id) ON DELETE CASCADE, + range_mode TEXT NOT NULL DEFAULT 'auto' CHECK (range_mode IN ('auto','fixed')), + range_start DATE, + range_end DATE, + CHECK ( + (range_mode = 'auto' AND range_start IS NULL AND range_end IS NULL) + OR (range_mode = 'fixed' AND range_start IS NOT NULL AND range_end IS NOT NULL AND range_end > range_start) + ) +); + +WITH created_views AS ( + INSERT INTO osint.board_views (id,board_id,view_type_id,placement_mode,dock_edge,height) + SELECT gen_random_uuid(),b.id,'timeline','docked','bottom',112 + FROM osint.boards b + RETURNING id,board_id +) +INSERT INTO osint.timeline_views (view_id,range_mode,range_start,range_end) +SELECT v.id, + CASE WHEN settings.board_id IS NULL THEN 'auto' ELSE 'fixed' END, + settings.range_start, + settings.range_end +FROM created_views v +LEFT JOIN osint.board_timeline_settings settings ON settings.board_id=v.board_id; + +DROP TABLE osint.board_timeline_settings; + +COMMENT ON TABLE osint.board_views IS 'Persistent frontend projections and workspace apparatus. Views are not investigation-domain exhibits.'; +COMMENT ON TABLE osint.timeline_views IS 'Timeline projection settings; temporal markers remain derived from exhibits and are never duplicated here.'; diff --git a/migrations/014_external_asset_objects.sql b/migrations/014_external_asset_objects.sql new file mode 100644 index 0000000..9f45325 --- /dev/null +++ b/migrations/014_external_asset_objects.sql @@ -0,0 +1,16 @@ +ALTER TABLE osint.assets + ALTER COLUMN content DROP NOT NULL, + ADD COLUMN storage_provider TEXT NOT NULL DEFAULT 'postgres' CHECK (storage_provider IN ('postgres','s3')), + ADD COLUMN storage_bucket TEXT, + ADD COLUMN object_key TEXT, + ADD COLUMN etag TEXT; + +ALTER TABLE osint.assets + ADD CONSTRAINT assets_storage_location_check CHECK ( + (storage_provider = 'postgres' AND content IS NOT NULL AND storage_bucket IS NULL AND object_key IS NULL) + OR (storage_provider = 's3' AND content IS NULL AND storage_bucket IS NOT NULL AND object_key IS NOT NULL) + ), + ADD CONSTRAINT assets_object_location_unique UNIQUE (storage_bucket,object_key); + +COMMENT ON COLUMN osint.assets.content IS 'Compatibility storage for assets created before object storage. New uploads use the private S3/MinIO bucket.'; +COMMENT ON COLUMN osint.assets.object_key IS 'Private object key; clients retrieve bytes through the authenticated application asset endpoint.'; diff --git a/package-lock.json b/package-lock.json index e258a8e..5ea047b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "gupi-osint-board", "version": "0.1.0", "dependencies": { + "@aws-sdk/client-s3": "^3.1111.0", "cookie-parser": "^1.4.7", "cors": "2.8.5", "dotenv": "16.5.0", @@ -41,6 +42,314 @@ "node": "^20.0.0 || >=22.0.0" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.28.tgz", + "integrity": "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1111.0.tgz", + "integrity": "sha512-VnLT6aSTN8tWl/NsXUysXNZor7wQBp9CRwufo7kt8cwGXvHLZ0S/cV1K9WFcREGboVYSo3NGQ3ZvU7LRidh2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.28", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-node": "^3.972.80", + "@aws-sdk/middleware-sdk-s3": "^3.972.74", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz", + "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.4", + "@aws-sdk/xml-builder": "^3.972.39", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz", + "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz", + "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz", + "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-login": "^3.972.76", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz", + "integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz", + "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-ini": "^3.973.14", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz", + "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz", + "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/token-providers": "3.1111.0", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz", + "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.74.tgz", + "integrity": "sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz", + "integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz", + "integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.4", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz", + "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz", + "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz", + "integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1179,6 +1488,87 @@ "win32" ] }, + "node_modules/@smithy/core": { + "version": "3.33.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.0.tgz", + "integrity": "sha512-uKbkxgqLyepQDZoq8aRSdUqD1ID//rOqG96ixBhp++O7vBtmwYM6fwldGhr9HJP0iYrdc7GP/AlgzPWEZIrNRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", + "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", + "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.0.tgz", + "integrity": "sha512-ssHIZsadPUA3lGdnoByxfnjtb9xPYQLvdfJRLKIwxOoa6tO1suG4sLFSsgd7D/CsvYd8QbBIuKTImuJha5l6aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", + "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", + "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1668,6 +2058,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -3765,7 +4161,6 @@ "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" }, "node_modules/tsx": { diff --git a/package.json b/package.json index ff7efca..f122487 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:e2e": "npm run build && playwright test" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1111.0", "cookie-parser": "^1.4.7", "cors": "2.8.5", "dotenv": "16.5.0", diff --git a/scripts/importMysteryTemplate.ts b/scripts/importMysteryTemplate.ts index 8993b48..62ad1a1 100644 --- a/scripts/importMysteryTemplate.ts +++ b/scripts/importMysteryTemplate.ts @@ -29,10 +29,6 @@ function requireOk(response: Response, action: string) { return response.text().then(body => { throw new Error(`${action} failed (${response.status}): ${body}`) }) } -function documentKind(type: SourceFileType) { - return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase() -} - async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument, authorization?: string) { if (!document.asset) return undefined const assetPath = path.resolve(manifestDir, document.asset) @@ -55,12 +51,15 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt }), 'Create authoring level') const state = await createdResponse.json() as CaseState + const documentPositions = new Map() + manifest.folders.forEach((folder, folderIndex) => folder.members.forEach((key, memberIndex) => documentPositions.set(key, { x: folder.x + 70 + memberIndex * 205, y: folder.y + 230 + folderIndex * 35 }))) const documents = new Map() for (const source of manifest.documents) { const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source, authorization) documents.set(source.key, { - id: uploaded?.id || randomUUID(), title: source.title, kind: documentKind(source.fileType), - date: source.publishedAt.slice(0, 10), publishedAt: source.publishedAt, + id: uploaded?.id || randomUUID(), type: 'document', title: source.title, publishedAt: source.publishedAt, + x: documentPositions.get(source.key)?.x || 100, y: documentPositions.get(source.key)?.y || 100, + width: uploaded?.width || 174, height: uploaded?.height || 145, rotation: 0, zIndex: uploaded?.zIndex || 1, hidden: false, body: source.body || [], regions: [], assetId: uploaded?.assetId, fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize, fileType: source.fileType, metadata: source.metadata || {}, @@ -69,20 +68,18 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt const folderIds = new Map(manifest.folders.map(folder => [folder.key, randomUUID()])) state.brief = { body: manifest.brief.body, concepts: manifest.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) } - state.timelineRange = manifest.timelineRange - state.documents = [...documents.values()] - state.evidence = manifest.folders.map(folder => ({ + state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: manifest.timelineRange ? 'fixed' : 'auto', range: manifest.timelineRange } : view) + const folders = manifest.folders.map(folder => ({ id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content, - x: folder.x, y: folder.y, width: folder.width, config: { open: false }, - containedDocumentIds: folder.members.map(key => documents.get(key)!.id), - })) - state.relations = manifest.folders.flatMap((folder, folderIndex) => folder.members.map((key, memberIndex) => { + x: folder.x, y: folder.y, width: folder.width, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false, + } as const)) + state.exhibits = [...documents.values(), ...folders] + state.relations = manifest.folders.flatMap(folder => folder.members.map((key, memberIndex) => { const document = documents.get(key) if (!document) throw new Error(`Folder ${folder.key} refers to unknown document ${key}`) return { id: `contains:${folderIds.get(folder.key)}:${document.id}`, - fromWidgetId: folderIds.get(folder.key)!, toWidgetId: document.id, type: 'contains', sortOrder: memberIndex, - config: { x: folder.x + 70 + memberIndex * 205, y: folder.y + 230 + folderIndex * 35 }, + fromExhibitId: folderIds.get(folder.key)!, toExhibitId: document.id, type: 'contains' as const, sortOrder: memberIndex, } })) state.connections = [] diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index 6799dc5..4875933 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url' import pg from 'pg' import jwt from 'jsonwebtoken' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import type { CaseState } from '../src/types.js' +import type { CaseState, DocumentExhibit, EventExhibit, FolderExhibit, NoteExhibit, PartyExhibit, TimelineView } from '../src/types.js' import { runMigrations } from './migrations.js' const { Client } = pg @@ -36,7 +36,9 @@ async function availablePort() { }) } -suite('level persistence API', () => { +const placed = (x: number, y: number, width: number, height: number, zIndex = 1) => ({ x, y, width, height, rotation: 0, zIndex, hidden: false }) + +suite('normalized level persistence API', () => { beforeAll(async () => { const adminUrl = new URL(baseDatabaseUrl!) adminUrl.pathname = '/postgres' @@ -46,13 +48,13 @@ suite('level persistence API', () => { const testUrl = new URL(baseDatabaseUrl!) testUrl.pathname = `/${databaseName}` const databaseUrl = testUrl.toString() - const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') - await runMigrations(databaseUrl, migrationsDir, () => undefined) + await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined) const port = await availablePort() process.env.DATABASE_URL = databaseUrl process.env.LEVEL_EDITING_ENABLED = 'true' process.env.JWT_SECRET = 'osint-integration-jwt-secret' + process.env.ASSET_STORAGE_DRIVER = 'memory' process.env.PORT = String(port) const serverModule = await import('./index.js') appServer = serverModule.server @@ -69,217 +71,62 @@ suite('level persistence API', () => { await adminClient.end() }) - it('persists one normalized level across authoring and play views', async () => { + it('round-trips exhibits, relations, board views, private objects, and template clones', async () => { expect(await (await fetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: false, isAdmin: false }) - expect(await (await adminFetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: true, isAdmin: true }) - expect((await fetch(`${baseUrl}/api/levels`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status).toBe(403) const createResponse = await adminFetch(`${baseUrl}/api/levels`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }), + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }), }) expect(createResponse.status).toBe(201) const state = await createResponse.json() as CaseState + const timeline = state.views.find((view): view is TimelineView => view.type === 'timeline')! + timeline.rangeMode = 'fixed' + timeline.range = { start: '2021-04-01', end: '2021-04-30' } state.viewport = { x: 91, y: -42, zoom: 0.85 } - state.timelineRange = { start: '2021-04-01', end: '2021-04-30' } - const documentId = randomUUID() - const folderId = randomUUID() - const noteId = randomUUID() - const eventId = randomUUID() - const personConceptId = randomUUID() - const organizationConceptId = randomUUID() - state.brief = { body: 'Identify Ada Lovelace and Analytical Engines Ltd in the source material.', concepts: [ - { id: personConceptId, label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' }, - { id: organizationConceptId, label: 'Analytical Engines Ltd', context: 'Issued the filing.', expectedPartyKind: 'organization' }, - ] } - state.documents = [{ id: documentId, title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {} }] - state.evidence = [ - { id: folderId, type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: [documentId] }, - { id: noteId, type: 'note', title: 'Extract', content: 'Date matters', sourceDocumentId: documentId, sourceRegionId: 'stamp', x: 420, y: 300, width: 108 }, - { id: eventId, type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', supportingEvidenceIds: [documentId, noteId], x: 520, y: 610, width: 270 }, + + const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {}, ...placed(1051, 417, 174, 145, 2) } + const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) } + const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) } + const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) } + const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) } + state.exhibits = [document, folder, note, event, party] + state.relations = [ + { id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, type: 'contains', sortOrder: 0 }, + { id: randomUUID(), fromExhibitId: note.id, toExhibitId: document.id, type: 'source', sourceRegionId: 'stamp', sortOrder: 0 }, + { id: randomUUID(), fromExhibitId: event.id, toExhibitId: document.id, type: 'supports', sortOrder: 0 }, + { id: randomUUID(), fromExhibitId: event.id, toExhibitId: note.id, type: 'supports', sortOrder: 1 }, + { id: randomUUID(), fromExhibitId: party.id, toExhibitId: document.id, type: 'concerns', sortOrder: 0 }, ] - state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }] - state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }] - - const saveResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(state), - }) - expect(await saveResponse.json()).toEqual({ ok: true, mode: 'author' }) + state.connections = [{ id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }] + state.brief = { body: 'Identify Ada Lovelace.', concepts: [{ id: randomUUID(), label: 'Ada Lovelace', context: 'Named in evidence.', expectedPartyKind: 'person', resolvedPartyExhibitId: party.id }] } + const save = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(state) }) + expect(await save.json()).toEqual({ ok: true, mode: 'author' }) const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState - expect(loaded.viewport).toEqual(state.viewport) - expect(loaded.timelineRange).toEqual(state.timelineRange) - expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } }) - expect(loaded.relations[0]).toMatchObject({ id: `contains:${folderId}:${documentId}`, config: { x: 1051, y: 417 } }) - expect(loaded.connections).toContainEqual(expect.objectContaining({ fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 })) - expect(loaded.brief.concepts).toEqual(expect.arrayContaining([ - expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }), - expect.objectContaining({ label: 'Analytical Engines Ltd', expectedPartyKind: 'organization' }), - ])) - const legacyClientState = structuredClone(loaded) - delete legacyClientState.timelineRange - const legacySave = await fetch(`${baseUrl}/api/levels/${state.id}`, { - method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(legacyClientState), - }) - expect(legacySave.ok).toBe(true) - expect((await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState).timelineRange).toEqual(state.timelineRange) + expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range }) + expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true }) + expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })])) + expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' }) const upload = new FormData() upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt') const uploadResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload }) expect(uploadResponse.status).toBe(201) - const uploaded = await uploadResponse.json() as CaseState['documents'][number] - expect(uploaded).toMatchObject({ title: 'smoke-evidence.txt', fileName: 'smoke-evidence.txt', mimeType: 'text/plain', fileType: 'text' }) - expect(uploaded.assetId).toBeTruthy() + const uploaded = await uploadResponse.json() as DocumentExhibit + expect(uploaded).toMatchObject({ type: 'document', fileName: 'smoke-evidence.txt', fileType: 'text' }) expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence') + const assetRow = await appPool.query<{ storage_provider: string; content: Buffer | null; object_key: string | null }>('SELECT storage_provider,content,object_key FROM osint.assets WHERE id=$1', [uploaded.assetId]) + expect(assetRow.rows[0]).toMatchObject({ storage_provider: 's3', content: null, object_key: expect.stringMatching(/^assets\//) }) - const withUpload = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState - const uploadedDocument = withUpload.documents.find(document => document.id === uploaded.id)! - uploadedDocument.title = 'Renamed smoke evidence' - uploadedDocument.publishedAt = '2022-06-15T10:30:00.000Z' - uploadedDocument.metadata = { witness: 'Integration test', confidence: 'high' } - const metadataSave = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(withUpload), - }) - expect(metadataSave.ok).toBe(true) - const afterMetadataSave = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState - expect(afterMetadataSave.documents.find(document => document.id === uploaded.id)).toMatchObject({ - title: 'Renamed smoke evidence', - publishedAt: '2022-06-15T10:30:00.000Z', - metadata: { witness: 'Integration test', confidence: 'high' }, - }) - - const undatedState = structuredClone(afterMetadataSave) - const undatedEvent = undatedState.evidence.find(exhibit => exhibit.id === eventId)! - delete undatedEvent.eventDate - const undatedSave = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { - method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(undatedState), - }) - expect(undatedSave.ok).toBe(true) - const loadedUndated = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState - expect(loadedUndated.evidence.find(exhibit => exhibit.id === eventId)?.eventDate).toBeUndefined() - expect((await appPool.query<{ occurred_at: Date | null }>('SELECT occurred_at FROM osint.event_exhibits WHERE exhibit_id=$1', [eventId])).rows[0].occurred_at).toBeNull() - loadedUndated.evidence.find(exhibit => exhibit.id === eventId)!.eventDate = '2021-04-18T14:30:00Z' - expect((await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { - method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(loadedUndated), - })).ok).toBe(true) - - const playerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState - expect(playerState.brief.concepts.every(concept => concept.expectedPartyKind === undefined)).toBe(true) - const personPartyId = randomUUID() - const organizationPartyId = randomUUID() - playerState.evidence.push( - { id: personPartyId, type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as the correspondent.', aliases: ['A. A. L.'], relatedEvidenceIds: [documentId, noteId], x: 720, y: 250, width: 280 }, - { id: organizationPartyId, type: 'party', partyKind: 'organization', organizationKind: 'business', title: 'Analytical Engines Ltd', content: 'Issued the filing.', aliases: ['AEL'], relatedEvidenceIds: [documentId], x: 1020, y: 250, width: 280 }, - ) - playerState.brief.concepts = playerState.brief.concepts.map(concept => ({ ...concept, - resolvedPartyExhibitId: concept.id === personConceptId ? personPartyId : organizationPartyId })) - playerState.viewport = { x: -150, y: 88, zoom: 1.1 } - playerState.evidence[0] = { ...playerState.evidence[0], x: 812, y: 533 } - const playerSave = await fetch(`${baseUrl}/api/levels/${state.id}`, { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(playerState), - }) - expect(await playerSave.json()).toEqual({ ok: true, mode: 'play' }) - const savedPlayerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState - expect(savedPlayerState.viewport).toEqual(playerState.viewport) - expect(savedPlayerState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 }) - const sameLevelInEditView = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState - expect(sameLevelInEditView.viewport).toEqual(playerState.viewport) - expect(sameLevelInEditView.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 }) - - const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string; sources: string; connections: string; events: string; event_evidence: string; parties: string; people: string; organizations: string; party_evidence: string; concepts: string; hidden_answers: string }>(`SELECT - (SELECT COUNT(*) FROM osint.exhibits)::text AS exhibits, - (SELECT COUNT(*) FROM osint.document_exhibits)::text AS documents, - (SELECT COUNT(*) FROM osint.folder_exhibits)::text AS folders, - (SELECT COUNT(*) FROM osint.folder_memberships)::text AS memberships, - (SELECT COUNT(*) FROM osint.exhibit_metadata_text_values)::text AS metadata, - (SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources, - (SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections, - (SELECT COUNT(*) FROM osint.event_exhibits)::text AS events, - (SELECT COUNT(*) FROM osint.event_evidence)::text AS event_evidence, - (SELECT COUNT(*) FROM osint.party_exhibits)::text AS parties, - (SELECT COUNT(*) FROM osint.person_parties)::text AS people, - (SELECT COUNT(*) FROM osint.organization_parties)::text AS organizations, - (SELECT COUNT(*) FROM osint.party_evidence)::text AS party_evidence, - (SELECT COUNT(*) FROM osint.brief_concepts)::text AS concepts, - (SELECT COUNT(*) FROM osint.brief_concepts WHERE expected_party_kind IS NOT NULL)::text AS hidden_answers`) - expect(normalized.rows[0]).toEqual({ exhibits: '7', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1', events: '1', event_evidence: '2', parties: '2', people: '1', organizations: '1', party_evidence: '3', concepts: '2', hidden_answers: '2' }) - - const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' }) - expect(resetResponse.ok).toBe(true) - const resetState = await resetResponse.json() as CaseState - expect(resetState.viewport).toEqual(playerState.viewport) - expect(resetState.timelineRange).toEqual(state.timelineRange) - expect(resetState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 }) - - const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { - method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }), - }) + const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }) }) expect(templateResponse.status).toBe(201) - expect(await templateResponse.json()).toMatchObject({ slug: 'smoke-template', currentVersion: 1, versionCount: 1 }) - expect(await (await fetch(`${baseUrl}/api/templates`)).json()).toEqual([ - expect.objectContaining({ slug: 'smoke-template', currentVersion: 1, versionCount: 1 }), - ]) - - const changedSource = structuredClone(savedPlayerState) - changedSource.title = 'Changed after template freeze' - changedSource.evidence[0].x = 999 - await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { - method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(changedSource), - }) - const cloneResponse = await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, { - method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'smoke-template-copy', title: 'Playable copy' }), - }) + const cloneResponse = await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'smoke-template-copy', title: 'Playable copy' }) }) expect(cloneResponse.status).toBe(201) const clone = await cloneResponse.json() as CaseState - expect(clone).toMatchObject({ id: 'smoke-template-copy', title: 'Playable copy', sourceTemplateVersionId: expect.any(String) }) - expect(clone.timelineRange).toEqual(state.timelineRange) - expect(clone.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 }) - expect(clone.documents.find(item => item.title === 'Renamed smoke evidence')?.assetId).toBe(uploaded.assetId) - expect(clone.documents[0].id).not.toBe(savedPlayerState.documents[0].id) - expect(clone.evidence.map(item => item.id)).not.toContain(folderId) - expect(clone.connections).toHaveLength(1) - expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12, toEvidenceId: clone.documents[0].id }) - expect(clone.evidence.find(item => item.type === 'note')).toMatchObject({ sourceRegionId: 'stamp' }) - const clonedEvent = clone.evidence.find(item => item.type === 'event')! - expect(clonedEvent).toMatchObject({ title: 'The meeting occurred', eventDate: '2021-04-18T14:30:00.000Z' }) - expect(clonedEvent.supportingEvidenceIds).toHaveLength(2) - expect(clonedEvent.supportingEvidenceIds).not.toContain(documentId) - expect(clonedEvent.supportingEvidenceIds).not.toContain(noteId) - expect(clone.evidence.filter(item => item.type === 'party')).toHaveLength(2) - expect(clone.brief.concepts.every(concept => Boolean(concept.resolvedPartyExhibitId))).toBe(true) - expect(clone.brief.concepts.map(concept => concept.resolvedPartyExhibitId)).not.toContain(personPartyId) - const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState - expect(authoredClone.brief.concepts.map(concept => concept.expectedPartyKind).sort()).toEqual(['organization', 'person']) - - const clonedFolder = clone.evidence.find(item => item.type === 'folder')! - clonedFolder.x = 1234 - clone.viewport = { x: 333, y: 222, zoom: 1.2 } - await fetch(`${baseUrl}/api/levels/${clone.id}`, { - method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(clone), - }) - const cloneReset = await (await fetch(`${baseUrl}/api/levels/${clone.id}/reset`, { method: 'POST' })).json() as CaseState - expect(cloneReset).toMatchObject({ title: 'API Smoke Level', viewport: { x: 0, y: 28, zoom: 0.7 } }) - expect(cloneReset.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 }) - expect(cloneReset.evidence.map(item => item.id)).not.toContain(clonedFolder.id) - - const versionTwoResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { - method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }), - }) - expect(await versionTwoResponse.json()).toMatchObject({ currentVersion: 2, versionCount: 2 }) - const oldVersion = await (await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, { - method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'old-version-copy', version: 1 }), - })).json() as CaseState - const currentVersion = await (await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, { - method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'current-version-copy' }), - })).json() as CaseState - expect(oldVersion.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812 }) - expect(currentVersion.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 999 }) + expect(clone.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range }) + expect(clone.exhibits.map(item => item.id)).not.toContain(folder.id) + expect(clone.exhibits.find(item => item.type === 'folder')).toMatchObject({ x: 685, y: 417 }) + expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2) + expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 }) + expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id) }) }) diff --git a/server/boardClone.ts b/server/boardClone.ts index 1762b4d..8271213 100644 --- a/server/boardClone.ts +++ b/server/boardClone.ts @@ -10,7 +10,7 @@ function mapped(ids: IdMap, sourceId: string, label: string) { } export async function clearBoard(client: PoolClient, boardId: string) { - await client.query('DELETE FROM osint.board_timeline_settings WHERE board_id=$1', [boardId]) + await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId]) await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId]) @@ -24,11 +24,22 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ const regionIds: IdMap = new Map() const fieldIds: IdMap = new Map() - const timeline = await client.query<{ range_start: string; range_end: string }>( - 'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [sourceBoardId]) - if (timeline.rows[0]) await client.query( - 'INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)', - [targetBoardId, timeline.rows[0].range_start, timeline.rows[0].range_end]) + const views = await client.query<{ + id: string; view_type_id: string; placement_mode: string; dock_edge: string | null; xpos: number | null; ypos: number | null + width: number | null; height: number; z_index: number; visible: boolean; range_mode: string | null; range_start: string | null; range_end: string | null + }>(`SELECT v.id,v.view_type_id,v.placement_mode,v.dock_edge,v.xpos,v.ypos,v.width,v.height,v.z_index,v.visible, + t.range_mode,t.range_start::text,t.range_end::text FROM osint.board_views v + LEFT JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at`, [sourceBoardId]) + for (const row of views.rows) { + const viewId = randomUUID() + await client.query(`INSERT INTO osint.board_views + (id,board_id,view_type_id,origin_view_id,placement_mode,dock_edge,xpos,ypos,width,height,z_index,visible) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, [viewId,targetBoardId,row.view_type_id,row.id,row.placement_mode,row.dock_edge, + row.xpos,row.ypos,row.width,row.height,row.z_index,row.visible]) + if (row.view_type_id === 'timeline') await client.query( + 'INSERT INTO osint.timeline_views (view_id,range_mode,range_start,range_end) VALUES ($1,$2,$3,$4)', + [viewId,row.range_mode || 'auto',row.range_start,row.range_end]) + } const exhibits = await client.query<{ id: string; exhibit_type_id: string; xpos: number; ypos: number; width: number; height: number diff --git a/server/e2eHarness.ts b/server/e2eHarness.ts index 8d6cccb..178805e 100644 --- a/server/e2eHarness.ts +++ b/server/e2eHarness.ts @@ -30,6 +30,7 @@ process.env.LEVEL_EDITING_ENABLED = 'true' process.env.JWT_SECRET = 'osint-e2e-jwt-secret' process.env.PORT = String(port) process.env.OSINT_MANAGED_SERVER = 'true' +process.env.ASSET_STORAGE_DRIVER = 'memory' const { server, pool } = await import('./index.js') if (!server.listening) await once(server, 'listening') const baseUrl = `http://127.0.0.1:${port}` @@ -49,17 +50,15 @@ state.brief = { body: 'Classify the named people and organizations in this inves { id: '44444444-4444-4444-8444-444444444444', label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' }, { id: '55555555-5555-4555-8555-555555555555', label: 'Difference Engine Bureau', context: 'Issued the archive notice.', expectedPartyKind: 'organization' }, ] } -state.documents = [{ - id: documentId, title: 'Dated source image', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00.000Z', - body: [], regions: [], fileType: 'image', metadata: {}, -}] -state.evidence = [{ +state.exhibits = [{ + id: documentId, type: 'document', title: 'Dated source image', publishedAt: '2021-04-17T12:00:00.000Z', + body: [], regions: [], fileType: 'image', metadata: {}, x: 980, y: 360, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, +}, { id: folderId, type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence', - x: 600, y: 360, width: 260, config: { open: false }, containedDocumentIds: [documentId], + x: 600, y: 360, width: 260, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false, }] state.relations = [{ - id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, - config: { x: 980, y: 360 }, + id: `contains:${folderId}:${documentId}`, fromExhibitId: folderId, toExhibitId: documentId, type: 'contains', sortOrder: 0, }] state.connections = [] state.viewport = { x: 0, y: 28, zoom: 0.7 } diff --git a/server/index.ts b/server/index.ts index 1875cdd..1e2e1ea 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,6 +10,7 @@ import pg from 'pg' import type { CaseState } from '../src/types.js' import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin } from './auth.js' import { createLevelRepository } from './levelRepository.js' +import { createObjectStorageFromEnv } from './objectStorage.js' const { Pool } = pg const databaseUrl = process.env.DATABASE_URL @@ -20,7 +21,9 @@ if (!databaseUrl) { export const pool = new Pool({ connectionString: databaseUrl }) const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true' -const levels = createLevelRepository(pool, editingEnabled) +const objectStorage = createObjectStorageFromEnv() +await objectStorage.initialize() +const levels = createLevelRepository(pool, editingEnabled, objectStorage) function wantsEdit(req: express.Request) { return editingEnabled && req.query.edit === '1' && hasAdminClaim(req) @@ -41,7 +44,7 @@ const upload = multer({ }) app.get('/api/health', async (_req, res) => { - try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', schema: 'osint', editingEnabled }) } + try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', objectStorage: objectStorage.provider, schema: 'osint', editingEnabled }) } catch { res.status(503).json({ ok: false, database: 'unavailable' }) } }) app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) })) @@ -89,12 +92,13 @@ app.get('/api/assets/:id', async (req, res, next) => { try { const asset = await levels.getAsset(req.params.id) if (!asset) return res.status(404).json({ error: 'Asset not found' }) - const inline = asset.mime_type === 'application/pdf' || asset.mime_type.startsWith('image/') || asset.mime_type.startsWith('text/') - res.setHeader('Content-Type', asset.mime_type || 'application/octet-stream') - res.setHeader('Content-Length', asset.byte_size) - res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.original_name)}`) + const inline = asset.mimeType === 'application/pdf' || asset.mimeType.startsWith('image/') || asset.mimeType.startsWith('text/') + res.setHeader('Content-Type', asset.mimeType || 'application/octet-stream') + res.setHeader('Content-Length', asset.byteSize) + res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.originalName)}`) res.setHeader('X-Content-Type-Options', 'nosniff') - res.send(asset.content) + asset.stream.on('error', next) + asset.stream.pipe(res) } catch (error) { next(error) } }) app.post('/api/levels/:id/documents', requireAdmin, upload.single('file'), async (req, res, next) => { @@ -113,7 +117,7 @@ app.get('/api/levels/:id', async (req, res, next) => { }) app.put('/api/levels/:id', async (req, res, next) => { const state = req.body as CaseState - if (!state || state.id !== req.params.id || !Array.isArray(state.evidence) || !Array.isArray(state.connections)) return res.status(400).json({ error: 'Invalid level state' }) + if (!state || state.id !== req.params.id || !Array.isArray(state.exhibits) || !Array.isArray(state.views) || !Array.isArray(state.connections)) return res.status(400).json({ error: 'Invalid level state' }) try { const authorMode = wantsEdit(req) await levels.saveLevel(state, authorMode) diff --git a/server/levelRepository.ts b/server/levelRepository.ts index f8f24fe..4d74746 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -1,10 +1,14 @@ import { createHash, randomUUID } from 'node:crypto' +import { Readable } from 'node:stream' import type { Pool, PoolClient } from 'pg' -import type { BriefConcept, CaseDocument, CaseState, Evidence, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from '../src/types.js' +import type { BoardView, BriefConcept, CaseDocument, CaseState, Evidence, Exhibit, ExhibitRelation, OrganizationKind, PartyKind, SourceFileType } from '../src/types.js' +import { isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js' import { clearBoard, cloneBoard } from './boardClone.js' +import type { ObjectStorage } from './objectStorage.js' export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number } -export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer } +export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer | null; storage_provider: 'postgres' | 's3'; object_key: string | null } +export type AssetResponse = { originalName: string; mimeType: string; byteSize: number; stream: NodeJS.ReadableStream } export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string } export interface LevelRepository { @@ -16,17 +20,17 @@ export interface LevelRepository { getLevel(levelId: string, authorMode?: boolean): Promise saveLevel(state: CaseState, authorMode: boolean): Promise resetLevel(levelId: string): Promise - getAsset(assetId: string): Promise + getAsset(assetId: string): Promise uploadDocument(levelId: string, file: UploadedDocument): Promise } type LevelRow = { id: string; slug: string; board_id: string; title: string; subtitle: string; status: string - viewport_x: number; viewport_y: number; viewport_zoom: number; updated_at: Date + viewport_x: number; viewport_y: number; viewport_zoom: number; updated_at: Date; revision: string source_template_version_id: string | null } type ExhibitRow = { - id: string; exhibit_type_id: 'folder' | 'document' | 'note' | 'event' | 'party'; xpos: number; ypos: number; width: number; hidden: boolean + id: string; exhibit_type_id: Exhibit['type']; xpos: number; ypos: number; width: number; height: number; rotation: number; z_index: number; hidden: boolean title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null asset_id: string | null; published_at: Date | null; occurred_at: Date | null original_name: string | null; mime_type: string | null; byte_size: string | null @@ -48,24 +52,27 @@ function documentType(document: CaseDocument): SourceFileType { const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file'] return allowed.includes(document.fileType) ? document.fileType : 'file' } -function documentKind(type: SourceFileType) { - return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase() -} - -export function createLevelRepository(pool: Pool, editingEnabled: boolean): LevelRepository { +export function createLevelRepository(pool: Pool, editingEnabled: boolean, objectStorage: ObjectStorage): LevelRepository { async function findLevel(client: Pool | PoolClient, slug: string, lock = false) { - const result = await client.query(`SELECT id, slug, board_id, title, subtitle, status, - viewport_x, viewport_y, viewport_zoom, updated_at, source_template_version_id - FROM osint.levels WHERE slug = $1${lock ? ' FOR UPDATE' : ''}`, [slug]) + const result = await client.query(`SELECT l.id,l.slug,l.board_id,l.title,l.subtitle,l.status, + l.viewport_x,l.viewport_y,l.viewport_zoom,l.updated_at,l.source_template_version_id,b.revision::text + FROM osint.levels l JOIN osint.boards b ON b.id=l.board_id WHERE l.slug = $1${lock ? ' FOR UPDATE OF l,b' : ''}`, [slug]) return result.rows[0] || null } + async function createDefaultBoardViews(client: PoolClient, boardId: string) { + const viewId = randomUUID() + await client.query(`INSERT INTO osint.board_views (id,board_id,view_type_id,placement_mode,dock_edge,height) + VALUES ($1,$2,'timeline','docked','bottom',112)`, [viewId, boardId]) + await client.query("INSERT INTO osint.timeline_views (view_id,range_mode) VALUES ($1,'auto')", [viewId]) + } + async function assembleLevel(slug: string, authorMode = false): Promise { const level = await findLevel(pool, slug) if (!level) return null const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult, - aliasesResult, partyEvidenceResult, briefResult, conceptsResult, timelineResult] = await Promise.all([ - pool.query(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden, + aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult] = await Promise.all([ + pool.query(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden, COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title, COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content, f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at, @@ -98,21 +105,23 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve pool.query<{ exhibit_id: string; field_key: string; value: string }>( `SELECT v.exhibit_id, f.field_key, v.value FROM osint.exhibit_metadata_text_values v JOIN osint.metadata_fields f ON f.id = v.field_id WHERE f.board_id = $1 ORDER BY f.field_key`, [level.board_id]), - pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string }>( - `SELECT event_exhibit_id,evidence_exhibit_id FROM osint.event_evidence WHERE board_id=$1 + pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>( + `SELECT event_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.event_evidence WHERE board_id=$1 ORDER BY event_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]), pool.query<{ party_exhibit_id: string; alias: string }>( `SELECT a.party_exhibit_id,a.alias FROM osint.party_aliases a JOIN osint.exhibits e ON e.id=a.party_exhibit_id WHERE e.board_id=$1 ORDER BY a.party_exhibit_id,a.sort_order,a.id`, [level.board_id]), - pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string }>( - `SELECT party_exhibit_id,evidence_exhibit_id FROM osint.party_evidence WHERE board_id=$1 + pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>( + `SELECT party_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.party_evidence WHERE board_id=$1 ORDER BY party_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]), pool.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [level.board_id]), pool.query<{ id: string; label: string; context_text: string; expected_party_kind: PartyKind | null; resolved_party_exhibit_id: string | null }>( `SELECT id,label,context_text,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts WHERE board_id=$1 ORDER BY sort_order,id`, [level.board_id]), - pool.query<{ range_start: string; range_end: string }>( - 'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]), + pool.query<{ id: string; view_type_id: 'timeline'; placement_mode: 'docked' | 'canvas' | 'window'; dock_edge: 'top' | 'right' | 'bottom' | 'left' | null; xpos: number | null; ypos: number | null; width: number | null; height: number; z_index: number; visible: boolean; range_mode: 'auto' | 'fixed'; range_start: string | null; range_end: string | null }>( + `SELECT v.id,v.view_type_id,v.placement_mode,v.dock_edge,v.xpos,v.ypos,v.width,v.height,v.z_index,v.visible, + t.range_mode,t.range_start::text,t.range_end::text FROM osint.board_views v + JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at`, [level.board_id]), ]) const blocks = new Map() @@ -123,43 +132,50 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve }]) const metadata = new Map>() for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value }) - const contained = new Map() - const eventEvidence = new Map() - for (const row of eventEvidenceResult.rows) eventEvidence.set(row.event_exhibit_id, [...(eventEvidence.get(row.event_exhibit_id) || []), row.evidence_exhibit_id]) const aliases = new Map() for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias]) - const partyEvidence = new Map() - for (const row of partyEvidenceResult.rows) partyEvidence.set(row.party_exhibit_id, [...(partyEvidence.get(row.party_exhibit_id) || []), row.evidence_exhibit_id]) - const relations: WidgetRelation[] = membershipsResult.rows.map(row => { - contained.set(row.folder_exhibit_id, [...(contained.get(row.folder_exhibit_id) || []), row.child_exhibit_id]) - return { id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromWidgetId: row.folder_exhibit_id, - toWidgetId: row.child_exhibit_id, type: 'contains', sortOrder: row.sort_order, config: { x: row.xpos, y: row.ypos } } - }) + const relations: ExhibitRelation[] = [ + ...membershipsResult.rows.map(row => ({ id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromExhibitId: row.folder_exhibit_id, + toExhibitId: row.child_exhibit_id, type: 'contains' as const, sortOrder: row.sort_order })), + ...eventEvidenceResult.rows.map(row => ({ id: `supports:${row.event_exhibit_id}:${row.evidence_exhibit_id}`, fromExhibitId: row.event_exhibit_id, + toExhibitId: row.evidence_exhibit_id, type: 'supports' as const, sortOrder: row.sort_order, note: row.note || undefined })), + ...partyEvidenceResult.rows.map(row => ({ id: `concerns:${row.party_exhibit_id}:${row.evidence_exhibit_id}`, fromExhibitId: row.party_exhibit_id, + toExhibitId: row.evidence_exhibit_id, type: 'concerns' as const, sortOrder: row.sort_order, note: row.note || undefined })), + ...exhibitsResult.rows.flatMap(row => row.source_document_id ? [{ id: `source:${row.id}`, fromExhibitId: row.id, toExhibitId: row.source_document_id, + type: 'source' as const, sourceRegionId: row.source_region_key || undefined, sortOrder: 0 }] : []), + ] + const base = (row: ExhibitRow) => ({ id: row.id, title: row.title, x: row.xpos, y: row.ypos, width: row.width, height: row.height, + rotation: row.rotation, zIndex: row.z_index, hidden: row.hidden }) const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => { const type = row.document_type_id || 'file' const publishedAt = row.published_at?.toISOString() - return { id: row.id, title: row.title, kind: documentKind(type), date: publishedAt?.slice(0, 10) || '', publishedAt, + return { ...base(row), type: 'document', publishedAt, body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined, fileName: row.original_name || undefined, mimeType: row.mime_type || undefined, fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} } }) - const evidence: Evidence[] = exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden).map(row => ({ - id: row.id, type: row.exhibit_type_id as Evidence['type'], title: row.title, content: row.content, - sourceDocumentId: row.source_document_id || undefined, sourceRegionId: row.source_region_key || undefined, - eventDate: row.occurred_at?.toISOString(), x: row.xpos, y: row.ypos, width: row.width, - supportingEvidenceIds: eventEvidence.get(row.id) || [], - partyKind: row.party_kind || undefined, organizationKind: row.organization_kind || undefined, - aliases: aliases.get(row.id) || [], relatedEvidenceIds: partyEvidence.get(row.id) || [], - config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.id) || [], - })) + const evidence: Evidence[] = [] + for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden)) { + const common = { ...base(row), title: row.title, content: row.content } + if (row.exhibit_type_id === 'folder') evidence.push({ ...common, type:'folder', isOpen:Boolean(row.is_open) }) + else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() }) + else if (row.exhibit_type_id === 'party') evidence.push({ ...common, type:'party', partyKind:row.party_kind || 'person', organizationKind:row.organization_kind || undefined, aliases:aliases.get(row.id) || [] }) + else if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note' }) + else throw new Error(`Unsupported exhibit type ${row.exhibit_type_id}`) + } + const views: BoardView[] = viewsResult.rows.map(row => ({ id: row.id, type: 'timeline', visible: row.visible, zIndex: row.z_index, + placement: row.placement_mode === 'docked' + ? { mode: 'docked', dockEdge: row.dock_edge || 'bottom', size: row.height } + : { mode: row.placement_mode, x: row.xpos || 0, y: row.ypos || 0, width: row.width || 900, height: row.height }, + rangeMode: row.range_mode, range: row.range_start && row.range_end ? { start: row.range_start, end: row.range_end } : undefined })) const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text, ...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined })) - return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations, - connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id, + return { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations, + connections: connectionsResult.rows.map(row => ({ id: row.id, fromExhibitId: row.from_exhibit_id, toExhibitId: row.to_exhibit_id, label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style, tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })), viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(), - timelineRange: timelineResult.rows[0] ? { start: timelineResult.rows[0].range_start, end: timelineResult.rows[0].range_end } : undefined, + views, revision: Number(level.revision), brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode, sourceTemplateVersionId: level.source_template_version_id || undefined } } @@ -178,27 +194,15 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve } async function replaceBoard(client: PoolClient, level: LevelRow, state: CaseState) { - const documentIds = new Set(state.documents.map(document => requireUuid(document.id, 'Document id'))) - const evidenceIds = new Set(state.evidence.map(exhibit => requireUuid(exhibit.id, 'Exhibit id'))) - const allIds = [...documentIds, ...evidenceIds] - if (new Set(allIds).size !== allIds.length) throw new Error('An id cannot identify both a document and another exhibit') - - const relationList = state.relations || state.evidence.flatMap(exhibit => (exhibit.containedDocumentIds || []).map((documentId, index) => ({ - id: `contains:${exhibit.id}:${documentId}`, fromWidgetId: exhibit.id, toWidgetId: documentId, type: 'contains', sortOrder: index, - }))) - const positions = new Map() - for (const relation of relationList.filter(item => item.type === 'contains')) { - positions.set(relation.toWidgetId, { x: Number(relation.config?.x ?? 100), y: Number(relation.config?.y ?? 100) }) - } - const existing = await client.query<{ id: string; xpos: number; ypos: number }>('SELECT id, xpos, ypos FROM osint.exhibits WHERE board_id = $1', [level.board_id]) - for (const row of existing.rows) if (!positions.has(row.id)) positions.set(row.id, { x: row.xpos, y: row.ypos }) + if (!Array.isArray(state.exhibits) || !Array.isArray(state.views)) throw new Error('Level state must contain exhibits and views') + const documents = state.exhibits.filter(isDocumentExhibit) + const evidence = state.exhibits.filter((exhibit): exhibit is Evidence => !isDocumentExhibit(exhibit)) + const documentIds = new Set(documents.map(document => requireUuid(document.id, 'Document id'))) + const evidenceIds = new Set(evidence.map(exhibit => requireUuid(exhibit.id, 'Exhibit id'))) + const allIds = state.exhibits.map(exhibit => exhibit.id) + if (new Set(allIds).size !== allIds.length) throw new Error('Exhibit ids must be unique within a board') const expectedConceptKinds = new Map((await client.query<{ id: string; expected_party_kind: PartyKind | null }>( 'SELECT id,expected_party_kind FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])).rows.map(row => [row.id, row.expected_party_kind])) - const existingTimelineResult = await client.query<{ range_start: string; range_end: string }>( - 'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]) - const existingTimeline = existingTimelineResult.rows[0] - ? { start: existingTimelineResult.rows[0].range_start, end: existingTimelineResult.rows[0].range_end } - : null await client.query(`UPDATE osint.levels SET title=$2, subtitle=$3, viewport_x=$4, viewport_y=$5, viewport_zoom=$6, updated_at=NOW() WHERE id=$1`, [level.id, state.title, state.subtitle, state.viewport.x, state.viewport.y, state.viewport.zoom]) @@ -208,7 +212,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve await client.query('DELETE FROM osint.event_evidence WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.party_evidence WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.party_relationships WHERE board_id=$1', [level.board_id]) - await client.query('DELETE FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]) + await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [level.board_id]) await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id]) @@ -219,14 +223,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve if (allIds.length) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1 AND NOT (id = ANY($2::uuid[]))', [level.board_id, allIds]) else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id]) - for (const [index, document] of state.documents.entries()) { - const position = positions.get(document.id) || { x: 100, y: 100 } - await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden) - VALUES ($1,$2,'document',$3,$4,174,145,$5,FALSE) - ON CONFLICT (id) DO UPDATE SET exhibit_type_id='document',xpos=$3,ypos=$4,width=174,height=145,z_index=$5,hidden=FALSE,updated_at=NOW()`, - [document.id, level.board_id, position.x, position.y, index]) - await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at) - VALUES ($1,$2,$3,$4,$5)`, [document.id, documentType(document), document.assetId || null, document.title, timestamp(document.publishedAt || document.date)]) + for (const exhibit of state.exhibits) { + await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,rotation,z_index,hidden) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=$7,rotation=$8,z_index=$9,hidden=$10,updated_at=NOW()`, + [exhibit.id, level.board_id, exhibit.type, exhibit.x, exhibit.y, exhibit.width, exhibit.height, exhibit.rotation, exhibit.zIndex, exhibit.hidden]) + } + for (const document of documents) { + await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri) + VALUES ($1,$2,$3,$4,$5,$6,$7)`, [document.id, documentType(document), document.assetId || null, document.title, + timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null]) if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id]) for (const [sortOrder, content] of document.body.entries()) await client.query( 'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content]) @@ -234,22 +240,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve `INSERT INTO osint.document_regions (id,document_exhibit_id,region_key,label,excerpt,occurred_at,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7)`, [randomUUID(), document.id, region.id, region.label, region.excerpt, timestamp(region.date), sortOrder]) } - for (const [index, exhibit] of state.evidence.entries()) { - const type = exhibit.type === 'evidence' ? 'folder' : exhibit.type - const canonicalType = type === 'folder' || type === 'note' || type === 'event' || type === 'party' ? type : 'note' - await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden) - VALUES ($1,$2,$3,$4,$5,$6,160,$7,FALSE) - ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=160,z_index=$7,hidden=FALSE,updated_at=NOW()`, - [exhibit.id, level.board_id, canonicalType, exhibit.x, exhibit.y, exhibit.width, state.documents.length + index]) - if (canonicalType === 'folder') await client.query( + for (const exhibit of evidence) { + if (isFolderExhibit(exhibit)) await client.query( 'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)', - [exhibit.id, exhibit.title, exhibit.content, Boolean(exhibit.config?.open)]) - if (canonicalType === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content]) - if (canonicalType === 'event') await client.query( + [exhibit.id, exhibit.title, exhibit.content, exhibit.isOpen]) + if (exhibit.type === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content]) + if (isEventExhibit(exhibit)) await client.query( 'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)', [exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate)]) - if (canonicalType === 'party') { - const partyKind: PartyKind = exhibit.partyKind === 'organization' ? 'organization' : 'person' + if (isPartyExhibit(exhibit)) { + const partyKind = exhibit.partyKind await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)', [exhibit.id, partyKind, exhibit.title, exhibit.content]) if (partyKind === 'person') await client.query('INSERT INTO osint.person_parties (exhibit_id) VALUES ($1)', [exhibit.id]) @@ -260,51 +260,57 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve } } - for (const relation of relationList.filter(item => item.type === 'contains')) { - if (!evidenceIds.has(relation.fromWidgetId) || !allIds.includes(relation.toWidgetId)) throw new Error('Folder membership references an unknown exhibit') - await client.query(`INSERT INTO osint.folder_memberships (board_id,folder_exhibit_id,child_exhibit_id,sort_order) - VALUES ($1,$2,$3,$4)`, [level.board_id, relation.fromWidgetId, relation.toWidgetId, relation.sortOrder || 0]) - } - for (const event of state.evidence.filter(item => item.type === 'event')) { - for (const [sortOrder, evidenceId] of (event.supportingEvidenceIds || []).entries()) { - if (evidenceId === event.id || !allIds.includes(evidenceId)) throw new Error('Event evidence references an unknown or identical exhibit') - await client.query(`INSERT INTO osint.event_evidence - (board_id,event_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`, - [level.board_id, event.id, evidenceId, sortOrder]) - } - } - for (const party of state.evidence.filter(item => item.type === 'party')) { - for (const [sortOrder, evidenceId] of (party.relatedEvidenceIds || []).entries()) { - if (evidenceId === party.id || !allIds.includes(evidenceId)) throw new Error('Party evidence references an unknown or identical exhibit') - await client.query(`INSERT INTO osint.party_evidence - (board_id,party_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`, - [level.board_id, party.id, evidenceId, sortOrder]) + for (const relation of state.relations) { + if (!allIds.includes(relation.fromExhibitId) || !allIds.includes(relation.toExhibitId) || relation.fromExhibitId === relation.toExhibitId) throw new Error('Relation references an unknown or identical exhibit') + if (relation.type === 'contains') await client.query(`INSERT INTO osint.folder_memberships + (board_id,folder_exhibit_id,child_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`, + [level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder]) + if (relation.type === 'supports') await client.query(`INSERT INTO osint.event_evidence + (board_id,event_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`, + [level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder, relation.note || null]) + if (relation.type === 'concerns') await client.query(`INSERT INTO osint.party_evidence + (board_id,party_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`, + [level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder, relation.note || null]) + if (relation.type === 'source') { + if (!documentIds.has(relation.toExhibitId)) throw new Error('Exhibit source must reference a document') + let regionId: string | null = null + if (relation.sourceRegionId) { + const region = await client.query<{ id: string }>( + 'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [relation.toExhibitId, relation.sourceRegionId]) + regionId = region.rows[0]?.id || null + } + await client.query('INSERT INTO osint.exhibit_sources (exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)', + [relation.fromExhibitId, relation.toExhibitId, regionId]) } } for (const connection of state.connections) { requireUuid(connection.id, 'Connection id') - if (!allIds.includes(connection.fromEvidenceId) || !allIds.includes(connection.toEvidenceId) || connection.fromEvidenceId === connection.toEvidenceId) throw new Error('Connection references an unknown or identical exhibit') + if (!allIds.includes(connection.fromExhibitId) || !allIds.includes(connection.toExhibitId) || connection.fromExhibitId === connection.toExhibitId) throw new Error('Connection references an unknown or identical exhibit') const tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65)))) const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage' const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50)))) const lateralLimit = Math.round(10 + (100 - tightness) * .6) const tagOffset = Math.max(-lateralLimit, Math.min(lateralLimit, Math.round(Number(connection.tagOffset ?? 0)))) await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset) - VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset]) + VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromExhibitId, connection.toExhibitId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset]) } - for (const exhibit of state.evidence.filter(item => item.sourceDocumentId)) { - if (!documentIds.has(exhibit.sourceDocumentId!)) throw new Error('Exhibit source references an unknown document') - let regionId: string | null = null - if (exhibit.sourceRegionId) { - const region = await client.query<{ id: string }>( - 'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [exhibit.sourceDocumentId, exhibit.sourceRegionId]) - regionId = region.rows[0]?.id || null + for (const view of state.views) { + requireUuid(view.id, 'Board view id') + const placement = view.placement + const docked = placement.mode === 'docked' + await client.query(`INSERT INTO osint.board_views + (id,board_id,view_type_id,placement_mode,dock_edge,xpos,ypos,width,height,z_index,visible) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, [view.id, level.board_id, view.type, placement.mode, + placement.mode === 'docked' ? placement.dockEdge : null, placement.mode === 'docked' ? null : placement.x, placement.mode === 'docked' ? null : placement.y, + placement.mode === 'docked' ? null : placement.width, placement.mode === 'docked' ? placement.size : placement.height, view.zIndex, view.visible]) + if (view.type === 'timeline') { + if (view.rangeMode === 'fixed' && (!view.range || !timestamp(view.range.start) || !timestamp(view.range.end) || Date.parse(view.range.end) <= Date.parse(view.range.start))) throw new Error('Timeline end must be after timeline start') + await client.query(`INSERT INTO osint.timeline_views (view_id,range_mode,range_start,range_end) VALUES ($1,$2,$3,$4)`, + [view.id, view.rangeMode, view.rangeMode === 'fixed' ? view.range!.start : null, view.rangeMode === 'fixed' ? view.range!.end : null]) } - await client.query('INSERT INTO osint.exhibit_sources (exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)', - [exhibit.id, exhibit.sourceDocumentId, regionId]) } const fields = new Map() - for (const document of state.documents) for (const key of Object.keys(document.metadata || {})) { + for (const document of documents) for (const key of Object.keys(document.metadata || {})) { if (!fields.has(key)) { const fieldId = randomUUID(); fields.set(key, fieldId) await client.query(`INSERT INTO osint.metadata_fields (id,board_id,field_key,label,value_type) VALUES ($1,$2,$3,$3,'text')`, [fieldId, level.board_id, key]) @@ -313,14 +319,6 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve [document.id, fields.get(key), document.metadata[key]]) } const brief = state.brief || { body: '', concepts: [] } - const savedTimeline = state.timelineRange === undefined ? existingTimeline : state.timelineRange - if (savedTimeline) { - const start = timestamp(savedTimeline.start) - const end = timestamp(savedTimeline.end) - if (!start || !end || Date.parse(end) <= Date.parse(start)) throw new Error('Timeline end must be after timeline start') - await client.query('INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)', - [level.board_id, savedTimeline.start, savedTimeline.end]) - } await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || '']) for (const [sortOrder, concept] of brief.concepts.entries()) { requireUuid(concept.id, 'Brief concept id') @@ -346,6 +344,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')`, [boardId]) await client.query(`INSERT INTO osint.levels (id,slug,board_id,title,subtitle) VALUES ($1,$2,$3,$4,$5)`, [levelId, input.id, boardId, input.title, input.subtitle]) + await createDefaultBoardViews(client, boardId) await client.query('COMMIT') } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } return (await assembleLevel(input.id))! @@ -444,8 +443,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve }, async getAsset(assetId) { if (!uuidPattern.test(assetId)) return null - const result = await pool.query('SELECT original_name,mime_type,byte_size,content FROM osint.assets WHERE id=$1', [assetId]) - return result.rows[0] || null + const result = await pool.query('SELECT original_name,mime_type,byte_size,content,storage_provider,object_key FROM osint.assets WHERE id=$1', [assetId]) + const asset = result.rows[0] + if (!asset) return null + if (asset.storage_provider === 'postgres') { + if (!asset.content) throw new Error(`PostgreSQL asset ${assetId} has no content`) + return { originalName: asset.original_name, mimeType: asset.mime_type, byteSize: Number(asset.byte_size), stream: Readable.from(asset.content) } + } + if (!asset.object_key) throw new Error(`Object asset ${assetId} has no object key`) + const object = await objectStorage.getObject(asset.object_key) + return object ? { originalName: asset.original_name, mimeType: asset.mime_type, byteSize: Number(asset.byte_size), stream: object.stream } : null }, async uploadDocument(levelId, file) { const client = await pool.connect() @@ -455,21 +462,28 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve if (!level) { await client.query('ROLLBACK'); return null } const candidateAssetId = randomUUID(); const exhibitId = randomUUID() const checksum = createHash('sha256').update(file.buffer).digest('hex') - const asset = await client.query<{ id: string }>(`INSERT INTO osint.assets - (id,original_name,mime_type,byte_size,content,checksum_sha256) VALUES ($1,$2,$3,$4,$5,$6) - ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`, - [candidateAssetId, file.originalname, file.mimetype || 'application/octet-stream', file.size, file.buffer, checksum]) + let assetId = (await client.query<{ id: string }>('SELECT id FROM osint.assets WHERE checksum_sha256=$1 AND byte_size=$2 FOR SHARE', [checksum,file.size])).rows[0]?.id + if (!assetId) { + const objectKey = `assets/${checksum.slice(0,2)}/${checksum}` + const stored = await objectStorage.putObject(objectKey,file.buffer,file.mimetype || 'application/octet-stream') + const asset = await client.query<{ id: string }>(`INSERT INTO osint.assets + (id,original_name,mime_type,byte_size,content,checksum_sha256,storage_provider,storage_bucket,object_key,etag) + VALUES ($1,$2,$3,$4,NULL,$5,'s3',$6,$7,$8) + ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`, + [candidateAssetId,file.originalname,file.mimetype || 'application/octet-stream',file.size,checksum,objectStorage.bucket,objectKey,stored.etag || null]) + assetId = asset.rows[0].id + } const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file' await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden) VALUES ($1,$2,'document',100,100,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id]) await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`, - [exhibitId, fileType, asset.rows[0].id, file.originalname]) + [exhibitId, fileType, assetId, file.originalname]) if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId]) await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id]) await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id]) await client.query('COMMIT') - return { id: exhibitId, title: file.originalname, kind: documentKind(fileType), fileType, metadata: {}, date: '', body: [], regions: [], - assetId: asset.rows[0].id, fileName: file.originalname, mimeType: file.mimetype, fileSize: file.size } + return { id: exhibitId,type:'document',title:file.originalname,x:100,y:100,width:174,height:145,rotation:0,zIndex:0,hidden:false, + fileType,metadata:{},body:[],regions:[],assetId,fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size } } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } }, } diff --git a/server/objectStorage.ts b/server/objectStorage.ts new file mode 100644 index 0000000..4f14a5b --- /dev/null +++ b/server/objectStorage.ts @@ -0,0 +1,88 @@ +import { CreateBucketCommand, GetObjectCommand, HeadBucketCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3' +import { Readable } from 'node:stream' + +export type ObjectBody = { stream: Readable; contentLength?: number } + +export interface ObjectStorage { + readonly bucket: string + readonly provider: 's3' | 'memory' + initialize(): Promise + putObject(key: string, body: Buffer, contentType: string): Promise<{ etag?: string }> + getObject(key: string): Promise +} + +export class MemoryObjectStorage implements ObjectStorage { + readonly bucket: string + readonly provider = 'memory' as const + private readonly objects = new Map() + + constructor(bucket = 'osint-test-assets') { this.bucket = bucket } + async initialize() { /* Nothing to initialize. */ } + async putObject(key: string, body: Buffer) { this.objects.set(key, Buffer.from(body)); return {} } + async getObject(key: string) { + const body = this.objects.get(key) + return body ? { stream: Readable.from(body), contentLength: body.byteLength } : null + } +} + +export class S3ObjectStorage implements ObjectStorage { + readonly provider = 's3' as const + readonly bucket: string + private readonly client: S3Client + + constructor(options: { endpoint: string; region: string; accessKey: string; secretKey: string; bucket: string; forcePathStyle: boolean }) { + this.bucket = options.bucket + this.client = new S3Client({ + endpoint: options.endpoint, + region: options.region, + forcePathStyle: options.forcePathStyle, + credentials: { accessKeyId: options.accessKey, secretAccessKey: options.secretKey }, + }) + } + + async initialize() { + try { + await this.client.send(new HeadBucketCommand({ Bucket: this.bucket })) + } catch (error) { + const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode + if (status !== 404) throw error + try { await this.client.send(new CreateBucketCommand({ Bucket: this.bucket })) } + catch (createError) { + const createStatus = (createError as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode + if (createStatus !== 409) throw createError + } + } + } + + async putObject(key: string, body: Buffer, contentType: string) { + const result = await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: body, ContentType: contentType })) + return { etag: result.ETag?.replaceAll('"', '') } + } + + async getObject(key: string) { + try { + const result = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })) + if (!result.Body || typeof (result.Body as NodeJS.ReadableStream).pipe !== 'function') throw new Error(`Object ${key} did not return a Node stream`) + return { stream: result.Body as Readable, contentLength: result.ContentLength } + } catch (error) { + const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode + if (status === 404) return null + throw error + } + } +} + +export function createObjectStorageFromEnv() { + if (process.env.ASSET_STORAGE_DRIVER === 'memory' || process.env.NODE_ENV === 'test') return new MemoryObjectStorage(process.env.S3_BUCKET) + const accessKey = process.env.S3_ACCESS_KEY + const secretKey = process.env.S3_SECRET_KEY + if (!accessKey || !secretKey) throw new Error('S3_ACCESS_KEY and S3_SECRET_KEY are required for MinIO asset storage') + return new S3ObjectStorage({ + endpoint: process.env.S3_ENDPOINT || 'http://127.0.0.1:9000', + region: process.env.S3_REGION || 'us-east-1', + accessKey, + secretKey, + bucket: process.env.S3_BUCKET || 'osint-evidence', + forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== 'false', + }) +} diff --git a/src/App.tsx b/src/App.tsx index 6484b1b..01a2965 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react' -import type { BriefConcept, CaseDocument, CaseState, Connection, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types' -import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, relationPosition, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' -import { documentWidget, exhibitWidget } from './exhibitRegistry' +import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView } from './types' +import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' +import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry' const BOARD_W = 2400 const BOARD_H = 1500 @@ -13,15 +13,24 @@ const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [ function uid(_prefix: string) { return crypto.randomUUID() } function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` } function documentSearchText(document: CaseDocument) { - return [document.title, document.kind, document.date, document.publishedAt, document.fileName, document.mimeType, + return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.fileName, document.mimeType, ...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]), ...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase() } -function connectionPoint(item: Evidence) { - return exhibitWidget(item.type).connectionPoint(item) +function connectionPoint(item: Exhibit) { + return exhibitWidget(item.type).connectionPorts(item)[0] } -type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; evidenceId?: string; documentId?: string } +type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; exhibitId: string } + +const placement = (x: number, y: number, width: number, height: number) => ({ x, y, width, height, rotation: 0, zIndex: 1, hidden: false }) +function replaceDirectedRelations(relations: ExhibitRelation[], type: 'supports' | 'concerns', fromExhibitId: string, targets: string[]) { + const retained = relations.filter(relation => relation.type !== type || relation.fromExhibitId !== fromExhibitId) + return [...retained, ...targets.map((toExhibitId, sortOrder): ExhibitRelation => ({ + id: relations.find(relation => relation.type === type && relation.fromExhibitId === fromExhibitId && relation.toExhibitId === toExhibitId)?.id || uid(type), + type, fromExhibitId, toExhibitId, sortOrder, + }))] +} export function App() { const [caseState, setCaseState] = useState(null) @@ -42,9 +51,9 @@ export function App() { const [editingFolderId, setEditingFolderId] = useState(null) const [editingFileId, setEditingFileId] = useState(null) const [editingEventId, setEditingEventId] = useState(null) - const [newEventDraft, setNewEventDraft] = useState(null) + const [newEventDraft, setNewEventDraft] = useState(null) const [editingPartyId, setEditingPartyId] = useState(null) - const [newPartyDraft, setNewPartyDraft] = useState(null) + const [newPartyDraft, setNewPartyDraft] = useState(null) const [briefOpen, setBriefOpen] = useState(false) const [editingBrief, setEditingBrief] = useState(false) const [editingTimeline, setEditingTimeline] = useState(false) @@ -121,7 +130,7 @@ export function App() { const focusEvidence = (id: string) => { if (!caseState) return - const ev = caseState.evidence.find(e => e.id === id) + const ev = caseState.exhibits.find(e => e.id === id) if (!ev) return setSelected(id) update(s => ({ ...s, viewport: { ...s.viewport, x: 500 - ev.x * s.viewport.zoom, y: 260 - ev.y * s.viewport.zoom } })) @@ -130,14 +139,16 @@ export function App() { const extract = (doc: CaseDocument, regionId: string) => { if (!caseState) return const region = doc.regions.find(r => r.id === regionId)! - const existing = caseState.evidence.find(e => e.sourceDocumentId === doc.id && e.sourceRegionId === regionId) - if (existing) { setOpenDoc(null); focusEvidence(existing.id); return } - const ev: Evidence = { - id: uid('folder'), type: 'folder', title: `${doc.kind} EVIDENCE`, content: region.excerpt, config: { open: false }, - sourceDocumentId: doc.id, sourceRegionId: region.id, containedDocumentIds: [doc.id], - x: 850 + Math.random() * 220, y: 390 + Math.random() * 250, width: 260, + const existingSource = caseState.relations.find(relation => relation.type === 'source' && relation.toExhibitId === doc.id && relation.sourceRegionId === regionId) + if (existingSource) { setOpenDoc(null); focusEvidence(existingSource.fromExhibitId); return } + const ev: FolderExhibit = { + id: uid('folder'), type: 'folder', title: `${documentWidget(doc.fileType).label.toUpperCase()} EVIDENCE`, content: region.excerpt, isOpen: false, + ...placement(850 + Math.random() * 220, 390 + Math.random() * 250, 260, 166), } - update(s => ({ ...s, evidence: [...s.evidence, ev], relations: [...s.relations, { id: `contains:${ev.id}:${doc.id}`, fromWidgetId: ev.id, toWidgetId: doc.id, type: 'contains', sortOrder: 0 }] })) + update(s => ({ ...s, exhibits: [...s.exhibits, ev], relations: [...s.relations, + { id: uid('contains'), fromExhibitId: ev.id, toExhibitId: doc.id, type: 'contains', sortOrder: 0 }, + { id: uid('source'), fromExhibitId: ev.id, toExhibitId: doc.id, type: 'source', sortOrder: 0, sourceRegionId: region.id }, + ] })) setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED') } @@ -145,27 +156,27 @@ export function App() { const content = window.prompt('What do you think this evidence means?')?.trim() if (!content || !caseState) return const { viewport } = caseState - const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 }) - const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...position, width: 108 } - update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id) + const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 }) + const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...placement(position.x, position.y, 108, 154) } + update(s => ({ ...s, exhibits: [...s.exhibits, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id) } const addEvent = () => { if (!caseState) return const { viewport } = caseState - const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 }) - const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.', - supportingEvidenceIds: [], ...position, width: 270 } + const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 }) + const event: EventExhibit = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.', + ...placement(position.x, position.y, 270, 174) } setNewEventDraft(event) } const addParty = () => { if (!caseState) return const { viewport } = caseState - const position = nextOpenBoardPosition(caseState.evidence, { + const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom), }, { width: 280 }) - setNewPartyDraft({ id: uid('party'), type: 'party', partyKind: 'person', title: '', content: '', aliases: [], relatedEvidenceIds: [], ...position, width: 280 }) + setNewPartyDraft({ id: uid('party'), type: 'party', partyKind: 'person', title: '', content: '', aliases: [], ...placement(position.x, position.y, 280, 190) }) } const classifyConcept = (conceptId: string, partyKind: PartyKind) => { @@ -175,15 +186,15 @@ export function App() { const existingId = concept.resolvedPartyExhibitId const partyId = existingId || uid('party') const { viewport } = caseState - const existingParty = caseState.evidence.find(item => item.id === existingId) - const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.evidence, { + const existingParty = caseState.exhibits.find((item): item is PartyExhibit => item.id === existingId && item.type === 'party') + const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom), }, { width: 280 }) - const party: Evidence = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined, - title: concept.label, content: concept.context, aliases: [], relatedEvidenceIds: [], - ...position, width: 280 } + const party: PartyExhibit = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined, + title: concept.label, content: concept.context, aliases: [], + ...placement(position.x, position.y, 280, 190) } update(state => ({ ...state, - evidence: existingId ? state.evidence.map(item => item.id === existingId ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.evidence, party], + exhibits: existingId ? state.exhibits.map(item => item.id === existingId && item.type === 'party' ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.exhibits, party], brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) }, })) setSelected(partyId) @@ -203,14 +214,14 @@ export function App() { const completeThread = (targetId: string) => { if (!linkFrom || !caseState || linkFrom === targetId) return - const existing = caseState.connections.find(connection => (connection.fromEvidenceId === linkFrom && connection.toEvidenceId === targetId) || (connection.fromEvidenceId === targetId && connection.toEvidenceId === linkFrom)) + const existing = caseState.connections.find(connection => (connection.fromExhibitId === linkFrom && connection.toExhibitId === targetId) || (connection.fromExhibitId === targetId && connection.toExhibitId === linkFrom)) if (existing) { setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG') return } - setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 }) + setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 }) setLinkFrom(null) - if (caseState.evidence.some(item => item.id === targetId)) setSelected(targetId) + if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId) } const saveThread = (connection: Connection) => { @@ -306,7 +317,7 @@ export function App() { const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents?edit=1`, { method: 'POST', body: form }) if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) } const document: CaseDocument = await response.json() - update(s => ({ ...s, documents: [...s.documents, document] })) + update(s => ({ ...s, exhibits: [...s.exhibits, document] })) setStatus(`IMPORTED · ${file.name.toUpperCase()}`) } catch (error) { setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED') @@ -317,27 +328,25 @@ export function App() { if (noLevels) return { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} /> if (!caseState) return
GU

GLITCH UNIVERSITY NETWORK TERMINAL

{status}
- const documentById = new Map(caseState.documents.map(document => [document.id, document])) + const documents = documentExhibits(caseState.exhibits) + const evidence = evidenceExhibits(caseState.exhibits) + const documentById = new Map(documents.map(document => [document.id, document])) const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase() - const filteredDocuments = normalizedDocumentQuery ? caseState.documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : caseState.documents + const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed) - const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId)) - const temporalItems: TemporalItem[] = [ - ...caseState.evidence.flatMap(folder => folder.type !== 'folder' ? [] : containedIds(caseState, folder.id).flatMap(documentId => { - const document = documentById.get(documentId) - const date = document?.publishedAt || document?.date - const relation = caseState.relations.find(candidate => candidate.type === 'contains' && candidate.fromWidgetId === folder.id && candidate.toWidgetId === documentId) - return document && date && relation ? [{ id: `folder:${folder.id}:document:${document.id}`, sourceTemporalId: folderIsOpen(folder) ? `file:${relation.id}` : `widget:${folder.id}`, date, label: document.title, kind: 'document' as const, evidenceId: folder.id, documentId: document.id }] : [] - })), - ...caseState.documents.filter(document => !containedDocumentIds.has(document.id) && (document.publishedAt || document.date)).map(document => ({ id: `document:${document.id}`, sourceTemporalId: `document:${document.id}`, date: document.publishedAt || document.date, label: document.title, kind: 'document' as const, documentId: document.id })), - ...caseState.evidence.filter(widget => widget.type === 'event' && widget.eventDate).map(widget => ({ id: `widget:${widget.id}`, sourceTemporalId: `widget:${widget.id}`, date: widget.eventDate!, label: widget.content, kind: 'widget' as const, evidenceId: widget.id })), - ].sort((a, b) => dateValue(a.date) - dateValue(b.date)) - const storyEvents = caseState.evidence.filter(item => item.type === 'event').sort((a, b) => { + const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => exhibitWidget(exhibit.type).temporalFacts(exhibit).map(fact => { + const membership = exhibit.type === 'document' ? caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === exhibit.id) : undefined + const folder = membership ? caseState.exhibits.find(candidate => candidate.id === membership.fromExhibitId && candidate.type === 'folder') as FolderExhibit | undefined : undefined + const sourceTemporalId = folder && !folder.isOpen ? `widget:${folder.id}` : `widget:${exhibit.id}` + return { id: fact.id, sourceTemporalId, date: fact.start, label: fact.label, kind: exhibit.type === 'document' ? 'document' as const : 'widget' as const, exhibitId: exhibit.id } + })).sort((a, b) => dateValue(a.date) - dateValue(b.date)) + const storyEvents = evidence.filter((item): item is EventExhibit => item.type === 'event').sort((a, b) => { if (!a.eventDate) return b.eventDate ? 1 : 0 if (!b.eventDate) return -1 return dateValue(a.eventDate) - dateValue(b.eventDate) }) + const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline') return
GUOSINT BOARD / {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}
@@ -363,13 +372,13 @@ export function App() {
- document.id === selected) ? selected : null} layoutKey={`${docsOpen}:${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${caseState.evidence.map(item => `${item.id}:${item.x}:${item.y}:${String(item.config?.open)}`).join('|')}:${caseState.relations.map(item => `${item.id}:${String(item.config?.x)}:${String(item.config?.y)}`).join('|')}`} /> - `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/> - setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/> - {openDoc && setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />} + document.id === selected) ? selected : null} layoutKey={`${docsOpen}:${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${caseState.exhibits.map(item => `${item.id}:${item.x}:${item.y}:${item.type === 'folder' ? item.isOpen : ''}`).join('|')}`} /> + `${e.id}:${e.x}:${e.y}:${e.type === 'folder' ? e.isOpen : ''}`).join('|')}`}/> + {timelineView?.visible !== false && setEditingTimeline(true)} onSelect={item => { const exhibit = caseState.exhibits.find(candidate => candidate.id === item.exhibitId); if (exhibit?.type === 'document') setOpenDoc(exhibit); else focusEvidence(item.exhibitId) }}/> + } + {openDoc && setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.relations.flatMap(relation => relation.type === 'source' && relation.toExhibitId === openDoc.id ? [relation.sourceRegionId] : [])} />} {editingFolderId && widget.id === editingFolderId)!} + folder={caseState.exhibits.find((widget): widget is FolderExhibit => widget.id === editingFolderId && widget.type === 'folder')!} memberIds={containedIds(caseState, editingFolderId)} - documents={caseState.documents} + documents={documents} canManageContents={canAuthor} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, - evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), + exhibits: state.exhibits.map(widget => widget.id === folder.id ? folder : widget), relations: [ - ...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), - ...members.map((documentId, index) => { - const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId) - const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }) - return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } - }), + ...state.relations.filter(relation => relation.type !== 'contains' || relation.fromExhibitId !== folder.id), + ...members.map((documentId, index): ExhibitRelation => ({ id: state.relations.find(relation => relation.type === 'contains' && relation.fromExhibitId === folder.id && relation.toExhibitId === documentId)?.id || uid('contains'), fromExhibitId: folder.id, toExhibitId: documentId, type: 'contains', sortOrder: index })), ], })) setEditingFolderId(null) setStatus('FOLDER UPDATED') }} />} - {editingFileId && document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>} + {editingFileId && document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/> + } {editingEventId && item.id === editingEventId)!} - evidence={caseState.evidence} - documents={caseState.documents} + event={caseState.exhibits.find((item): item is EventExhibit => item.id === editingEventId && item.type === 'event')!} + exhibits={caseState.exhibits} + relations={caseState.relations} onClose={() => setEditingEventId(null)} - onSave={event => { update(state => ({ ...state, evidence: state.evidence.map(item => item.id === event.id ? event : item) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }} + onSave={(event, supports) => { update(state => ({ ...state, exhibits: state.exhibits.map(item => item.id === event.id ? event : item), relations: replaceDirectedRelations(state.relations, 'supports', event.id, supports) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }} />} {newEventDraft && setNewEventDraft(null)} - onSave={event => { - update(state => ({ ...state, evidence: [...state.evidence, event] })) + onSave={(event, supports) => { + update(state => ({ ...state, exhibits: [...state.exhibits, event], relations: replaceDirectedRelations(state.relations, 'supports', event.id, supports) })) setNewEventDraft(null) setSelected(event.id) setRecentlyCreatedExhibitId(event.id) @@ -464,12 +471,12 @@ export function App() { />} {editingPartyId && item.id === editingPartyId)!} - evidence={caseState.evidence} - documents={caseState.documents} + party={caseState.exhibits.find((item): item is PartyExhibit => item.id === editingPartyId && item.type === 'party')!} + exhibits={caseState.exhibits} + relations={caseState.relations} onClose={() => setEditingPartyId(null)} - onSave={party => { - update(state => ({ ...state, evidence: state.evidence.map(item => item.id === party.id ? party : item) })) + onSave={(party, related) => { + update(state => ({ ...state, exhibits: state.exhibits.map(item => item.id === party.id ? party : item), relations: replaceDirectedRelations(state.relations, 'concerns', party.id, related) })) setEditingPartyId(null) setStatus('PARTY DOSSIER UPDATED') }} @@ -477,12 +484,12 @@ export function App() { {newPartyDraft && setNewPartyDraft(null)} - onSave={party => { - update(state => ({ ...state, evidence: [...state.evidence, party] })) + onSave={(party, related) => { + update(state => ({ ...state, exhibits: [...state.exhibits, party], relations: replaceDirectedRelations(state.relations, 'concerns', party.id, related) })) setNewPartyDraft(null) setSelected(party.id) setRecentlyCreatedExhibitId(party.id) @@ -499,11 +506,11 @@ export function App() { }} />} {editingTimeline && item.date)} onClose={() => setEditingTimeline(false)} onSave={timelineRange => { - update(state => ({ ...state, timelineRange })) + update(state => ({ ...state, views: state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: timelineRange ? 'fixed' : 'auto', range: timelineRange || undefined } : view) })) setEditingTimeline(false) setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC') }} @@ -511,8 +518,8 @@ export function App() { {threadDraft && item.id === threadDraft.fromEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.fromEvidenceId)?.title || 'Exhibit'} - targetName={caseState.evidence.find(item => item.id === threadDraft.toEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.toEvidenceId)?.title || 'Exhibit'} + sourceName={caseState.exhibits.find(item => item.id === threadDraft.fromExhibitId)?.title || 'Exhibit'} + targetName={caseState.exhibits.find(item => item.id === threadDraft.toExhibitId)?.title || 'Exhibit'} isNew={!caseState.connections.some(item => item.id === threadDraft.id)} onClose={() => setThreadDraft(null)} onSave={saveThread} @@ -537,8 +544,8 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le return
GU
GLITCH UNIVERSITY LEVEL ARCHIVE

No investigations found.

The database is ready, but no authored level exists yet.

{canEdit ? :

Add ?edit=1 and enable level editing on the server to begin authoring.

}
} -function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) { - const drag = useRef<{ kind: 'pan' | 'widget' | 'relation' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null) +function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) { + const drag = useRef<{ kind: 'pan' | 'widget' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null) const suppressClick = useRef(false) const touchPoints = useRef(new Map()) const pinchDistance = useRef(null) @@ -549,16 +556,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr const [trashActive, setTrashActive] = useState(false) const trashRef = useRef(null) const trashTarget = useRef(false) - const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence]) + const byId = useMemo(() => new Map(state.exhibits.map(exhibit => [exhibit.id, exhibit])), [state.exhibits]) const containmentRelations = state.relations.filter(relation => relation.type === 'contains') const pointForId = (id: string) => { - const evidence = byId.get(id) - if (evidence) return connectionPoint(evidence) - const relation = containmentRelations.find(item => item.toWidgetId === id) - const folder = relation ? byId.get(relation.fromWidgetId) : undefined - if (!relation || !folder) return undefined - const position = relationPosition(state, relation) - return folderIsOpen(folder) ? { x: position.x + 87, y: position.y + 72 } : connectionPoint(folder) + const exhibit = byId.get(id) + if (!exhibit) return undefined + const membership = exhibit.type === 'document' ? containmentRelations.find(item => item.toExhibitId === id) : undefined + const folder = membership ? byId.get(membership.fromExhibitId) : undefined + return folder?.type === 'folder' && !folder.isOpen ? connectionPoint(folder) : connectionPoint(exhibit) } useEffect(() => { const board = boardRef.current @@ -574,7 +579,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr board.addEventListener('wheel', handleWheelZoom, { passive: false }) return () => board.removeEventListener('wheel', handleWheelZoom) }, [boardRef, update]) - const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget' | 'relation'; id: string }) => { + const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget'; id: string }) => { if ((event.target as HTMLElement).closest('button')) return if (event.pointerType === 'touch') { touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) @@ -587,9 +592,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr } } const widget = target?.kind === 'widget' ? byId.get(target.id) : undefined - const relation = target?.kind === 'relation' ? state.relations.find(candidate => candidate.id === target.id) : undefined - const position = relation ? relationPosition(state, relation) : undefined - drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? position?.x ?? state.viewport.x, originY: widget?.y ?? position?.y ?? state.viewport.y } + drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? state.viewport.x, originY: widget?.y ?? state.viewport.y } setDraggingWidget(target?.kind === 'widget') try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ } } @@ -638,16 +641,15 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr if (drag.current.kind === 'thread-tag' && boardRef.current) { if (drag.current.moved) setExpandedThreadTagId(null) const connection = state.connections.find(item => item.id === drag.current!.id) - const from = connection ? pointForId(connection.fromEvidenceId) : undefined - const to = connection ? pointForId(connection.toEvidenceId) : undefined + const from = connection ? pointForId(connection.fromExhibitId) : undefined + const to = connection ? pointForId(connection.toExhibitId) : undefined if (connection && from && to) { const bounds = boardRef.current.getBoundingClientRect() const pointer = { x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom } const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer) update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: placement.lateralOffset } : item) })) } - } else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } }) - else if (drag.current.kind === 'relation') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), ...next } } : relation) } }) + } else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === drag.current!.id ? { ...exhibit, ...next } : exhibit) } }) else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) })) } const finishDrag = (event: React.PointerEvent) => { @@ -664,7 +666,16 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) onDiscardExhibit(completedDrag.id) trashTarget.current = false } - const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) })) + const toggleFolder = (id: string) => update(s => ({ ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'folder' ? { ...exhibit, isOpen: !exhibit.isOpen } : exhibit) })) + const widgetContext: ExhibitWidgetContext = { exhibits: state.exhibits, relations: state.relations, dispatch: (command: WidgetCommand) => { + if (command.type === 'open-document') onOpenSource(command.documentId) + else if (command.type === 'toggle-folder') toggleFolder(command.folderId) + else if (command.type === 'edit-folder') onEditFolder(command.folderId) + else if (command.type === 'edit-event') onEditEvent(command.eventId) + else if (command.type === 'edit-party') onEditParty(command.partyId) + else if (command.type === 'edit-document') onEditFile(command.documentId) + else if (command.type === 'update-memory-cue') onUpdateDocumentCue(command.documentId, command.cue) + } } const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined return
{ @@ -678,54 +689,32 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
AUTHORIZED CITIZEN SCIENTIST WORKSTATION GU-NET / 04
- {state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; return })} + {state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; return })} {previewOrigin && threadPointer && } - {state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const placement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return })} + {state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; const tagPlacement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return })} - {state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => { - const evidence = byId.get(evidenceId) - let target = evidence ? connectionPoint(evidence) : undefined - if (!target) { - const relation = containmentRelations.find(item => item.toWidgetId === evidenceId) - const folder = relation ? byId.get(relation.fromWidgetId) : undefined - if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder) - } - if (!target) return [] - const origin = connectionPoint(event) - return [] - }))} + {state.relations.filter(relation => relation.type === 'supports').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? : null })} - {state.evidence.filter(party => party.type === 'party').flatMap(party => (party.relatedEvidenceIds || []).flatMap(evidenceId => { - const evidence = byId.get(evidenceId) - let target = evidence ? connectionPoint(evidence) : undefined - if (!target) { - const relation = containmentRelations.find(item => item.toWidgetId === evidenceId) - const folder = relation ? byId.get(relation.fromWidgetId) : undefined - if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder) - } - if (!target) return [] - const origin = connectionPoint(party) - return [] - }))} + {state.relations.filter(relation => relation.type === 'concerns').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? : null })} - {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return })} + {containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return })} - {state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !folderIsOpen(ev) && containedDocuments.some(document => document.id === selected) ? selected : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return
!exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === selected) ? selected : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return
{ e.stopPropagation(); if (linkFrom && e.button === 0) return; if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }} onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }} onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}> -
{definition.heading(ev, containedDocuments)}{String(i + 1).padStart(3, '0')}
- +
{definition.heading(ev, widgetContext)}{String(i + 1).padStart(3, '0')}
+
})} - {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), located = open && selected === document.id, target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return
{ event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'relation', id: relation.id }) }} - onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (linkFrom) onConnectionTarget(document.id); else if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}> -
{definition.label.toUpperCase()}{String((relation.sortOrder || 0) + 1).padStart(2, '0')}
+ {documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && selected === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return
{ event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }} + onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}> +
{definition.label.toUpperCase()}{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}
onUpdateDocumentCue(document.id, cue)}/>
- {document.title} + {document.title}
})}
@@ -817,7 +806,7 @@ function Timeline({ items, range, selected, onSelect, onEdit }: { items: Tempora const ticks = range ? Array.from({ length: 5 }, (_, index) => { const value = start + (end - start) * index / 4; return { value, label: new Date(value).toISOString().slice(5, 10) } }) : Array.from({ length: endYear - startYear + 1 }, (_, index) => { const year = startYear + index; return { value: Date.parse(`${year}-01-01T00:00:00.000Z`), label: String(year) } }) - return
TEMPORAL INDEXTIMELINE
{ticks.map((tick, index) => {tick.label})}{items.map((item, i) => )}
SOURCE SELECTED
+ return
TEMPORAL INDEXTIMELINE
{ticks.map((tick, index) => {tick.label})}{items.map((item, i) => )}
SOURCE SELECTED
} function localDateTime(value?: string) { @@ -866,7 +855,7 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
} -function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; parties: Evidence[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) { +function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) { const [minimized, setMinimized] = useState(false) const partyById = new Map(parties.map(party => [party.id, party])) const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length @@ -895,16 +884,16 @@ function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: ( } -function PartyEditor({ party, evidence, documents, creating = false, onClose, onSave }: { party: Evidence; evidence: Evidence[]; documents: CaseDocument[]; creating?: boolean; onClose: () => void; onSave: (party: Evidence) => void }) { +function PartyEditor({ party, exhibits, relations, creating = false, onClose, onSave }: { party: PartyExhibit; exhibits: Exhibit[]; relations: ExhibitRelation[]; creating?: boolean; onClose: () => void; onSave: (party: PartyExhibit, related: string[]) => void }) { const [name, setName] = useState(party.title) const [summary, setSummary] = useState(party.content) - const [partyKind, setPartyKind] = useState(party.partyKind || 'person') + const [partyKind, setPartyKind] = useState(party.partyKind) const [organizationKind, setOrganizationKind] = useState(party.organizationKind || 'business') - const [aliases, setAliases] = useState((party.aliases || []).join('\n')) - const [related, setRelated] = useState(party.relatedEvidenceIds || []) - const candidates = [...evidence.filter(item => item.id !== party.id && item.type !== 'party').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })), ...documents.map(item => ({ id: item.id, title: item.title, kind: item.fileType.toUpperCase() }))] + const [aliases, setAliases] = useState(party.aliases.join('\n')) + const [related, setRelated] = useState(relations.filter(relation => relation.type === 'concerns' && relation.fromExhibitId === party.id).map(relation => relation.toExhibitId)) + const candidates = exhibits.filter(item => item.id !== party.id && item.type !== 'party').map(item => ({ id: item.id, title: item.title, kind: item.type === 'document' ? item.fileType.toUpperCase() : item.type.toUpperCase() })) const toggle = (id: string) => setRelated(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id]) - return
{ submit.preventDefault(); onSave({ ...party, partyKind, title: name.trim(), content: summary.trim(), organizationKind: partyKind === 'organization' ? organizationKind : undefined, aliases: aliases.split('\n').map(item => item.trim()).filter(Boolean), relatedEvidenceIds: related }) }}> + return
{ submit.preventDefault(); onSave({ ...party, partyKind, title: name.trim(), content: summary.trim(), organizationKind: partyKind === 'organization' ? organizationKind : undefined, aliases: aliases.split('\n').map(item => item.trim()).filter(Boolean) }, related) }}>
{partyKind === 'person' ? : }{creating ? 'Create party dossier' : `Edit ${partyKind} dossier`}
PARTY EXHIBIT · {partyKind.toUpperCase()} {creating && } @@ -919,14 +908,14 @@ function PartyEditor({ party, evidence, documents, creating = false, onClose, on
} -function FolderEditor({ folder, memberIds, documents, canManageContents, onClose, onSave }: { folder: Evidence; memberIds: string[]; documents: CaseDocument[]; canManageContents: boolean; onClose: () => void; onSave: (folder: Evidence, members: string[]) => void }) { +function FolderEditor({ folder, memberIds, documents, canManageContents, onClose, onSave }: { folder: FolderExhibit; memberIds: string[]; documents: CaseDocument[]; canManageContents: boolean; onClose: () => void; onSave: (folder: FolderExhibit, members: string[]) => void }) { const [title, setTitle] = useState(folder.title) const [content, setContent] = useState(folder.content) const [members, setMembers] = useState(memberIds) const toggleMember = (documentId: string) => setMembers(current => current.includes(documentId) ? current.filter(id => id !== documentId) : [...current, documentId]) const submit = (event: React.FormEvent) => { event.preventDefault() - onSave({ ...folder, title: title.trim() || 'UNTITLED EVIDENCE FOLDER', content: content.trim(), containedDocumentIds: members }, members) + onSave({ ...folder, title: title.trim() || 'UNTITLED EVIDENCE FOLDER', content: content.trim() }, members) } return
Edit evidence folder
@@ -938,7 +927,7 @@ function FolderEditor({ folder, memberIds, documents, canManageContents, onClose
{documents.map(document => { const included = members.includes(document.id); return
- {(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'} + {document.publishedAt?.slice(0, 10) || 'UNDATED'}
})}

The folder owns this text and its containment relationships. Publication time and other metadata belong to the individual files.

@@ -947,20 +936,17 @@ function FolderEditor({ folder, memberIds, documents, canManageContents, onClose
} -function EventEditor({ event, evidence, documents, onClose, onSave }: { event: Evidence; evidence: Evidence[]; documents: CaseDocument[]; onClose: () => void; onSave: (event: Evidence) => void }) { +function EventEditor({ event, exhibits, relations, onClose, onSave }: { event: EventExhibit; exhibits: Exhibit[]; relations: ExhibitRelation[]; onClose: () => void; onSave: (event: EventExhibit, supports: string[]) => void }) { const [title, setTitle] = useState(event.title) const [narrative, setNarrative] = useState(event.content) const [occurredAt, setOccurredAt] = useState(localDateTime(event.eventDate)) - const [supports, setSupports] = useState(event.supportingEvidenceIds || []) - const candidates = [ - ...evidence.filter(item => item.id !== event.id && item.type !== 'event').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })), - ...documents.map(document => ({ id: document.id, title: document.title, kind: document.fileType.replaceAll('_', ' ').toUpperCase() })), - ] + const [supports, setSupports] = useState(relations.filter(relation => relation.type === 'supports' && relation.fromExhibitId === event.id).map(relation => relation.toExhibitId)) + const candidates = exhibits.filter(item => item.id !== event.id && item.type !== 'event').map(item => ({ id: item.id, title: item.title, kind: item.type === 'document' ? item.fileType.replaceAll('_', ' ').toUpperCase() : item.type.toUpperCase() })) const toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id]) const submit = (submitEvent: React.FormEvent) => { submitEvent.preventDefault() const eventDate = occurredAt ? new Date(occurredAt).toISOString() : undefined - onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate, supportingEvidenceIds: supports }) + onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate }, supports) } return
Edit reconstructed event
@@ -983,12 +969,12 @@ function EventEditor({ event, evidence, documents, onClose, onSave }: { event: E function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onClose: () => void; onSave: (document: CaseDocument) => void }) { const [title, setTitle] = useState(document.title) const [fileType, setFileType] = useState(document.fileType) - const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt || document.date)) + const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt)) const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value }))) const submit = (event: React.FormEvent) => { event.preventDefault() const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined - onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt, date: publishedAt?.slice(0, 10) || '', metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) }) + onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt, metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) }) } return
Edit source-file metadata
@@ -1019,8 +1005,8 @@ function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocum return
drag.current && setPos({ x: drag.current.px + e.clientX - drag.current.x, y: drag.current.py + e.clientY - drag.current.y })} onPointerUp={() => { drag.current = null }} onDoubleClick={() => setMinimized(v => !v)}>{doc.title}
{!minimized && <> -
GLITCH UNIVERSITY ARCHIVE{doc.kind}
{doc.assetId ? : doc.body.map((line, i) =>

{line}

)}{doc.regions.length > 0 &&
{doc.regions.map(r => )}
}
-
ARCHIVE ITEM · {doc.date}PROVENANCE LOCKED
} +
GLITCH UNIVERSITY ARCHIVE{documentWidget(doc.fileType).label}
{doc.assetId ? : doc.body.map((line, i) =>

{line}

)}{doc.regions.length > 0 &&
{doc.regions.map(r => )}
}
+
ARCHIVE ITEM · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}PROVENANCE LOCKED
}
} diff --git a/src/boardDomain.test.ts b/src/boardDomain.test.ts index a6e3428..45c5ed4 100644 --- a/src/boardDomain.test.ts +++ b/src/boardDomain.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { CaseState, Evidence, WidgetRelation } from './types' +import type { CaseState, ExhibitRelation, FolderExhibit } from './types' import { MAX_BOARD_ZOOM, MIN_BOARD_ZOOM, @@ -49,7 +49,7 @@ describe('red thread geometry', () => { }) }) -const folder: Evidence = { +const folder: FolderExhibit = { id: 'folder-1', type: 'folder', title: 'Folder', @@ -57,7 +57,11 @@ const folder: Evidence = { x: 200, y: 300, width: 260, - config: { open: false }, + height: 166, + rotation: 0, + zIndex: 1, + hidden: false, + isOpen: false, } const state: CaseState = { @@ -65,11 +69,12 @@ const state: CaseState = { id: 'test-level', title: 'Test', subtitle: '', - documents: [], - evidence: [folder], + exhibits: [folder], relations: [], connections: [], viewport: { x: 10, y: 20, zoom: 0.5 }, + views: [], + revision: 0, } describe('board coordinate math', () => { @@ -109,10 +114,10 @@ describe('board coordinate math', () => { it('places new exhibits in deterministic open slots', () => { const preferred = { x: 500, y: 400 } expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred) - expect(nextOpenBoardPosition([{ x: 500, y: 400, width: 280 }], preferred, { width: 280 })).toEqual({ x: 826, y: 400 }) + expect(nextOpenBoardPosition([{ x: 500, y: 400, width: 280, height: 160 }], preferred, { width: 280 })).toEqual({ x: 826, y: 400 }) expect(nextOpenBoardPosition([ - { x: 500, y: 400, width: 280 }, - { x: 826, y: 400, width: 280 }, + { x: 500, y: 400, width: 280, height: 160 }, + { x: 826, y: 400, width: 280, height: 160 }, ], preferred, { width: 280 })).toEqual({ x: 174, y: 400 }) }) }) @@ -147,25 +152,24 @@ describe('timeline projection', () => { }) describe('folder domain behavior', () => { - const relations: WidgetRelation[] = [ - { id: 'later', fromWidgetId: folder.id, toWidgetId: 'doc-2', type: 'contains', sortOrder: 2 }, - { id: 'other', fromWidgetId: 'folder-2', toWidgetId: 'doc-x', type: 'contains', sortOrder: 0 }, - { id: 'first', fromWidgetId: folder.id, toWidgetId: 'doc-1', type: 'contains', sortOrder: 0 }, + const relations: ExhibitRelation[] = [ + { id: 'later', fromExhibitId: folder.id, toExhibitId: 'doc-2', type: 'contains', sortOrder: 2 }, + { id: 'other', fromExhibitId: 'folder-2', toExhibitId: 'doc-x', type: 'contains', sortOrder: 0 }, + { id: 'first', fromExhibitId: folder.id, toExhibitId: 'doc-1', type: 'contains', sortOrder: 0 }, ] it('orders and scopes contained documents by their normalized relations', () => { expect(containedIds({ ...state, relations }, folder.id)).toEqual(['doc-1', 'doc-2']) }) - it('only treats an explicit boolean true as open', () => { + it('stores the open state explicitly on the folder exhibit', () => { expect(folderIsOpen(folder)).toBe(false) - expect(folderIsOpen({ ...folder, config: { open: true } })).toBe(true) - expect(folderIsOpen({ ...folder, config: { open: 'true' } })).toBe(false) + expect(folderIsOpen({ ...folder, isOpen: true })).toBe(true) }) it('retains a configured expanded file position', () => { - const relation = { ...relations[0], config: { x: 720, y: 415 } } - expect(relationPosition({ ...state, relations }, relation)).toEqual({ x: 720, y: 415 }) + const document = { id: 'doc-2', type: 'document' as const, title: 'Source', x: 720, y: 415, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, metadata: {} } + expect(relationPosition({ ...state, exhibits: [...state.exhibits, document], relations }, relations[0])).toEqual({ x: 720, y: 415 }) }) it('derives a deterministic position when a relation has not been moved', () => { @@ -175,38 +179,39 @@ describe('folder domain behavior', () => { it('normalizes legacy containment without changing source ownership', () => { const legacy = { - ...state, + id: state.id, title: state.title, subtitle: state.subtitle, viewport: state.viewport, brief: state.brief, documents: [{ id: 'doc-1', title: 'Image', kind: 'IMAGE', date: '', body: [], regions: [], mimeType: 'image/png' }], evidence: [{ ...folder, type: 'evidence', sourceDocumentId: 'doc-1', containedDocumentIds: ['doc-1'], config: undefined }], relations: undefined, - } as unknown as CaseState + } const normalized = normalizeCase(legacy) - expect(normalized.evidence[0]).toMatchObject({ type: 'folder', config: {}, containedDocumentIds: ['doc-1'] }) - expect(normalized.documents[0]).toMatchObject({ fileType: 'image', metadata: {} }) + expect(normalized.exhibits.find(exhibit => exhibit.type === 'folder')).toMatchObject({ type: 'folder', isOpen: false }) + expect(normalized.exhibits.find(exhibit => exhibit.type === 'document')).toMatchObject({ fileType: 'image', metadata: {} }) expect(normalized.relations).toHaveLength(1) }) }) describe('exhibit disposal', () => { - it('removes an exhibit and every graph reference while retaining source documents', () => { + it('removes an exhibit and every graph reference while retaining unrelated exhibits', () => { const note = { ...folder, id: 'note-1', type: 'note' as const, title: 'Working note' } - const event = { ...folder, id: 'event-1', type: 'event' as const, supportingEvidenceIds: [note.id, 'doc-1'] } - const party = { ...folder, id: 'party-1', type: 'party' as const, relatedEvidenceIds: [note.id] } + const event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined } + const party = { ...folder, id: 'party-1', type: 'party' as const, partyKind: 'person' as const, aliases: [] } + const document = { id: 'doc-1', type: 'document' as const, title: 'Source', x: 20, y: 20, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, metadata: {} } const discarded = discardExhibit({ ...state, - documents: [{ id: 'doc-1', title: 'Source', kind: 'TEXT', date: '', body: [], regions: [], fileType: 'text', metadata: {} }], - evidence: [folder, note, event, party], - relations: [{ id: 'nested', fromWidgetId: folder.id, toWidgetId: note.id, type: 'contains' }], - connections: [{ id: 'thread', fromEvidenceId: note.id, toEvidenceId: party.id }], + exhibits: [folder, document, note, event, party], + relations: [ + { id: 'supports', fromExhibitId: event.id, toExhibitId: note.id, type: 'supports', sortOrder: 0 }, + { id: 'concerns', fromExhibitId: party.id, toExhibitId: note.id, type: 'concerns', sortOrder: 0 }, + ], + connections: [{ id: 'thread', fromExhibitId: note.id, toExhibitId: party.id }], brief: { body: '', concepts: [{ id: 'concept-1', label: 'Unknown', context: '', resolvedPartyExhibitId: note.id }] }, }, note.id) - expect(discarded.documents).toHaveLength(1) - expect(discarded.evidence.map(exhibit => exhibit.id)).not.toContain(note.id) + expect(discarded.exhibits).toContainEqual(document) + expect(discarded.exhibits.map(exhibit => exhibit.id)).not.toContain(note.id) expect(discarded.relations).toEqual([]) expect(discarded.connections).toEqual([]) - expect(discarded.evidence.find(exhibit => exhibit.id === event.id)?.supportingEvidenceIds).toEqual(['doc-1']) - expect(discarded.evidence.find(exhibit => exhibit.id === party.id)?.relatedEvidenceIds).toEqual([]) expect(discarded.brief.concepts[0].resolvedPartyExhibitId).toBeUndefined() }) }) diff --git a/src/boardDomain.ts b/src/boardDomain.ts index e2bd471..5eedac3 100644 --- a/src/boardDomain.ts +++ b/src/boardDomain.ts @@ -1,4 +1,4 @@ -import type { CaseState, Evidence, TimelineRange, Viewport, WidgetRelation } from './types' +import type { BoardView, CaseState, Connection, Exhibit, ExhibitRelation, FolderExhibit, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types' export interface BoardPoint { x: number; y: number } @@ -99,7 +99,7 @@ export function moveBoardPoint(origin: { x: number; y: number }, screenDelta: { } export function nextOpenBoardPosition( - evidence: Pick[], + evidence: Pick[], preferred: { x: number; y: number }, size: { width: number; height?: number }, bounds = { width: 2400, height: 1500 }, @@ -108,7 +108,7 @@ export function nextOpenBoardPosition( const gap = 28 const overlaps = (x: number, y: number) => evidence.some(item => x < item.x + item.width + gap && x + size.width + gap > item.x && - y < item.y + 160 + gap && y + height + gap > item.y) + y < item.y + item.height + gap && y + height + gap > item.y) const xStep = size.width + 46 const yStep = height + 46 for (let row = 0; row < 7; row += 1) { @@ -158,40 +158,33 @@ export function timelinePositionPercent(date: string, range: Pick relation.type === 'contains' && relation.fromWidgetId === widgetId) + .filter(relation => relation.type === 'contains' && relation.fromExhibitId === widgetId) .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) - .map(relation => relation.toWidgetId) + .map(relation => relation.toExhibitId) } -export function folderIsOpen(folder: Evidence) { - return folder.config?.open === true +export function folderIsOpen(folder: FolderExhibit) { + return folder.isOpen } -export function relationPosition(state: CaseState, relation: WidgetRelation) { - const folder = state.evidence.find(widget => widget.id === relation.fromWidgetId) +export function relationPosition(state: CaseState, relation: ExhibitRelation) { + const target = state.exhibits.find(exhibit => exhibit.id === relation.toExhibitId) + if (target) return { x: target.x, y: target.y } + const folder = state.exhibits.find(exhibit => exhibit.id === relation.fromExhibitId) const order = relation.sortOrder || 0 - const configuredX = Number(relation.config?.x) - const configuredY = Number(relation.config?.y) return { - x: Number.isFinite(configuredX) ? configuredX : (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205, - y: Number.isFinite(configuredY) ? configuredY : (folder?.y || 100) - 30 + Math.floor(order / 3) * 185, + x: (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205, + y: (folder?.y || 100) - 30 + Math.floor(order / 3) * 185, } } export function discardExhibit(state: CaseState, exhibitId: string): CaseState { - if (!state.evidence.some(exhibit => exhibit.id === exhibitId)) return state + if (!state.exhibits.some(exhibit => exhibit.id === exhibitId)) return state return { ...state, - evidence: state.evidence - .filter(exhibit => exhibit.id !== exhibitId) - .map(exhibit => ({ - ...exhibit, - containedDocumentIds: exhibit.containedDocumentIds?.filter(id => id !== exhibitId), - supportingEvidenceIds: exhibit.supportingEvidenceIds?.filter(id => id !== exhibitId), - relatedEvidenceIds: exhibit.relatedEvidenceIds?.filter(id => id !== exhibitId), - })), - relations: state.relations.filter(relation => relation.fromWidgetId !== exhibitId && relation.toWidgetId !== exhibitId), - connections: state.connections.filter(connection => connection.fromEvidenceId !== exhibitId && connection.toEvidenceId !== exhibitId), + exhibits: state.exhibits.filter(exhibit => exhibit.id !== exhibitId), + relations: state.relations.filter(relation => relation.fromExhibitId !== exhibitId && relation.toExhibitId !== exhibitId), + connections: state.connections.filter(connection => connection.fromExhibitId !== exhibitId && connection.toExhibitId !== exhibitId), brief: { ...state.brief, concepts: state.brief.concepts.map(concept => concept.resolvedPartyExhibitId === exhibitId @@ -201,30 +194,88 @@ export function discardExhibit(state: CaseState, exhibitId: string): CaseState { } } -export function normalizeCase(state: CaseState): CaseState { - const relations = Array.isArray(state.relations) - ? state.relations - : state.evidence.flatMap(widget => (widget.containedDocumentIds || (widget.sourceDocumentId ? [widget.sourceDocumentId] : [])).map((documentId, index) => ({ - id: `contains:${widget.id}:${documentId}`, - fromWidgetId: widget.id, - toWidgetId: documentId, - type: 'contains', - sortOrder: index, - }))) - const normalized = { ...state, relations } - return { - ...normalized, - brief: state.brief || { body: '', concepts: [] }, - documents: state.documents.map(document => ({ - ...document, - fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'), - metadata: document.metadata || {}, - })), - evidence: state.evidence.map(widget => ({ - ...widget, - type: widget.type === 'evidence' ? 'folder' : widget.type, - config: widget.config || {}, - containedDocumentIds: containedIds(normalized, widget.id), - })), +export function defaultTimelineView(range?: TimelineRange | null): BoardView { + return { id: crypto.randomUUID(), type: 'timeline', placement: { mode: 'docked', dockEdge: 'bottom', size: 112 }, visible: true, zIndex: 0, + rangeMode: range ? 'fixed' : 'auto', range: range || undefined } +} + +type LegacyCaseState = { + id: string + title: string + subtitle: string + viewport: Viewport + brief?: CaseState['brief'] + updatedAt?: string + levelStatus?: string + sourceTemplateVersionId?: string + editingAllowed?: boolean + revision?: number + exhibits?: Exhibit[] + views?: BoardView[] + documents?: Array> + evidence?: Array> + timelineRange?: TimelineRange | null + relations?: Array> + connections?: Array> +} + +function placement(item: Record, defaults: { width: number; height: number }, index: number) { + return { x: Number(item.x ?? 100), y: Number(item.y ?? 100), width: Number(item.width ?? defaults.width), height: Number(item.height ?? defaults.height), + rotation: Number(item.rotation ?? 0), zIndex: Number(item.zIndex ?? index), hidden: Boolean(item.hidden) } +} + +function sourceFileType(value: unknown, mimeType: unknown): SourceFileType { + const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file'] + if (allowed.includes(value as SourceFileType)) return value as SourceFileType + return String(mimeType || '').startsWith('image/') ? 'image' : mimeType === 'application/pdf' ? 'pdf' : 'file' +} + +/** Normalizes current API state and upgrades disposable pre-registry browser caches. */ +export function normalizeCase(input: CaseState | LegacyCaseState): CaseState { + const state = input as LegacyCaseState + if (Array.isArray(state.exhibits)) { + return { id: state.id, title: state.title, subtitle: state.subtitle, viewport: state.viewport, + relations: (state.relations || []) as unknown as ExhibitRelation[], connections: (state.connections || []) as unknown as Connection[], + revision: Number(state.revision || 0), brief: state.brief || { body: '', concepts: [] }, + views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)], + exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })), + updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed, + } } + + const legacyEvidence = state.evidence || [] + const legacyRelations = state.relations || [] + const documentPositions = new Map(legacyRelations.filter(relation => relation.type === 'contains').map(relation => [String(relation.toWidgetId), { + x: Number((relation.config as Record | undefined)?.x ?? 100), y: Number((relation.config as Record | undefined)?.y ?? 100), + }])) + const documents: Exhibit[] = (state.documents || []).map((document, index) => ({ + id: String(document.id), type: 'document', title: String(document.title || ''), + ...placement({ ...document, ...(documentPositions.get(String(document.id)) || {}) }, { width: 174, height: 145 }, index), + publishedAt: String(document.publishedAt || document.date || '') || undefined, capturedAt: String(document.capturedAt || '') || undefined, + sourceUri: String(document.sourceUri || '') || undefined, body: Array.isArray(document.body) ? document.body.map(String) : [], + regions: Array.isArray(document.regions) ? document.regions as never[] : [], assetId: String(document.assetId || '') || undefined, + fileName: String(document.fileName || '') || undefined, mimeType: String(document.mimeType || '') || undefined, + fileSize: document.fileSize === undefined ? undefined : Number(document.fileSize), + fileType: sourceFileType(document.fileType, document.mimeType), + metadata: document.metadata && typeof document.metadata === 'object' ? document.metadata as Record : {}, + } as Exhibit)) + const evidence: Exhibit[] = legacyEvidence.map((item, index) => { + const type = item.type === 'evidence' ? 'folder' : item.type + const common = { id: String(item.id), type, title: String(item.title || ''), content: String(item.content || ''), ...placement(item, { width: 240, height: 160 }, documents.length + index) } + if (type === 'folder') return { ...common, type: 'folder', isOpen: (item.config as Record | undefined)?.open === true } + if (type === 'event') return { ...common, type: 'event', eventDate: String(item.eventDate || '') || undefined } + if (type === 'party') return { ...common, type: 'party', partyKind: item.partyKind === 'organization' ? 'organization' : 'person', organizationKind: item.organizationKind as OrganizationKind | undefined, aliases: Array.isArray(item.aliases) ? item.aliases.map(String) : [] } + return { ...common, type: 'note' } + }) + const derivedRelations: ExhibitRelation[] = [ + ...legacyRelations.filter(relation => relation.type === 'contains').map(relation => ({ id: String(relation.id), type: 'contains' as const, fromExhibitId: String(relation.fromWidgetId), toExhibitId: String(relation.toWidgetId), sortOrder: Number(relation.sortOrder || 0) })), + ...legacyEvidence.flatMap(item => Array.isArray(item.supportingEvidenceIds) ? item.supportingEvidenceIds.map((id, index) => ({ id: `supports:${item.id}:${id}`, type: 'supports' as const, fromExhibitId: String(item.id), toExhibitId: String(id), sortOrder: index })) : []), + ...legacyEvidence.flatMap(item => Array.isArray(item.relatedEvidenceIds) ? item.relatedEvidenceIds.map((id, index) => ({ id: `concerns:${item.id}:${id}`, type: 'concerns' as const, fromExhibitId: String(item.id), toExhibitId: String(id), sortOrder: index })) : []), + ...legacyEvidence.flatMap(item => item.sourceDocumentId ? [{ id: `source:${item.id}`, type: 'source' as const, fromExhibitId: String(item.id), toExhibitId: String(item.sourceDocumentId), sourceRegionId: String(item.sourceRegionId || '') || undefined, sortOrder: 0 }] : []), + ] + const connections: Connection[] = (state.connections || []).map(connection => ({ ...connection, id: String(connection.id), + fromExhibitId: String(connection.fromExhibitId || connection.fromEvidenceId), toExhibitId: String(connection.toExhibitId || connection.toEvidenceId) } as Connection)) + return { id: state.id, title: state.title, subtitle: state.subtitle, exhibits: [...documents, ...evidence], relations: derivedRelations, connections, + views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, revision: Number(state.revision || 0), + updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed } } diff --git a/src/exhibitRegistry.test.ts b/src/exhibitRegistry.test.ts index 1b2c9df..8d180ce 100644 --- a/src/exhibitRegistry.test.ts +++ b/src/exhibitRegistry.test.ts @@ -1,13 +1,12 @@ import { describe, expect, it } from 'vitest' -import type { EvidenceType, SourceFileType } from './types' +import type { SourceFileType } from './types' import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry' describe('frontend exhibit registry', () => { - it('registers every API exhibit type and keeps legacy evidence on the folder renderer', () => { - const types: EvidenceType[] = ['folder', 'evidence', 'note', 'event', 'party'] - expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual(types.sort()) - expect(exhibitWidget('evidence')).toBe(exhibitWidget('folder')) - expect(exhibitWidget('event').heading({} as never, [])).toContain('THIS HAPPENED') + it('registers every normalized exhibit type', () => { + const types = ['folder', 'document', 'note', 'event', 'party'] as const + expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual([...types].sort()) + expect(exhibitWidget('event').heading({} as never, { exhibits: [], relations: [], dispatch: () => {} })).toContain('THIS HAPPENED') }) it('registers every normalized document type with an explicit renderer', () => { diff --git a/src/exhibitRegistry.tsx b/src/exhibitRegistry.tsx index a6e76dc..43241ff 100644 --- a/src/exhibitRegistry.tsx +++ b/src/exhibitRegistry.tsx @@ -1,118 +1,135 @@ import type { ComponentType } from 'react' import { BookOpen, Building2, CalendarClock, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil, UserRound } from 'lucide-react' -import type { CaseDocument, Evidence, EvidenceType, SourceFileType } from './types' -import { folderIsOpen } from './boardDomain' +import type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types' -export type ExhibitWidgetProps = { - exhibit: Evidence - documents: CaseDocument[] - onOpenSource: (id: string) => void - onToggleFolder: (id: string) => void - onEditFolder: (id: string) => void - onEditEvent: (id: string) => void - onEditParty: (id: string) => void +export type WidgetCommand = + | { type: 'open-document'; documentId: string } + | { type: 'toggle-folder'; folderId: string } + | { type: 'edit-folder'; folderId: string } + | { type: 'edit-event'; eventId: string } + | { type: 'edit-party'; partyId: string } + | { type: 'edit-document'; documentId: string } + | { type: 'update-memory-cue'; documentId: string; cue: string } + +export type ExhibitWidgetContext = { + exhibits: Exhibit[] + relations: ExhibitRelation[] + dispatch: (command: WidgetCommand) => void } +export type ExhibitWidgetProps = { exhibit: Exhibit; context: ExhibitWidgetContext } +export type WidgetCapabilities = { movable: boolean; resizable: boolean; connectable: boolean; discardable: boolean; dockable: boolean } +export type ConnectionPort = { id: string; x: number; y: number } + export type ExhibitWidgetDefinition = { - visualType: Exclude - heading: (exhibit: Evidence, documents: CaseDocument[]) => string - connectionPoint: (exhibit: Evidence) => { x: number; y: number } + modelKind: 'exhibit' + visualType: ExhibitType + shell: 'card' | 'document' + defaultSize: { width: number; height: number } + capabilities: WidgetCapabilities + heading: (exhibit: Exhibit, context: ExhibitWidgetContext) => string + connectionPorts: (exhibit: Exhibit) => ConnectionPort[] + temporalFacts: (exhibit: Exhibit) => TemporalFact[] + searchText: (exhibit: Exhibit) => string Component: ComponentType } -function FolderWidget({ exhibit, documents, onOpenSource, onToggleFolder, onEditFolder }: ExhibitWidgetProps) { +const relationsFrom = (context: ExhibitWidgetContext, exhibitId: string, type: ExhibitRelation['type']) => context.relations + .filter(relation => relation.type === type && relation.fromExhibitId === exhibitId) + .sort((a, b) => a.sortOrder - b.sortOrder) + +function FolderWidget({ exhibit, context }: ExhibitWidgetProps) { + if (exhibit.type !== 'folder') return null + const documents = relationsFrom(context, exhibit.id, 'contains').flatMap(relation => { + const document = context.exhibits.find(candidate => candidate.id === relation.toExhibitId) + return document?.type === 'document' ? [document] : [] + }) return

{exhibit.title}

{exhibit.content}

- {!folderIsOpen(exhibit) &&
{documents.slice(0, 3).map(document => )}{documents.length > 3 && + {documents.length - 3} MORE FILES}
} -
+ {!exhibit.isOpen &&
{documents.slice(0,3).map(document => )}{documents.length > 3 && + {documents.length - 3} MORE FILES}
} +
} -function StandardWidget({ exhibit, onOpenSource }: ExhibitWidgetProps) { +function NoteWidget({ exhibit, context }: ExhibitWidgetProps) { + if (exhibit.type !== 'note') return null + const source = relationsFrom(context, exhibit.id, 'source')[0] return

{exhibit.title}

{exhibit.content}

- {exhibit.eventDate && } - {exhibit.sourceDocumentId && } + {source && }
} -function EventWidget({ exhibit, onEditEvent }: ExhibitWidgetProps) { - const supportCount = exhibit.supportingEvidenceIds?.length || 0 +function EventWidget({ exhibit, context }: ExhibitWidgetProps) { + if (exhibit.type !== 'event') return null + const supportCount = relationsFrom(context,exhibit.id,'supports').length return

{exhibit.title}

{exhibit.content}

-
{supportCount} SUPPORTING EXHIBIT{supportCount === 1 ? '' : 'S'}
+
{supportCount} SUPPORTING EXHIBIT{supportCount === 1 ? '' : 'S'}
} -function PartyWidget({ exhibit, onEditParty }: ExhibitWidgetProps) { +function PartyWidget({ exhibit, context }: ExhibitWidgetProps) { + if (exhibit.type !== 'party') return null const person = exhibit.partyKind === 'person' - return
{person ? : }

{exhibit.title}

{person ? 'PERSON' : (exhibit.organizationKind || 'ORGANIZATION').replaceAll('_', ' ').toUpperCase()}
+ const evidenceCount = relationsFrom(context,exhibit.id,'concerns').length + return
{person ? : }

{exhibit.title}

{person ? 'PERSON' : (exhibit.organizationKind || 'ORGANIZATION').replaceAll('_',' ').toUpperCase()}

{exhibit.content || 'No dossier summary yet.'}

- {(exhibit.aliases?.length || 0) > 0 &&
AKA · {exhibit.aliases!.join(' · ')}
} -
{exhibit.relatedEvidenceIds?.length || 0} ASSOCIATED EXHIBITS
+ {exhibit.aliases.length > 0 &&
AKA · {exhibit.aliases.join(' · ')}
} +
{evidenceCount} ASSOCIATED EXHIBITS
} -const standardPoint = (exhibit: Evidence) => ({ x: exhibit.x + exhibit.width / 2, y: exhibit.y + 68 }) -const folderDefinition: ExhibitWidgetDefinition = { - visualType: 'folder', heading: (_exhibit, documents) => `EVIDENCE FOLDER / ${documents.length}`, - connectionPoint: standardPoint, Component: FolderWidget, +function DocumentWidget({ exhibit }: ExhibitWidgetProps) { + if (exhibit.type !== 'document') return null + return {exhibit.title} } -export const exhibitWidgetRegistry: Record = { - folder: folderDefinition, - evidence: folderDefinition, - note: { visualType: 'note', heading: () => 'INVESTIGATOR / NOTE', connectionPoint: exhibit => ({ x: exhibit.x + 54, y: exhibit.y + 12 }), Component: StandardWidget }, - event: { visualType: 'event', heading: () => 'EVENT / THIS HAPPENED', connectionPoint: standardPoint, Component: EventWidget }, - party: { visualType: 'party', heading: exhibit => exhibit.partyKind === 'person' ? 'PARTY / PERSON DOSSIER' : 'PARTY / ORGANIZATION DOSSIER', connectionPoint: standardPoint, Component: PartyWidget }, +const standardPorts = (exhibit: Exhibit) => [{ id:'centre',x:exhibit.x + exhibit.width / 2,y:exhibit.y + exhibit.height / 2 }] +const notePorts = (exhibit: Exhibit) => [{ id:'knot',x:exhibit.x + exhibit.width / 2,y:exhibit.y + 12 }] +const standardCapabilities: WidgetCapabilities = { movable:true,resizable:false,connectable:true,discardable:true,dockable:false } +const searchable = (exhibit: Exhibit) => exhibit.type === 'document' + ? [exhibit.title,...exhibit.body,...Object.values(exhibit.metadata)].join('\n').toLocaleLowerCase() + : [exhibit.title,exhibit.content].join('\n').toLocaleLowerCase() + +export const exhibitWidgetRegistry: Record = { + folder: { modelKind:'exhibit',visualType:'folder',shell:'card',defaultSize:{width:260,height:166},capabilities:standardCapabilities, + heading:(exhibit,context) => `EVIDENCE FOLDER / ${relationsFrom(context,exhibit.id,'contains').length}`,connectionPorts:standardPorts,temporalFacts:() => [],searchText:searchable,Component:FolderWidget }, + document: { modelKind:'exhibit',visualType:'document',shell:'document',defaultSize:{width:174,height:145},capabilities:standardCapabilities, + heading:exhibit => exhibit.type === 'document' ? documentWidget(exhibit.fileType).label.toUpperCase() : 'DOCUMENT',connectionPorts:standardPorts, + temporalFacts:exhibit => exhibit.type === 'document' ? [ + ...(exhibit.publishedAt ? [{ id:`${exhibit.id}:published`,exhibitId:exhibit.id,kind:'published' as const,start:exhibit.publishedAt,label:exhibit.title }] : []), + ...(exhibit.capturedAt ? [{ id:`${exhibit.id}:captured`,exhibitId:exhibit.id,kind:'captured' as const,start:exhibit.capturedAt,label:`${exhibit.title} captured` }] : []), + ...exhibit.regions.flatMap(region => region.date ? [{ id:`${exhibit.id}:region:${region.id}`,exhibitId:exhibit.id,kind:'region_date' as const,start:region.date,label:region.label }] : []), + ] : [],searchText:searchable,Component:DocumentWidget }, + note: { modelKind:'exhibit',visualType:'note',shell:'card',defaultSize:{width:108,height:154},capabilities:standardCapabilities, + heading:() => 'INVESTIGATOR / NOTE',connectionPorts:notePorts,temporalFacts:() => [],searchText:searchable,Component:NoteWidget }, + event: { modelKind:'exhibit',visualType:'event',shell:'card',defaultSize:{width:270,height:174},capabilities:standardCapabilities, + heading:() => 'EVENT / THIS HAPPENED',connectionPorts:standardPorts,temporalFacts:exhibit => exhibit.type === 'event' && exhibit.eventDate + ? [{ id:`${exhibit.id}:occurred`,exhibitId:exhibit.id,kind:'occurred',start:exhibit.eventDate,label:exhibit.content }] : [],searchText:searchable,Component:EventWidget }, + party: { modelKind:'exhibit',visualType:'party',shell:'card',defaultSize:{width:280,height:190},capabilities:standardCapabilities, + heading:exhibit => exhibit.type === 'party' && exhibit.partyKind === 'person' ? 'PARTY / PERSON DOSSIER' : 'PARTY / ORGANIZATION DOSSIER',connectionPorts:standardPorts,temporalFacts:() => [],searchText:searchable,Component:PartyWidget }, } -export function exhibitWidget(type: EvidenceType) { - return exhibitWidgetRegistry[type] || exhibitWidgetRegistry.note -} +export function exhibitWidget(type: ExhibitType) { return exhibitWidgetRegistry[type] } type DocumentWidgetProps = { document: CaseDocument; source: string; onMemoryCue?: (cue: string) => void } -export type DocumentWidgetDefinition = { - label: string - Preview: ComponentType - Asset: ComponentType -} +export type DocumentWidgetDefinition = { label:string; Preview:ComponentType; Asset:ComponentType } -function ImagePreview({ document, source }: DocumentWidgetProps) { - return document.assetId ? : -} -function GenericPreview({ document }: DocumentWidgetProps) { - return
{document.kind}
-} -function TextPreview({ document, onMemoryCue }: DocumentWidgetProps) { - const excerpt = document.body.filter(Boolean).slice(0, 2).join(' ') - return
-

{excerpt || document.title}

-