Adding working board

This commit is contained in:
2026-08-17 09:24:53 +02:00
parent 35685f765a
commit 0237da74cf
18 changed files with 1365 additions and 738 deletions
+79
View File
@@ -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] 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] Turn the successful solve path into an end-to-end acceptance test.
- [x] Perform a manual playability and visual-polish pass before production deployment. - [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.
+58
View File
@@ -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.';
+16
View File
@@ -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.';
+396 -1
View File
@@ -8,6 +8,7 @@
"name": "gupi-osint-board", "name": "gupi-osint-board",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1111.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "2.8.5", "cors": "2.8.5",
"dotenv": "16.5.0", "dotenv": "16.5.0",
@@ -41,6 +42,314 @@
"node": "^20.0.0 || >=22.0.0" "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": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1179,6 +1488,87 @@
"win32" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1668,6 +2058,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -3765,7 +4161,6 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": { "node_modules/tsx": {
+1
View File
@@ -17,6 +17,7 @@
"test:e2e": "npm run build && playwright test" "test:e2e": "npm run build && playwright test"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1111.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "2.8.5", "cors": "2.8.5",
"dotenv": "16.5.0", "dotenv": "16.5.0",
+12 -15
View File
@@ -29,10 +29,6 @@ function requireOk(response: Response, action: string) {
return response.text().then(body => { throw new Error(`${action} failed (${response.status}): ${body}`) }) 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) { async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument, authorization?: string) {
if (!document.asset) return undefined if (!document.asset) return undefined
const assetPath = path.resolve(manifestDir, document.asset) const assetPath = path.resolve(manifestDir, document.asset)
@@ -55,12 +51,15 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
}), 'Create authoring level') }), 'Create authoring level')
const state = await createdResponse.json() as CaseState const state = await createdResponse.json() as CaseState
const documentPositions = new Map<string, { x: number; y: number }>()
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<string, CaseDocument>() const documents = new Map<string, CaseDocument>()
for (const source of manifest.documents) { for (const source of manifest.documents) {
const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source, authorization) const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source, authorization)
documents.set(source.key, { documents.set(source.key, {
id: uploaded?.id || randomUUID(), title: source.title, kind: documentKind(source.fileType), id: uploaded?.id || randomUUID(), type: 'document', title: source.title, publishedAt: source.publishedAt,
date: source.publishedAt.slice(0, 10), 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, body: source.body || [], regions: [], assetId: uploaded?.assetId,
fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize, fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize,
fileType: source.fileType, metadata: source.metadata || {}, 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()])) 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.brief = { body: manifest.brief.body, concepts: manifest.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) }
state.timelineRange = manifest.timelineRange state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: manifest.timelineRange ? 'fixed' : 'auto', range: manifest.timelineRange } : view)
state.documents = [...documents.values()] const folders = manifest.folders.map(folder => ({
state.evidence = manifest.folders.map(folder => ({
id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content, 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 }, x: folder.x, y: folder.y, width: folder.width, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
containedDocumentIds: folder.members.map(key => documents.get(key)!.id), } as const))
})) state.exhibits = [...documents.values(), ...folders]
state.relations = manifest.folders.flatMap((folder, folderIndex) => folder.members.map((key, memberIndex) => { state.relations = manifest.folders.flatMap(folder => folder.members.map((key, memberIndex) => {
const document = documents.get(key) const document = documents.get(key)
if (!document) throw new Error(`Folder ${folder.key} refers to unknown document ${key}`) if (!document) throw new Error(`Folder ${folder.key} refers to unknown document ${key}`)
return { return {
id: `contains:${folderIds.get(folder.key)}:${document.id}`, id: `contains:${folderIds.get(folder.key)}:${document.id}`,
fromWidgetId: folderIds.get(folder.key)!, toWidgetId: document.id, type: 'contains', sortOrder: memberIndex, fromExhibitId: folderIds.get(folder.key)!, toExhibitId: document.id, type: 'contains' as const, sortOrder: memberIndex,
config: { x: folder.x + 70 + memberIndex * 205, y: folder.y + 230 + folderIndex * 35 },
} }
})) }))
state.connections = [] state.connections = []
+44 -197
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
import pg from 'pg' import pg from 'pg'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import { afterAll, beforeAll, describe, expect, it } from 'vitest' 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' import { runMigrations } from './migrations.js'
const { Client } = pg 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 () => { beforeAll(async () => {
const adminUrl = new URL(baseDatabaseUrl!) const adminUrl = new URL(baseDatabaseUrl!)
adminUrl.pathname = '/postgres' adminUrl.pathname = '/postgres'
@@ -46,13 +48,13 @@ suite('level persistence API', () => {
const testUrl = new URL(baseDatabaseUrl!) const testUrl = new URL(baseDatabaseUrl!)
testUrl.pathname = `/${databaseName}` testUrl.pathname = `/${databaseName}`
const databaseUrl = testUrl.toString() const databaseUrl = testUrl.toString()
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined)
await runMigrations(databaseUrl, migrationsDir, () => undefined)
const port = await availablePort() const port = await availablePort()
process.env.DATABASE_URL = databaseUrl process.env.DATABASE_URL = databaseUrl
process.env.LEVEL_EDITING_ENABLED = 'true' process.env.LEVEL_EDITING_ENABLED = 'true'
process.env.JWT_SECRET = 'osint-integration-jwt-secret' process.env.JWT_SECRET = 'osint-integration-jwt-secret'
process.env.ASSET_STORAGE_DRIVER = 'memory'
process.env.PORT = String(port) process.env.PORT = String(port)
const serverModule = await import('./index.js') const serverModule = await import('./index.js')
appServer = serverModule.server appServer = serverModule.server
@@ -69,217 +71,62 @@ suite('level persistence API', () => {
await adminClient.end() 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 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`, { const createResponse = await adminFetch(`${baseUrl}/api/levels`, {
method: 'POST', method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
}) })
expect(createResponse.status).toBe(201) expect(createResponse.status).toBe(201)
const state = await createResponse.json() as CaseState 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.viewport = { x: 91, y: -42, zoom: 0.85 }
state.timelineRange = { start: '2021-04-01', end: '2021-04-30' }
const documentId = randomUUID() 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 folderId = randomUUID() const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
const noteId = randomUUID() const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) }
const eventId = randomUUID() 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 personConceptId = randomUUID() 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) }
const organizationConceptId = randomUUID() state.exhibits = [document, folder, note, event, party]
state.brief = { body: 'Identify Ada Lovelace and Analytical Engines Ltd in the source material.', concepts: [ state.relations = [
{ id: personConceptId, label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' }, { id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, type: 'contains', sortOrder: 0 },
{ id: organizationConceptId, label: 'Analytical Engines Ltd', context: 'Issued the filing.', expectedPartyKind: 'organization' }, { 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 },
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: {} }] { id: randomUUID(), fromExhibitId: event.id, toExhibitId: note.id, type: 'supports', sortOrder: 1 },
state.evidence = [ { id: randomUUID(), fromExhibitId: party.id, toExhibitId: document.id, type: 'concerns', sortOrder: 0 },
{ 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 },
] ]
state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }] state.connections = [{ id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }]
state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: documentId, 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 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' })
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 const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(loaded.viewport).toEqual(state.viewport) expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
expect(loaded.timelineRange).toEqual(state.timelineRange) expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } }) expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
expect(loaded.relations[0]).toMatchObject({ id: `contains:${folderId}:${documentId}`, config: { x: 1051, y: 417 } }) expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
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)
const upload = new FormData() const upload = new FormData()
upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt') 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 }) const uploadResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload })
expect(uploadResponse.status).toBe(201) expect(uploadResponse.status).toBe(201)
const uploaded = await uploadResponse.json() as CaseState['documents'][number] const uploaded = await uploadResponse.json() as DocumentExhibit
expect(uploaded).toMatchObject({ title: 'smoke-evidence.txt', fileName: 'smoke-evidence.txt', mimeType: 'text/plain', fileType: 'text' }) expect(uploaded).toMatchObject({ type: 'document', fileName: 'smoke-evidence.txt', fileType: 'text' })
expect(uploaded.assetId).toBeTruthy()
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence') 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 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 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' }),
})
expect(templateResponse.status).toBe(201) expect(templateResponse.status).toBe(201)
expect(await templateResponse.json()).toMatchObject({ slug: 'smoke-template', currentVersion: 1, versionCount: 1 }) 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(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' }),
})
expect(cloneResponse.status).toBe(201) expect(cloneResponse.status).toBe(201)
const clone = await cloneResponse.json() as CaseState const clone = await cloneResponse.json() as CaseState
expect(clone).toMatchObject({ id: 'smoke-template-copy', title: 'Playable copy', sourceTemplateVersionId: expect.any(String) }) expect(clone.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
expect(clone.timelineRange).toEqual(state.timelineRange) expect(clone.exhibits.map(item => item.id)).not.toContain(folder.id)
expect(clone.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 }) expect(clone.exhibits.find(item => item.type === 'folder')).toMatchObject({ x: 685, y: 417 })
expect(clone.documents.find(item => item.title === 'Renamed smoke evidence')?.assetId).toBe(uploaded.assetId) expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2)
expect(clone.documents[0].id).not.toBe(savedPlayerState.documents[0].id) expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
expect(clone.evidence.map(item => item.id)).not.toContain(folderId) expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
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 })
}) })
}) })
+17 -6
View File
@@ -10,7 +10,7 @@ function mapped(ids: IdMap, sourceId: string, label: string) {
} }
export async function clearBoard(client: PoolClient, boardId: 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.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.level_briefs WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.metadata_fields 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 regionIds: IdMap = new Map()
const fieldIds: IdMap = new Map() const fieldIds: IdMap = new Map()
const timeline = await client.query<{ range_start: string; range_end: string }>( const views = await client.query<{
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [sourceBoardId]) id: string; view_type_id: string; placement_mode: string; dock_edge: string | null; xpos: number | null; ypos: number | null
if (timeline.rows[0]) await client.query( width: number | null; height: number; z_index: number; visible: boolean; range_mode: string | null; range_start: string | null; range_end: string | null
'INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)', }>(`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,
[targetBoardId, timeline.rows[0].range_start, timeline.rows[0].range_end]) 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<{ const exhibits = await client.query<{
id: string; exhibit_type_id: string; xpos: number; ypos: number; width: number; height: number id: string; exhibit_type_id: string; xpos: number; ypos: number; width: number; height: number
+7 -8
View File
@@ -30,6 +30,7 @@ process.env.LEVEL_EDITING_ENABLED = 'true'
process.env.JWT_SECRET = 'osint-e2e-jwt-secret' process.env.JWT_SECRET = 'osint-e2e-jwt-secret'
process.env.PORT = String(port) process.env.PORT = String(port)
process.env.OSINT_MANAGED_SERVER = 'true' process.env.OSINT_MANAGED_SERVER = 'true'
process.env.ASSET_STORAGE_DRIVER = 'memory'
const { server, pool } = await import('./index.js') const { server, pool } = await import('./index.js')
if (!server.listening) await once(server, 'listening') if (!server.listening) await once(server, 'listening')
const baseUrl = `http://127.0.0.1:${port}` 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: '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' }, { id: '55555555-5555-4555-8555-555555555555', label: 'Difference Engine Bureau', context: 'Issued the archive notice.', expectedPartyKind: 'organization' },
] } ] }
state.documents = [{ state.exhibits = [{
id: documentId, title: 'Dated source image', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00.000Z', id: documentId, type: 'document', title: 'Dated source image', publishedAt: '2021-04-17T12:00:00.000Z',
body: [], regions: [], fileType: 'image', metadata: {}, body: [], regions: [], fileType: 'image', metadata: {}, x: 980, y: 360, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false,
}] }, {
state.evidence = [{
id: folderId, type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence', 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 = [{ state.relations = [{
id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, id: `contains:${folderId}:${documentId}`, fromExhibitId: folderId, toExhibitId: documentId, type: 'contains', sortOrder: 0,
config: { x: 980, y: 360 },
}] }]
state.connections = [] state.connections = []
state.viewport = { x: 0, y: 28, zoom: 0.7 } state.viewport = { x: 0, y: 28, zoom: 0.7 }
+12 -8
View File
@@ -10,6 +10,7 @@ import pg from 'pg'
import type { CaseState } from '../src/types.js' import type { CaseState } from '../src/types.js'
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin } from './auth.js' import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin } from './auth.js'
import { createLevelRepository } from './levelRepository.js' import { createLevelRepository } from './levelRepository.js'
import { createObjectStorageFromEnv } from './objectStorage.js'
const { Pool } = pg const { Pool } = pg
const databaseUrl = process.env.DATABASE_URL const databaseUrl = process.env.DATABASE_URL
@@ -20,7 +21,9 @@ if (!databaseUrl) {
export const pool = new Pool({ connectionString: databaseUrl }) export const pool = new Pool({ connectionString: databaseUrl })
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true' 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) { function wantsEdit(req: express.Request) {
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req) return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
@@ -41,7 +44,7 @@ const upload = multer({
}) })
app.get('/api/health', async (_req, res) => { 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' }) } catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
}) })
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) })) 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 { try {
const asset = await levels.getAsset(req.params.id) const asset = await levels.getAsset(req.params.id)
if (!asset) return res.status(404).json({ error: 'Asset not found' }) 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/') const inline = asset.mimeType === 'application/pdf' || asset.mimeType.startsWith('image/') || asset.mimeType.startsWith('text/')
res.setHeader('Content-Type', asset.mime_type || 'application/octet-stream') res.setHeader('Content-Type', asset.mimeType || 'application/octet-stream')
res.setHeader('Content-Length', asset.byte_size) res.setHeader('Content-Length', asset.byteSize)
res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.original_name)}`) res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.originalName)}`)
res.setHeader('X-Content-Type-Options', 'nosniff') res.setHeader('X-Content-Type-Options', 'nosniff')
res.send(asset.content) asset.stream.on('error', next)
asset.stream.pipe(res)
} catch (error) { next(error) } } catch (error) { next(error) }
}) })
app.post('/api/levels/:id/documents', requireAdmin, upload.single('file'), async (req, res, next) => { 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) => { app.put('/api/levels/:id', async (req, res, next) => {
const state = req.body as CaseState 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 { try {
const authorMode = wantsEdit(req) const authorMode = wantsEdit(req)
await levels.saveLevel(state, authorMode) await levels.saveLevel(state, authorMode)
+147 -133
View File
@@ -1,10 +1,14 @@
import { createHash, randomUUID } from 'node:crypto' import { createHash, randomUUID } from 'node:crypto'
import { Readable } from 'node:stream'
import type { Pool, PoolClient } from 'pg' 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 { clearBoard, cloneBoard } from './boardClone.js'
import type { ObjectStorage } from './objectStorage.js'
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number } 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 type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
export interface LevelRepository { export interface LevelRepository {
@@ -16,17 +20,17 @@ export interface LevelRepository {
getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null> getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null>
saveLevel(state: CaseState, authorMode: boolean): Promise<void> saveLevel(state: CaseState, authorMode: boolean): Promise<void>
resetLevel(levelId: string): Promise<CaseState | null> resetLevel(levelId: string): Promise<CaseState | null>
getAsset(assetId: string): Promise<AssetRecord | null> getAsset(assetId: string): Promise<AssetResponse | null>
uploadDocument(levelId: string, file: UploadedDocument): Promise<CaseDocument | null> uploadDocument(levelId: string, file: UploadedDocument): Promise<CaseDocument | null>
} }
type LevelRow = { type LevelRow = {
id: string; slug: string; board_id: string; title: string; subtitle: string; status: string 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 source_template_version_id: string | null
} }
type ExhibitRow = { 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 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 asset_id: string | null; published_at: Date | null; occurred_at: Date | null
original_name: string | null; mime_type: string | null; byte_size: string | 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'] const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
return allowed.includes(document.fileType) ? document.fileType : 'file' return allowed.includes(document.fileType) ? document.fileType : 'file'
} }
function documentKind(type: SourceFileType) { export function createLevelRepository(pool: Pool, editingEnabled: boolean, objectStorage: ObjectStorage): LevelRepository {
return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase()
}
export function createLevelRepository(pool: Pool, editingEnabled: boolean): LevelRepository {
async function findLevel(client: Pool | PoolClient, slug: string, lock = false) { async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
const result = await client.query<LevelRow>(`SELECT id, slug, board_id, title, subtitle, status, const result = await client.query<LevelRow>(`SELECT l.id,l.slug,l.board_id,l.title,l.subtitle,l.status,
viewport_x, viewport_y, viewport_zoom, updated_at, source_template_version_id l.viewport_x,l.viewport_y,l.viewport_zoom,l.updated_at,l.source_template_version_id,b.revision::text
FROM osint.levels WHERE slug = $1${lock ? ' FOR UPDATE' : ''}`, [slug]) 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 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<CaseState | null> { async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
const level = await findLevel(pool, slug) const level = await findLevel(pool, slug)
if (!level) return null if (!level) return null
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult, const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, timelineResult] = await Promise.all([ aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult] = await Promise.all([
pool.query<ExhibitRow>(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden, pool.query<ExhibitRow>(`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.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, 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, 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 }>( 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 `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]), 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 }>( pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
`SELECT event_exhibit_id,evidence_exhibit_id FROM osint.event_evidence WHERE board_id=$1 `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]), ORDER BY event_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
pool.query<{ party_exhibit_id: string; alias: string }>( 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 `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]), 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 }>( pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
`SELECT party_exhibit_id,evidence_exhibit_id FROM osint.party_evidence WHERE board_id=$1 `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]), 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<{ 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 }>( 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 `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]), WHERE board_id=$1 ORDER BY sort_order,id`, [level.board_id]),
pool.query<{ range_start: string; range_end: string }>( 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 range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]), `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<string, string[]>() const blocks = new Map<string, string[]>()
@@ -123,43 +132,50 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
}]) }])
const metadata = new Map<string, Record<string, string>>() const metadata = new Map<string, Record<string, string>>()
for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value }) 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<string, string[]>()
const eventEvidence = new Map<string, string[]>()
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<string, string[]>() const aliases = new Map<string, string[]>()
for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias]) for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias])
const partyEvidence = new Map<string, string[]>() const relations: ExhibitRelation[] = [
for (const row of partyEvidenceResult.rows) partyEvidence.set(row.party_exhibit_id, [...(partyEvidence.get(row.party_exhibit_id) || []), row.evidence_exhibit_id]) ...membershipsResult.rows.map(row => ({ id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromExhibitId: row.folder_exhibit_id,
const relations: WidgetRelation[] = membershipsResult.rows.map(row => { toExhibitId: row.child_exhibit_id, type: 'contains' as const, sortOrder: row.sort_order })),
contained.set(row.folder_exhibit_id, [...(contained.get(row.folder_exhibit_id) || []), row.child_exhibit_id]) ...eventEvidenceResult.rows.map(row => ({ id: `supports:${row.event_exhibit_id}:${row.evidence_exhibit_id}`, fromExhibitId: row.event_exhibit_id,
return { id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromWidgetId: row.folder_exhibit_id, toExhibitId: row.evidence_exhibit_id, type: 'supports' as const, sortOrder: row.sort_order, note: row.note || undefined })),
toWidgetId: row.child_exhibit_id, type: 'contains', sortOrder: row.sort_order, config: { x: row.xpos, y: row.ypos } } ...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 documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
const type = row.document_type_id || 'file' const type = row.document_type_id || 'file'
const publishedAt = row.published_at?.toISOString() 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, 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, 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) || {} } 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 => ({ const evidence: Evidence[] = []
id: row.id, type: row.exhibit_type_id as Evidence['type'], title: row.title, content: row.content, for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden)) {
sourceDocumentId: row.source_document_id || undefined, sourceRegionId: row.source_region_key || undefined, const common = { ...base(row), title: row.title, content: row.content }
eventDate: row.occurred_at?.toISOString(), x: row.xpos, y: row.ypos, width: row.width, if (row.exhibit_type_id === 'folder') evidence.push({ ...common, type:'folder', isOpen:Boolean(row.is_open) })
supportingEvidenceIds: eventEvidence.get(row.id) || [], else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
partyKind: row.party_kind || undefined, organizationKind: row.organization_kind || undefined, 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) || [] })
aliases: aliases.get(row.id) || [], relatedEvidenceIds: partyEvidence.get(row.id) || [], else if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note' })
config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.id) || [], 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, 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 })) ...(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, return { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations,
connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id, 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, label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })), 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(), 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, brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
sourceTemplateVersionId: level.source_template_version_id || undefined } 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) { async function replaceBoard(client: PoolClient, level: LevelRow, state: CaseState) {
const documentIds = new Set(state.documents.map(document => requireUuid(document.id, 'Document id'))) if (!Array.isArray(state.exhibits) || !Array.isArray(state.views)) throw new Error('Level state must contain exhibits and views')
const evidenceIds = new Set(state.evidence.map(exhibit => requireUuid(exhibit.id, 'Exhibit id'))) const documents = state.exhibits.filter(isDocumentExhibit)
const allIds = [...documentIds, ...evidenceIds] const evidence = state.exhibits.filter((exhibit): exhibit is Evidence => !isDocumentExhibit(exhibit))
if (new Set(allIds).size !== allIds.length) throw new Error('An id cannot identify both a document and another 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 relationList = state.relations || state.evidence.flatMap(exhibit => (exhibit.containedDocumentIds || []).map((documentId, index) => ({ const allIds = state.exhibits.map(exhibit => exhibit.id)
id: `contains:${exhibit.id}:${documentId}`, fromWidgetId: exhibit.id, toWidgetId: documentId, type: 'contains', sortOrder: index, if (new Set(allIds).size !== allIds.length) throw new Error('Exhibit ids must be unique within a board')
})))
const positions = new Map<string, { x: number; y: number }>()
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 })
const expectedConceptKinds = new Map((await client.query<{ id: string; expected_party_kind: PartyKind | null }>( 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])) '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, 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]) 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.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_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.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.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.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]) 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]) 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]) else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id])
for (const [index, document] of state.documents.entries()) { for (const exhibit of state.exhibits) {
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,rotation,z_index,hidden)
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,$7,$8,$9,$10)
VALUES ($1,$2,'document',$3,$4,174,145,$5,FALSE) 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()`,
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()`, [exhibit.id, level.board_id, exhibit.type, exhibit.x, exhibit.y, exhibit.width, exhibit.height, exhibit.rotation, exhibit.zIndex, exhibit.hidden])
[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) for (const document of documents) {
VALUES ($1,$2,$3,$4,$5)`, [document.id, documentType(document), document.assetId || null, document.title, timestamp(document.publishedAt || document.date)]) 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]) 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( 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]) '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) `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]) 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()) { for (const exhibit of evidence) {
const type = exhibit.type === 'evidence' ? 'folder' : exhibit.type if (isFolderExhibit(exhibit)) await client.query(
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(
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)', '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)]) [exhibit.id, exhibit.title, exhibit.content, exhibit.isOpen])
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 (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 (canonicalType === 'event') await client.query( if (isEventExhibit(exhibit)) await client.query(
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)', '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)]) [exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate)])
if (canonicalType === 'party') { if (isPartyExhibit(exhibit)) {
const partyKind: PartyKind = exhibit.partyKind === 'organization' ? 'organization' : 'person' const partyKind = exhibit.partyKind
await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)', 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]) [exhibit.id, partyKind, exhibit.title, exhibit.content])
if (partyKind === 'person') await client.query('INSERT INTO osint.person_parties (exhibit_id) VALUES ($1)', [exhibit.id]) 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')) { for (const relation of state.relations) {
if (!evidenceIds.has(relation.fromWidgetId) || !allIds.includes(relation.toWidgetId)) throw new Error('Folder membership references an unknown exhibit') if (!allIds.includes(relation.fromExhibitId) || !allIds.includes(relation.toExhibitId) || relation.fromExhibitId === relation.toExhibitId) throw new Error('Relation references an unknown or identical exhibit')
await client.query(`INSERT INTO osint.folder_memberships (board_id,folder_exhibit_id,child_exhibit_id,sort_order) if (relation.type === 'contains') await client.query(`INSERT INTO osint.folder_memberships
VALUES ($1,$2,$3,$4)`, [level.board_id, relation.fromWidgetId, relation.toWidgetId, relation.sortOrder || 0]) (board_id,folder_exhibit_id,child_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
} [level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder])
for (const event of state.evidence.filter(item => item.type === 'event')) { if (relation.type === 'supports') await client.query(`INSERT INTO osint.event_evidence
for (const [sortOrder, evidenceId] of (event.supportingEvidenceIds || []).entries()) { (board_id,event_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
if (evidenceId === event.id || !allIds.includes(evidenceId)) throw new Error('Event evidence references an unknown or identical exhibit') [level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder, relation.note || null])
await client.query(`INSERT INTO osint.event_evidence if (relation.type === 'concerns') await client.query(`INSERT INTO osint.party_evidence
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`, (board_id,party_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
[level.board_id, event.id, evidenceId, sortOrder]) [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')
for (const party of state.evidence.filter(item => item.type === 'party')) { let regionId: string | null = null
for (const [sortOrder, evidenceId] of (party.relatedEvidenceIds || []).entries()) { if (relation.sourceRegionId) {
if (evidenceId === party.id || !allIds.includes(evidenceId)) throw new Error('Party evidence references an unknown or identical exhibit') const region = await client.query<{ id: string }>(
await client.query(`INSERT INTO osint.party_evidence 'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [relation.toExhibitId, relation.sourceRegionId])
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`, regionId = region.rows[0]?.id || null
[level.board_id, party.id, evidenceId, sortOrder]) }
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) { for (const connection of state.connections) {
requireUuid(connection.id, 'Connection id') 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 tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65))))
const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage' const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage'
const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50)))) const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50))))
const lateralLimit = Math.round(10 + (100 - tightness) * .6) const lateralLimit = Math.round(10 + (100 - tightness) * .6)
const tagOffset = Math.max(-lateralLimit, Math.min(lateralLimit, Math.round(Number(connection.tagOffset ?? 0)))) 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) 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)) { for (const view of state.views) {
if (!documentIds.has(exhibit.sourceDocumentId!)) throw new Error('Exhibit source references an unknown document') requireUuid(view.id, 'Board view id')
let regionId: string | null = null const placement = view.placement
if (exhibit.sourceRegionId) { const docked = placement.mode === 'docked'
const region = await client.query<{ id: string }>( await client.query(`INSERT INTO osint.board_views
'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [exhibit.sourceDocumentId, exhibit.sourceRegionId]) (id,board_id,view_type_id,placement_mode,dock_edge,xpos,ypos,width,height,z_index,visible)
regionId = region.rows[0]?.id || null 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<string, string>() const fields = new Map<string, string>()
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)) { if (!fields.has(key)) {
const fieldId = randomUUID(); fields.set(key, fieldId) 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]) 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]]) [document.id, fields.get(key), document.metadata[key]])
} }
const brief = state.brief || { body: '', concepts: [] } 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 || '']) 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()) { for (const [sortOrder, concept] of brief.concepts.entries()) {
requireUuid(concept.id, 'Brief concept id') 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.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)`, 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]) [levelId, input.id, boardId, input.title, input.subtitle])
await createDefaultBoardViews(client, boardId)
await client.query('COMMIT') await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return (await assembleLevel(input.id))! return (await assembleLevel(input.id))!
@@ -444,8 +443,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
}, },
async getAsset(assetId) { async getAsset(assetId) {
if (!uuidPattern.test(assetId)) return null if (!uuidPattern.test(assetId)) return null
const result = await pool.query<AssetRecord>('SELECT original_name,mime_type,byte_size,content FROM osint.assets WHERE id=$1', [assetId]) const result = await pool.query<AssetRecord>('SELECT original_name,mime_type,byte_size,content,storage_provider,object_key FROM osint.assets WHERE id=$1', [assetId])
return result.rows[0] || null 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) { async uploadDocument(levelId, file) {
const client = await pool.connect() 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 } if (!level) { await client.query('ROLLBACK'); return null }
const candidateAssetId = randomUUID(); const exhibitId = randomUUID() const candidateAssetId = randomUUID(); const exhibitId = randomUUID()
const checksum = createHash('sha256').update(file.buffer).digest('hex') const checksum = createHash('sha256').update(file.buffer).digest('hex')
const asset = await client.query<{ id: string }>(`INSERT INTO osint.assets 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
(id,original_name,mime_type,byte_size,content,checksum_sha256) VALUES ($1,$2,$3,$4,$5,$6) if (!assetId) {
ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`, const objectKey = `assets/${checksum.slice(0,2)}/${checksum}`
[candidateAssetId, file.originalname, file.mimetype || 'application/octet-stream', file.size, file.buffer, 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' 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) 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]) 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)`, 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]) 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.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('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
await client.query('COMMIT') await client.query('COMMIT')
return { id: exhibitId, title: file.originalname, kind: documentKind(fileType), fileType, metadata: {}, date: '', body: [], regions: [], return { id: exhibitId,type:'document',title:file.originalname,x:100,y:100,width:174,height:145,rotation:0,zIndex:0,hidden:false,
assetId: asset.rows[0].id, fileName: file.originalname, mimeType: file.mimetype, fileSize: file.size } 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() } } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
}, },
} }
+88
View File
@@ -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<void>
putObject(key: string, body: Buffer, contentType: string): Promise<{ etag?: string }>
getObject(key: string): Promise<ObjectBody | null>
}
export class MemoryObjectStorage implements ObjectStorage {
readonly bucket: string
readonly provider = 'memory' as const
private readonly objects = new Map<string, Buffer>()
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',
})
}
+152 -166
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' 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 { 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 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, relationPosition, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { documentWidget, exhibitWidget } from './exhibitRegistry' import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
const BOARD_W = 2400 const BOARD_W = 2400
const BOARD_H = 1500 const BOARD_H = 1500
@@ -13,15 +13,24 @@ const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
function uid(_prefix: string) { return crypto.randomUUID() } function uid(_prefix: string) { return crypto.randomUUID() }
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` } function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
function documentSearchText(document: CaseDocument) { 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]), ...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() ...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase()
} }
function connectionPoint(item: Evidence) { function connectionPoint(item: Exhibit) {
return exhibitWidget(item.type).connectionPoint(item) 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() { export function App() {
const [caseState, setCaseState] = useState<CaseState | null>(null) const [caseState, setCaseState] = useState<CaseState | null>(null)
@@ -42,9 +51,9 @@ export function App() {
const [editingFolderId, setEditingFolderId] = useState<string | null>(null) const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
const [editingFileId, setEditingFileId] = useState<string | null>(null) const [editingFileId, setEditingFileId] = useState<string | null>(null)
const [editingEventId, setEditingEventId] = useState<string | null>(null) const [editingEventId, setEditingEventId] = useState<string | null>(null)
const [newEventDraft, setNewEventDraft] = useState<Evidence | null>(null) const [newEventDraft, setNewEventDraft] = useState<EventExhibit | null>(null)
const [editingPartyId, setEditingPartyId] = useState<string | null>(null) const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
const [newPartyDraft, setNewPartyDraft] = useState<Evidence | null>(null) const [newPartyDraft, setNewPartyDraft] = useState<PartyExhibit | null>(null)
const [briefOpen, setBriefOpen] = useState(false) const [briefOpen, setBriefOpen] = useState(false)
const [editingBrief, setEditingBrief] = useState(false) const [editingBrief, setEditingBrief] = useState(false)
const [editingTimeline, setEditingTimeline] = useState(false) const [editingTimeline, setEditingTimeline] = useState(false)
@@ -121,7 +130,7 @@ export function App() {
const focusEvidence = (id: string) => { const focusEvidence = (id: string) => {
if (!caseState) return if (!caseState) return
const ev = caseState.evidence.find(e => e.id === id) const ev = caseState.exhibits.find(e => e.id === id)
if (!ev) return if (!ev) return
setSelected(id) setSelected(id)
update(s => ({ ...s, viewport: { ...s.viewport, x: 500 - ev.x * s.viewport.zoom, y: 260 - ev.y * s.viewport.zoom } })) 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) => { const extract = (doc: CaseDocument, regionId: string) => {
if (!caseState) return if (!caseState) return
const region = doc.regions.find(r => r.id === regionId)! const region = doc.regions.find(r => r.id === regionId)!
const existing = caseState.evidence.find(e => e.sourceDocumentId === doc.id && e.sourceRegionId === regionId) const existingSource = caseState.relations.find(relation => relation.type === 'source' && relation.toExhibitId === doc.id && relation.sourceRegionId === regionId)
if (existing) { setOpenDoc(null); focusEvidence(existing.id); return } if (existingSource) { setOpenDoc(null); focusEvidence(existingSource.fromExhibitId); return }
const ev: Evidence = { const ev: FolderExhibit = {
id: uid('folder'), type: 'folder', title: `${doc.kind} EVIDENCE`, content: region.excerpt, config: { open: false }, id: uid('folder'), type: 'folder', title: `${documentWidget(doc.fileType).label.toUpperCase()} EVIDENCE`, content: region.excerpt, isOpen: false,
sourceDocumentId: doc.id, sourceRegionId: region.id, containedDocumentIds: [doc.id], ...placement(850 + Math.random() * 220, 390 + Math.random() * 250, 260, 166),
x: 850 + Math.random() * 220, y: 390 + Math.random() * 250, width: 260,
} }
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') 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() const content = window.prompt('What do you think this evidence means?')?.trim()
if (!content || !caseState) return if (!content || !caseState) return
const { viewport } = caseState 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 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, ...position, width: 108 } const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...placement(position.x, position.y, 108, 154) }
update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id) update(s => ({ ...s, exhibits: [...s.exhibits, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id)
} }
const addEvent = () => { const addEvent = () => {
if (!caseState) return if (!caseState) return
const { viewport } = caseState 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 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: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.', const event: EventExhibit = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
supportingEvidenceIds: [], ...position, width: 270 } ...placement(position.x, position.y, 270, 174) }
setNewEventDraft(event) setNewEventDraft(event)
} }
const addParty = () => { const addParty = () => {
if (!caseState) return if (!caseState) return
const { viewport } = caseState 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), x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
}, { width: 280 }) }, { 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) => { const classifyConcept = (conceptId: string, partyKind: PartyKind) => {
@@ -175,15 +186,15 @@ export function App() {
const existingId = concept.resolvedPartyExhibitId const existingId = concept.resolvedPartyExhibitId
const partyId = existingId || uid('party') const partyId = existingId || uid('party')
const { viewport } = caseState const { viewport } = caseState
const existingParty = caseState.evidence.find(item => item.id === existingId) 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.evidence, { 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), x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
}, { width: 280 }) }, { width: 280 })
const party: Evidence = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined, const party: PartyExhibit = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined,
title: concept.label, content: concept.context, aliases: [], relatedEvidenceIds: [], title: concept.label, content: concept.context, aliases: [],
...position, width: 280 } ...placement(position.x, position.y, 280, 190) }
update(state => ({ ...state, 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) }, brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) },
})) }))
setSelected(partyId) setSelected(partyId)
@@ -203,14 +214,14 @@ export function App() {
const completeThread = (targetId: string) => { const completeThread = (targetId: string) => {
if (!linkFrom || !caseState || linkFrom === targetId) return 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) { if (existing) {
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG') setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
return 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) 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) => { 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 }) 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})`) } if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
const document: CaseDocument = await response.json() 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()}`) setStatus(`IMPORTED · ${file.name.toUpperCase()}`)
} catch (error) { } catch (error) {
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED') setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
@@ -317,27 +328,25 @@ export function App() {
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} /> if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div> if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
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 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 unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed) 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.exhibits.flatMap(exhibit => exhibitWidget(exhibit.type).temporalFacts(exhibit).map(fact => {
const temporalItems: TemporalItem[] = [ const membership = exhibit.type === 'document' ? caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === exhibit.id) : undefined
...caseState.evidence.flatMap(folder => folder.type !== 'folder' ? [] : containedIds(caseState, folder.id).flatMap(documentId => { const folder = membership ? caseState.exhibits.find(candidate => candidate.id === membership.fromExhibitId && candidate.type === 'folder') as FolderExhibit | undefined : undefined
const document = documentById.get(documentId) const sourceTemporalId = folder && !folder.isOpen ? `widget:${folder.id}` : `widget:${exhibit.id}`
const date = document?.publishedAt || document?.date return { id: fact.id, sourceTemporalId, date: fact.start, label: fact.label, kind: exhibit.type === 'document' ? 'document' as const : 'widget' as const, exhibitId: exhibit.id }
const relation = caseState.relations.find(candidate => candidate.type === 'contains' && candidate.fromWidgetId === folder.id && candidate.toWidgetId === documentId) })).sort((a, b) => dateValue(a.date) - dateValue(b.date))
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 }] : [] const storyEvents = evidence.filter((item): item is EventExhibit => item.type === 'event').sort((a, b) => {
})),
...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) => {
if (!a.eventDate) return b.eventDate ? 1 : 0 if (!a.eventDate) return b.eventDate ? 1 : 0
if (!b.eventDate) return -1 if (!b.eventDate) return -1
return dateValue(a.eventDate) - dateValue(b.eventDate) return dateValue(a.eventDate) - dateValue(b.eventDate)
}) })
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
return <main className="desktop"> return <main className="desktop">
<header className="menubar"> <header className="menubar">
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div> <div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
@@ -363,13 +372,13 @@ export function App() {
<section className="workspace"> <section className="workspace">
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}> <aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${caseState.documents.length}` : caseState.documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div> <div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${documents.length}` : documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
<label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label> <label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label>
{canAuthor && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>} {canAuthor && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>}
<div className="doc-list"> <div className="doc-list">
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}> {filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.kind.slice(0, 3)}</b></div> <div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
<div><strong>{doc.title}</strong><span>{doc.kind} · {doc.date}</span></div><ChevronRight size={16}/> <div><strong>{doc.title}</strong><span>{documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
</button>)} </button>)}
{normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>} {normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
</div> </div>
@@ -378,10 +387,10 @@ export function App() {
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && canAuthor) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (canAuthor) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}> <div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && canAuthor) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (canAuthor) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div> <div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, documents: state.documents.map(document => document.id === id ? { ...document, metadata: { ...document.metadata, memory_cue: cue } } : document) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} /> <Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
{briefOpen && <BriefPanel {briefOpen && <BriefPanel
brief={caseState.brief} brief={caseState.brief}
parties={caseState.evidence.filter(item => item.type === 'party')} parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedExhibitId={recentlyCreatedExhibitId}
canEdit={canAuthor} canEdit={canAuthor}
onClose={closeBrief} onClose={closeBrief}
@@ -392,7 +401,7 @@ export function App() {
onEditParty={setEditingPartyId} onEditParty={setEditingPartyId}
/>} />}
{storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate?.slice(0, 10) || 'UNDATED'}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>} {storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate?.slice(0, 10) || 'UNDATED'}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
{!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{caseState.documents.length}</b></button>} {!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{documents.length}</b></button>}
<div className="board-actions"> <div className="board-actions">
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button> <button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
<button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button> <button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
@@ -411,51 +420,49 @@ export function App() {
</div> </div>
</section> </section>
<DocumentLocatorBeam documentId={caseState.documents.some(document => 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('|')}`} /> <DocumentLocatorBeam documentId={documents.some(document => 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('|')}`} />
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.evidence.map(e => `${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('|')}`}/> <TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.exhibits.map(e => `${e.id}:${e.x}:${e.y}:${e.type === 'folder' ? e.isOpen : ''}`).join('|')}`}/>
<Timeline items={temporalItems} range={caseState.timelineRange} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/> {timelineView?.visible !== false && <Timeline items={temporalItems} range={timelineView?.rangeMode === 'fixed' ? timelineView.range : undefined} selected={selected} onEdit={() => 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 && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />} }
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.relations.flatMap(relation => relation.type === 'source' && relation.toExhibitId === openDoc.id ? [relation.sourceRegionId] : [])} />}
{editingFolderId && <FolderEditor {editingFolderId && <FolderEditor
key={editingFolderId} key={editingFolderId}
folder={caseState.evidence.find(widget => widget.id === editingFolderId)!} folder={caseState.exhibits.find((widget): widget is FolderExhibit => widget.id === editingFolderId && widget.type === 'folder')!}
memberIds={containedIds(caseState, editingFolderId)} memberIds={containedIds(caseState, editingFolderId)}
documents={caseState.documents} documents={documents}
canManageContents={canAuthor} canManageContents={canAuthor}
onClose={() => setEditingFolderId(null)} onClose={() => setEditingFolderId(null)}
onSave={(folder, members) => { onSave={(folder, members) => {
update(state => ({ update(state => ({
...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: [ relations: [
...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...state.relations.filter(relation => relation.type !== 'contains' || relation.fromExhibitId !== folder.id),
...members.map((documentId, index) => { ...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 })),
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 }
}),
], ],
})) }))
setEditingFolderId(null) setEditingFolderId(null)
setStatus('FOLDER UPDATED') setStatus('FOLDER UPDATED')
}} }}
/>} />}
{editingFileId && <FileEditor key={editingFileId} document={caseState.documents.find(document => 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 && <FileEditor key={editingFileId} document={documents.find(document => 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 && <EventEditor {editingEventId && <EventEditor
key={editingEventId} key={editingEventId}
event={caseState.evidence.find(item => item.id === editingEventId)!} event={caseState.exhibits.find((item): item is EventExhibit => item.id === editingEventId && item.type === 'event')!}
evidence={caseState.evidence} exhibits={caseState.exhibits}
documents={caseState.documents} relations={caseState.relations}
onClose={() => setEditingEventId(null)} 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 && <EventEditor {newEventDraft && <EventEditor
key={newEventDraft.id} key={newEventDraft.id}
event={newEventDraft} event={newEventDraft}
evidence={caseState.evidence} exhibits={caseState.exhibits}
documents={caseState.documents} relations={caseState.relations}
onClose={() => setNewEventDraft(null)} onClose={() => setNewEventDraft(null)}
onSave={event => { onSave={(event, supports) => {
update(state => ({ ...state, evidence: [...state.evidence, event] })) update(state => ({ ...state, exhibits: [...state.exhibits, event], relations: replaceDirectedRelations(state.relations, 'supports', event.id, supports) }))
setNewEventDraft(null) setNewEventDraft(null)
setSelected(event.id) setSelected(event.id)
setRecentlyCreatedExhibitId(event.id) setRecentlyCreatedExhibitId(event.id)
@@ -464,12 +471,12 @@ export function App() {
/>} />}
{editingPartyId && <PartyEditor {editingPartyId && <PartyEditor
key={editingPartyId} key={editingPartyId}
party={caseState.evidence.find(item => item.id === editingPartyId)!} party={caseState.exhibits.find((item): item is PartyExhibit => item.id === editingPartyId && item.type === 'party')!}
evidence={caseState.evidence} exhibits={caseState.exhibits}
documents={caseState.documents} relations={caseState.relations}
onClose={() => setEditingPartyId(null)} onClose={() => setEditingPartyId(null)}
onSave={party => { onSave={(party, related) => {
update(state => ({ ...state, evidence: state.evidence.map(item => item.id === party.id ? party : item) })) update(state => ({ ...state, exhibits: state.exhibits.map(item => item.id === party.id ? party : item), relations: replaceDirectedRelations(state.relations, 'concerns', party.id, related) }))
setEditingPartyId(null) setEditingPartyId(null)
setStatus('PARTY DOSSIER UPDATED') setStatus('PARTY DOSSIER UPDATED')
}} }}
@@ -477,12 +484,12 @@ export function App() {
{newPartyDraft && <PartyEditor {newPartyDraft && <PartyEditor
key={newPartyDraft.id} key={newPartyDraft.id}
party={newPartyDraft} party={newPartyDraft}
evidence={caseState.evidence} exhibits={caseState.exhibits}
documents={caseState.documents} relations={caseState.relations}
creating creating
onClose={() => setNewPartyDraft(null)} onClose={() => setNewPartyDraft(null)}
onSave={party => { onSave={(party, related) => {
update(state => ({ ...state, evidence: [...state.evidence, party] })) update(state => ({ ...state, exhibits: [...state.exhibits, party], relations: replaceDirectedRelations(state.relations, 'concerns', party.id, related) }))
setNewPartyDraft(null) setNewPartyDraft(null)
setSelected(party.id) setSelected(party.id)
setRecentlyCreatedExhibitId(party.id) setRecentlyCreatedExhibitId(party.id)
@@ -499,11 +506,11 @@ export function App() {
}} }}
/>} />}
{editingTimeline && <TimelineRangeEditor {editingTimeline && <TimelineRangeEditor
range={caseState.timelineRange} range={timelineView?.rangeMode === 'fixed' ? timelineView.range : undefined}
dates={temporalItems.map(item => item.date)} dates={temporalItems.map(item => item.date)}
onClose={() => setEditingTimeline(false)} onClose={() => setEditingTimeline(false)}
onSave={timelineRange => { 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) setEditingTimeline(false)
setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC') setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC')
}} }}
@@ -511,8 +518,8 @@ export function App() {
{threadDraft && <ThreadEditor {threadDraft && <ThreadEditor
key={threadDraft.id} key={threadDraft.id}
connection={threadDraft} connection={threadDraft}
sourceName={caseState.evidence.find(item => item.id === threadDraft.fromEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.fromEvidenceId)?.title || 'Exhibit'} sourceName={caseState.exhibits.find(item => item.id === threadDraft.fromExhibitId)?.title || 'Exhibit'}
targetName={caseState.evidence.find(item => item.id === threadDraft.toEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.toEvidenceId)?.title || 'Exhibit'} targetName={caseState.exhibits.find(item => item.id === threadDraft.toExhibitId)?.title || 'Exhibit'}
isNew={!caseState.connections.some(item => item.id === threadDraft.id)} isNew={!caseState.connections.some(item => item.id === threadDraft.id)}
onClose={() => setThreadDraft(null)} onClose={() => setThreadDraft(null)}
onSave={saveThread} onSave={saveThread}
@@ -537,8 +544,8 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main> return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
} }
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<HTMLDivElement | null>; 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 }) { 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<HTMLDivElement | null>; 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) 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 suppressClick = useRef(false)
const touchPoints = useRef(new Map<number, { x: number; y: number }>()) const touchPoints = useRef(new Map<number, { x: number; y: number }>())
const pinchDistance = useRef<number | null>(null) const pinchDistance = useRef<number | null>(null)
@@ -549,16 +556,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
const [trashActive, setTrashActive] = useState(false) const [trashActive, setTrashActive] = useState(false)
const trashRef = useRef<HTMLDivElement>(null) const trashRef = useRef<HTMLDivElement>(null)
const trashTarget = useRef(false) 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 containmentRelations = state.relations.filter(relation => relation.type === 'contains')
const pointForId = (id: string) => { const pointForId = (id: string) => {
const evidence = byId.get(id) const exhibit = byId.get(id)
if (evidence) return connectionPoint(evidence) if (!exhibit) return undefined
const relation = containmentRelations.find(item => item.toWidgetId === id) const membership = exhibit.type === 'document' ? containmentRelations.find(item => item.toExhibitId === id) : undefined
const folder = relation ? byId.get(relation.fromWidgetId) : undefined const folder = membership ? byId.get(membership.fromExhibitId) : undefined
if (!relation || !folder) return undefined return folder?.type === 'folder' && !folder.isOpen ? connectionPoint(folder) : connectionPoint(exhibit)
const position = relationPosition(state, relation)
return folderIsOpen(folder) ? { x: position.x + 87, y: position.y + 72 } : connectionPoint(folder)
} }
useEffect(() => { useEffect(() => {
const board = boardRef.current const board = boardRef.current
@@ -574,7 +579,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
board.addEventListener('wheel', handleWheelZoom, { passive: false }) board.addEventListener('wheel', handleWheelZoom, { passive: false })
return () => board.removeEventListener('wheel', handleWheelZoom) return () => board.removeEventListener('wheel', handleWheelZoom)
}, [boardRef, update]) }, [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.target as HTMLElement).closest('button')) return
if (event.pointerType === 'touch') { if (event.pointerType === 'touch') {
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) 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 widget = target?.kind === 'widget' ? byId.get(target.id) : undefined
const relation = target?.kind === 'relation' ? state.relations.find(candidate => candidate.id === target.id) : undefined 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 }
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 }
setDraggingWidget(target?.kind === 'widget') setDraggingWidget(target?.kind === 'widget')
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ } 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.kind === 'thread-tag' && boardRef.current) {
if (drag.current.moved) setExpandedThreadTagId(null) if (drag.current.moved) setExpandedThreadTagId(null)
const connection = state.connections.find(item => item.id === drag.current!.id) const connection = state.connections.find(item => item.id === drag.current!.id)
const from = connection ? pointForId(connection.fromEvidenceId) : undefined const from = connection ? pointForId(connection.fromExhibitId) : undefined
const to = connection ? pointForId(connection.toEvidenceId) : undefined const to = connection ? pointForId(connection.toExhibitId) : undefined
if (connection && from && to) { if (connection && from && to) {
const bounds = boardRef.current.getBoundingClientRect() 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 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) 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) })) 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 === '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 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 update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) })) 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) => { 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) if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) onDiscardExhibit(completedDrag.id)
trashTarget.current = false 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 const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined
return <div className={`board-viewport tool-${tool} ${linkFrom ? 'threading' : ''}`} ref={boardRef} return <div className={`board-viewport tool-${tool} ${linkFrom ? 'threading' : ''}`} ref={boardRef}
onPointerDown={e => { onPointerDown={e => {
@@ -678,54 +689,32 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
<div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}> <div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}>
<div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div> <div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
<svg className="connections" width={BOARD_W} height={BOARD_H}> <svg className="connections" width={BOARD_W} height={BOARD_H}>
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; return <g className={recentlyCreatedConnectionId === connection.id ? 'tightening' : ''} key={connection.id}><path d={threadCurve(p1, p2, connection.tightness).path}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })} {state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; return <g className={recentlyCreatedConnectionId === connection.id ? 'tightening' : ''} key={connection.id}><path d={threadCurve(p1, p2, connection.tightness).path}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })}
{previewOrigin && threadPointer && <g className="thread-preview"><path d={threadCurve(previewOrigin, threadPointer, 35).path}/><circle cx={previewOrigin.x} cy={previewOrigin.y} r="4"/><circle cx={threadPointer.x} cy={threadPointer.y} r="3"/></g>} {previewOrigin && threadPointer && <g className="thread-preview"><path d={threadCurve(previewOrigin, threadPointer, 35).path}/><circle cx={previewOrigin.x} cy={previewOrigin.y} r="4"/><circle cx={threadPointer.x} cy={threadPointer.y} r="3"/></g>}
</svg> </svg>
{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 <button key={`tag:${connection.id}`} aria-expanded={connection.label && !compact ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${expanded ? 'expanded' : ''} ${dragging ? 'dragging' : ''}`} style={{ left: placement.x, top: placement.y }} title={connection.label ? dragging ? `Position ${Math.round(placement.positionPercent)}%` : compact ? 'Drag to position · click to edit' : expanded ? 'Click again to edit this thread' : 'Drag along thread · click to rotate' : 'Edit thread tag and tightness'} onPointerDown={event => connection.label ? threadTagPointerDown(event, connection.id) : event.stopPropagation()} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!connection.label || compact || expanded) { setExpandedThreadTagId(null); onEditConnection(connection) } else setExpandedThreadTagId(connection.id) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(placement.positionPercent)}%</output>}{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b>{expanded && <em>CLICK AGAIN TO EDIT THREAD</em>}</span>)}</button> })} {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 <button key={`tag:${connection.id}`} aria-expanded={connection.label && !compact ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${expanded ? 'expanded' : ''} ${dragging ? 'dragging' : ''}`} style={{ left: tagPlacement.x, top: tagPlacement.y }} title={connection.label ? dragging ? `Position ${Math.round(tagPlacement.positionPercent)}%` : compact ? 'Drag to position · click to edit' : expanded ? 'Click again to edit this thread' : 'Drag along thread · click to rotate' : 'Edit thread tag and tightness'} onPointerDown={event => connection.label ? threadTagPointerDown(event, connection.id) : event.stopPropagation()} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!connection.label || compact || expanded) { setExpandedThreadTagId(null); onEditConnection(connection) } else setExpandedThreadTagId(connection.id) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(tagPlacement.positionPercent)}%</output>}{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b>{expanded && <em>CLICK AGAIN TO EDIT THREAD</em>}</span>)}</button> })}
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}> <svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
{state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => { {state.relations.filter(relation => relation.type === 'supports').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? <line key={relation.id} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/> : null })}
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 [<line key={`${event.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
}))}
</svg> </svg>
<svg className="party-association-lines" width={BOARD_W} height={BOARD_H}> <svg className="party-association-lines" width={BOARD_W} height={BOARD_H}>
{state.evidence.filter(party => party.type === 'party').flatMap(party => (party.relatedEvidenceIds || []).flatMap(evidenceId => { {state.relations.filter(relation => relation.type === 'concerns').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? <line key={relation.id} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/> : null })}
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 [<line key={`${party.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
}))}
</svg> </svg>
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}> <svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
{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 <line className={open ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={open ? position.x + 87 : origin.x} y2={open ? position.y + 72 : origin.y}/> })} {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 <line className={folder.isOpen ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={folder.isOpen ? document.x + document.width / 2 : origin.x} y2={folder.isOpen ? document.y + document.height / 2 : origin.y}/> })}
</svg> </svg>
{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 <article key={ev.id} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }} {evidenceExhibits(state.exhibits).filter(exhibit => !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 <article key={ev.id} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
onPointerDown={e => { 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 }) }} onPointerDown={e => { 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) }} 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) }}> 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) }}>
<header><span>{definition.heading(ev, containedDocuments)}</span><i>{String(i + 1).padStart(3, '0')}</i></header> <header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
<Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent} onEditParty={onEditParty}/> <Widget exhibit={ev} context={widgetContext}/>
</article>})} </article>})}
{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 <article key={relation.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top }} {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 <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
onPointerDown={event => { 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 }) }} onPointerDown={event => { 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 (linkFrom) onConnectionTarget(document.id); else if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(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)}>
<header><span>{definition.label.toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header> <header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div> <div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
<strong>{document.title}</strong><time>{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</time> <strong>{document.title}</strong><time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div> <div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
</article> })} </article> })}
</div> </div>
@@ -817,7 +806,7 @@ function Timeline({ items, range, selected, onSelect, onEdit }: { items: Tempora
const ticks = range 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: 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) } }) : 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 <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><button onClick={onEdit}>{range ? `${range.start}${range.end}` : `${items.length} DATED ITEMS · AUTO`}</button></div><div className="timeline-track"><div className="axis"/>{ticks.map((tick, index) => <span className="year" key={`${tick.value}:${index}`} style={{ left: `${timelinePositionPercent(new Date(tick.value).toISOString(), { start, end })}%` }}>{tick.label}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.evidenceId === selected || item.documentId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)}${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer> return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><button onClick={onEdit}>{range ? `${range.start}${range.end}` : `${items.length} DATED ITEMS · AUTO`}</button></div><div className="timeline-track"><div className="axis"/>{ticks.map((tick, index) => <span className="year" key={`${tick.value}:${index}`} style={{ left: `${timelinePositionPercent(new Date(tick.value).toISOString(), { start, end })}%` }}>{tick.label}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.exhibitId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)}${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
} }
function localDateTime(value?: string) { function localDateTime(value?: string) {
@@ -866,7 +855,7 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
</form></div> </form></div>
} }
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 [minimized, setMinimized] = useState(false)
const partyById = new Map(parties.map(party => [party.id, party])) const partyById = new Map(parties.map(party => [party.id, party]))
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
@@ -895,16 +884,16 @@ function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: (
</form></div> </form></div>
} }
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 [name, setName] = useState(party.title)
const [summary, setSummary] = useState(party.content) const [summary, setSummary] = useState(party.content)
const [partyKind, setPartyKind] = useState<PartyKind>(party.partyKind || 'person') const [partyKind, setPartyKind] = useState<PartyKind>(party.partyKind)
const [organizationKind, setOrganizationKind] = useState<OrganizationKind>(party.organizationKind || 'business') const [organizationKind, setOrganizationKind] = useState<OrganizationKind>(party.organizationKind || 'business')
const [aliases, setAliases] = useState((party.aliases || []).join('\n')) const [aliases, setAliases] = useState(party.aliases.join('\n'))
const [related, setRelated] = useState(party.relatedEvidenceIds || []) const [related, setRelated] = useState(relations.filter(relation => relation.type === 'concerns' && relation.fromExhibitId === party.id).map(relation => relation.toExhibitId))
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 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]) const toggle = (id: string) => setRelated(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
return <div className="modal-shade"><form className="window folder-editor party-editor" onSubmit={submit => { 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 <div className="modal-shade"><form className="window folder-editor party-editor" onSubmit={submit => { 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) }}>
<header>{partyKind === 'person' ? <UserRound size={16}/> : <Building2 size={16}/>}<b>{creating ? 'Create party dossier' : `Edit ${partyKind} dossier`}</b><span/><button type="button" aria-label="Close party editor" onClick={onClose}><X size={14}/></button></header> <header>{partyKind === 'person' ? <UserRound size={16}/> : <Building2 size={16}/>}<b>{creating ? 'Create party dossier' : `Edit ${partyKind} dossier`}</b><span/><button type="button" aria-label="Close party editor" onClick={onClose}><X size={14}/></button></header>
<div className="folder-editor-body"><small>PARTY EXHIBIT · {partyKind.toUpperCase()}</small> <div className="folder-editor-body"><small>PARTY EXHIBIT · {partyKind.toUpperCase()}</small>
{creating && <label className="field"><span>PARTY TYPE</span><select aria-label="Party type" value={partyKind} onChange={event => setPartyKind(event.target.value as PartyKind)}><option value="person">Person</option><option value="organization">Organization</option></select></label>} {creating && <label className="field"><span>PARTY TYPE</span><select aria-label="Party type" value={partyKind} onChange={event => setPartyKind(event.target.value as PartyKind)}><option value="person">Person</option><option value="organization">Organization</option></select></label>}
@@ -919,14 +908,14 @@ function PartyEditor({ party, evidence, documents, creating = false, onClose, on
</form></div> </form></div>
} }
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 [title, setTitle] = useState(folder.title)
const [content, setContent] = useState(folder.content) const [content, setContent] = useState(folder.content)
const [members, setMembers] = useState(memberIds) const [members, setMembers] = useState(memberIds)
const toggleMember = (documentId: string) => setMembers(current => current.includes(documentId) ? current.filter(id => id !== documentId) : [...current, documentId]) const toggleMember = (documentId: string) => setMembers(current => current.includes(documentId) ? current.filter(id => id !== documentId) : [...current, documentId])
const submit = (event: React.FormEvent) => { const submit = (event: React.FormEvent) => {
event.preventDefault() 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 <div className="modal-shade"><form className="window folder-editor" onSubmit={submit}> return <div className="modal-shade"><form className="window folder-editor" onSubmit={submit}>
<header><FolderOpen size={16}/><b>Edit evidence folder</b><span/><button type="button" aria-label="Close folder editor" onClick={onClose}><X size={14}/></button></header> <header><FolderOpen size={16}/><b>Edit evidence folder</b><span/><button type="button" aria-label="Close folder editor" onClick={onClose}><X size={14}/></button></header>
@@ -938,7 +927,7 @@ function FolderEditor({ folder, memberIds, documents, canManageContents, onClose
<div className="folder-members"> <div className="folder-members">
{documents.map(document => { const included = members.includes(document.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={document.id}> {documents.map(document => { const included = members.includes(document.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={document.id}>
<label><input type="checkbox" disabled={!canManageContents} checked={included} onChange={() => toggleMember(document.id)}/><FileText size={16}/><span><b>{document.title}</b><small>{document.fileType.replaceAll('_', ' ').toUpperCase()}</small></span></label> <label><input type="checkbox" disabled={!canManageContents} checked={included} onChange={() => toggleMember(document.id)}/><FileText size={16}/><span><b>{document.title}</b><small>{document.fileType.replaceAll('_', ' ').toUpperCase()}</small></span></label>
<span className="folder-member-date">{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</span> <span className="folder-member-date">{document.publishedAt?.slice(0, 10) || 'UNDATED'}</span>
</div> })} </div> })}
</div> </div>
<p className="folder-editor-note">The folder owns this text and its containment relationships. Publication time and other metadata belong to the individual files.</p> <p className="folder-editor-note">The folder owns this text and its containment relationships. Publication time and other metadata belong to the individual files.</p>
@@ -947,20 +936,17 @@ function FolderEditor({ folder, memberIds, documents, canManageContents, onClose
</form></div> </form></div>
} }
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 [title, setTitle] = useState(event.title)
const [narrative, setNarrative] = useState(event.content) const [narrative, setNarrative] = useState(event.content)
const [occurredAt, setOccurredAt] = useState(localDateTime(event.eventDate)) const [occurredAt, setOccurredAt] = useState(localDateTime(event.eventDate))
const [supports, setSupports] = useState(event.supportingEvidenceIds || []) const [supports, setSupports] = useState(relations.filter(relation => relation.type === 'supports' && relation.fromExhibitId === event.id).map(relation => relation.toExhibitId))
const candidates = [ 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() }))
...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 toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id]) const toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
const submit = (submitEvent: React.FormEvent) => { const submit = (submitEvent: React.FormEvent) => {
submitEvent.preventDefault() submitEvent.preventDefault()
const eventDate = occurredAt ? new Date(occurredAt).toISOString() : undefined 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 <div className="modal-shade"><form className="window folder-editor event-editor" onSubmit={submit}> return <div className="modal-shade"><form className="window folder-editor event-editor" onSubmit={submit}>
<header><CalendarClock size={16}/><b>Edit reconstructed event</b><span/><button type="button" aria-label="Close event editor" onClick={onClose}><X size={14}/></button></header> <header><CalendarClock size={16}/><b>Edit reconstructed event</b><span/><button type="button" aria-label="Close event editor" onClick={onClose}><X size={14}/></button></header>
@@ -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 }) { function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onClose: () => void; onSave: (document: CaseDocument) => void }) {
const [title, setTitle] = useState(document.title) const [title, setTitle] = useState(document.title)
const [fileType, setFileType] = useState<SourceFileType>(document.fileType) const [fileType, setFileType] = useState<SourceFileType>(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 [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
const submit = (event: React.FormEvent) => { const submit = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined 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 <div className="modal-shade"><form className="window file-editor" onSubmit={submit}> return <div className="modal-shade"><form className="window file-editor" onSubmit={submit}>
<header><ImageIcon size={16}/><b>Edit source-file metadata</b><span/><button type="button" aria-label="Close file editor" onClick={onClose}><X size={14}/></button></header> <header><ImageIcon size={16}/><b>Edit source-file metadata</b><span/><button type="button" aria-label="Close file editor" onClick={onClose}><X size={14}/></button></header>
@@ -1019,8 +1005,8 @@ function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocum
return <section className={`window document-window ${minimized ? 'minimized' : ''}`} style={{ left: pos.x, top: pos.y }}> return <section className={`window document-window ${minimized ? 'minimized' : ''}`} style={{ left: pos.x, top: pos.y }}>
<header onPointerDown={startDrag} onPointerMove={e => 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)}><FileText size={15}/><b>{doc.title}</b><span/><button type="button" aria-label={minimized ? 'Restore document' : 'Minimize document'} title={minimized ? 'Restore' : 'Minimize'} onPointerDown={e => e.stopPropagation()} onClick={() => setMinimized(v => !v)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close document" title="Close" onPointerDown={e => e.stopPropagation()} onClick={onClose}><X size={14}/></button></header> <header onPointerDown={startDrag} onPointerMove={e => 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)}><FileText size={15}/><b>{doc.title}</b><span/><button type="button" aria-label={minimized ? 'Restore document' : 'Minimize document'} title={minimized ? 'Restore' : 'Minimize'} onPointerDown={e => e.stopPropagation()} onClick={() => setMinimized(v => !v)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close document" title="Close" onPointerDown={e => e.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
{!minimized && <><nav>FILE&nbsp;&nbsp; EDIT&nbsp;&nbsp; EVIDENCE&nbsp;&nbsp; VIEW</nav> {!minimized && <><nav>FILE&nbsp;&nbsp; EDIT&nbsp;&nbsp; EVIDENCE&nbsp;&nbsp; VIEW</nav>
<div className={`paper ${doc.assetId ? 'asset-paper' : ''}`}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{doc.kind}</b></div>{doc.assetId ? <DocumentAsset doc={doc}/> : doc.body.map((line, i) => <p key={i}>{line}</p>)}{doc.regions.length > 0 && <div className="extracts">{doc.regions.map(r => <button key={r.id} className={extracted.includes(r.id) ? 'done' : ''} onClick={() => onExtract(r.id)}><Network size={15}/>{extracted.includes(r.id) ? 'LOCATE ON BOARD' : r.label}</button>)}</div>}</div> <div className={`paper ${doc.assetId ? 'asset-paper' : ''}`}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{documentWidget(doc.fileType).label}</b></div>{doc.assetId ? <DocumentAsset doc={doc}/> : doc.body.map((line, i) => <p key={i}>{line}</p>)}{doc.regions.length > 0 && <div className="extracts">{doc.regions.map(r => <button key={r.id} className={extracted.includes(r.id) ? 'done' : ''} onClick={() => onExtract(r.id)}><Network size={15}/>{extracted.includes(r.id) ? 'LOCATE ON BOARD' : r.label}</button>)}</div>}</div>
<footer><span>ARCHIVE ITEM · {doc.date}</span><span>PROVENANCE LOCKED</span></footer></>} <footer><span>ARCHIVE ITEM · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span><span>PROVENANCE LOCKED</span></footer></>}
</section> </section>
} }
+37 -32
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { CaseState, Evidence, WidgetRelation } from './types' import type { CaseState, ExhibitRelation, FolderExhibit } from './types'
import { import {
MAX_BOARD_ZOOM, MAX_BOARD_ZOOM,
MIN_BOARD_ZOOM, MIN_BOARD_ZOOM,
@@ -49,7 +49,7 @@ describe('red thread geometry', () => {
}) })
}) })
const folder: Evidence = { const folder: FolderExhibit = {
id: 'folder-1', id: 'folder-1',
type: 'folder', type: 'folder',
title: 'Folder', title: 'Folder',
@@ -57,7 +57,11 @@ const folder: Evidence = {
x: 200, x: 200,
y: 300, y: 300,
width: 260, width: 260,
config: { open: false }, height: 166,
rotation: 0,
zIndex: 1,
hidden: false,
isOpen: false,
} }
const state: CaseState = { const state: CaseState = {
@@ -65,11 +69,12 @@ const state: CaseState = {
id: 'test-level', id: 'test-level',
title: 'Test', title: 'Test',
subtitle: '', subtitle: '',
documents: [], exhibits: [folder],
evidence: [folder],
relations: [], relations: [],
connections: [], connections: [],
viewport: { x: 10, y: 20, zoom: 0.5 }, viewport: { x: 10, y: 20, zoom: 0.5 },
views: [],
revision: 0,
} }
describe('board coordinate math', () => { describe('board coordinate math', () => {
@@ -109,10 +114,10 @@ describe('board coordinate math', () => {
it('places new exhibits in deterministic open slots', () => { it('places new exhibits in deterministic open slots', () => {
const preferred = { x: 500, y: 400 } const preferred = { x: 500, y: 400 }
expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred) 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([ expect(nextOpenBoardPosition([
{ x: 500, y: 400, width: 280 }, { x: 500, y: 400, width: 280, height: 160 },
{ x: 826, y: 400, width: 280 }, { x: 826, y: 400, width: 280, height: 160 },
], preferred, { width: 280 })).toEqual({ x: 174, y: 400 }) ], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
}) })
}) })
@@ -147,25 +152,24 @@ describe('timeline projection', () => {
}) })
describe('folder domain behavior', () => { describe('folder domain behavior', () => {
const relations: WidgetRelation[] = [ const relations: ExhibitRelation[] = [
{ id: 'later', fromWidgetId: folder.id, toWidgetId: 'doc-2', type: 'contains', sortOrder: 2 }, { id: 'later', fromExhibitId: folder.id, toExhibitId: 'doc-2', type: 'contains', sortOrder: 2 },
{ id: 'other', fromWidgetId: 'folder-2', toWidgetId: 'doc-x', type: 'contains', sortOrder: 0 }, { id: 'other', fromExhibitId: 'folder-2', toExhibitId: 'doc-x', type: 'contains', sortOrder: 0 },
{ id: 'first', fromWidgetId: folder.id, toWidgetId: 'doc-1', 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', () => { it('orders and scopes contained documents by their normalized relations', () => {
expect(containedIds({ ...state, relations }, folder.id)).toEqual(['doc-1', 'doc-2']) 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)).toBe(false)
expect(folderIsOpen({ ...folder, config: { open: true } })).toBe(true) expect(folderIsOpen({ ...folder, isOpen: true })).toBe(true)
expect(folderIsOpen({ ...folder, config: { open: 'true' } })).toBe(false)
}) })
it('retains a configured expanded file position', () => { it('retains a configured expanded file position', () => {
const relation = { ...relations[0], config: { 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, relations }, relation)).toEqual({ x: 720, y: 415 }) 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', () => { 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', () => { it('normalizes legacy containment without changing source ownership', () => {
const legacy = { 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' }], 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 }], evidence: [{ ...folder, type: 'evidence', sourceDocumentId: 'doc-1', containedDocumentIds: ['doc-1'], config: undefined }],
relations: undefined, relations: undefined,
} as unknown as CaseState }
const normalized = normalizeCase(legacy) const normalized = normalizeCase(legacy)
expect(normalized.evidence[0]).toMatchObject({ type: 'folder', config: {}, containedDocumentIds: ['doc-1'] }) expect(normalized.exhibits.find(exhibit => exhibit.type === 'folder')).toMatchObject({ type: 'folder', isOpen: false })
expect(normalized.documents[0]).toMatchObject({ fileType: 'image', metadata: {} }) expect(normalized.exhibits.find(exhibit => exhibit.type === 'document')).toMatchObject({ fileType: 'image', metadata: {} })
expect(normalized.relations).toHaveLength(1) expect(normalized.relations).toHaveLength(1)
}) })
}) })
describe('exhibit disposal', () => { 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 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 event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined }
const party = { ...folder, id: 'party-1', type: 'party' as const, relatedEvidenceIds: [note.id] } 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({ const discarded = discardExhibit({
...state, ...state,
documents: [{ id: 'doc-1', title: 'Source', kind: 'TEXT', date: '', body: [], regions: [], fileType: 'text', metadata: {} }], exhibits: [folder, document, note, event, party],
evidence: [folder, note, event, party], relations: [
relations: [{ id: 'nested', fromWidgetId: folder.id, toWidgetId: note.id, type: 'contains' }], { id: 'supports', fromExhibitId: event.id, toExhibitId: note.id, type: 'supports', sortOrder: 0 },
connections: [{ id: 'thread', fromEvidenceId: note.id, toEvidenceId: party.id }], { 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 }] }, brief: { body: '', concepts: [{ id: 'concept-1', label: 'Unknown', context: '', resolvedPartyExhibitId: note.id }] },
}, note.id) }, note.id)
expect(discarded.documents).toHaveLength(1) expect(discarded.exhibits).toContainEqual(document)
expect(discarded.evidence.map(exhibit => exhibit.id)).not.toContain(note.id) expect(discarded.exhibits.map(exhibit => exhibit.id)).not.toContain(note.id)
expect(discarded.relations).toEqual([]) expect(discarded.relations).toEqual([])
expect(discarded.connections).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() expect(discarded.brief.concepts[0].resolvedPartyExhibitId).toBeUndefined()
}) })
}) })
+100 -49
View File
@@ -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 } export interface BoardPoint { x: number; y: number }
@@ -99,7 +99,7 @@ export function moveBoardPoint(origin: { x: number; y: number }, screenDelta: {
} }
export function nextOpenBoardPosition( export function nextOpenBoardPosition(
evidence: Pick<Evidence, 'x' | 'y' | 'width'>[], evidence: Pick<Exhibit, 'x' | 'y' | 'width' | 'height'>[],
preferred: { x: number; y: number }, preferred: { x: number; y: number },
size: { width: number; height?: number }, size: { width: number; height?: number },
bounds = { width: 2400, height: 1500 }, bounds = { width: 2400, height: 1500 },
@@ -108,7 +108,7 @@ export function nextOpenBoardPosition(
const gap = 28 const gap = 28
const overlaps = (x: number, y: number) => evidence.some(item => const overlaps = (x: number, y: number) => evidence.some(item =>
x < item.x + item.width + gap && x + size.width + gap > item.x && 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 xStep = size.width + 46
const yStep = height + 46 const yStep = height + 46
for (let row = 0; row < 7; row += 1) { for (let row = 0; row < 7; row += 1) {
@@ -158,40 +158,33 @@ export function timelinePositionPercent(date: string, range: Pick<ReturnType<typ
export function containedIds(state: CaseState, widgetId: string) { export function containedIds(state: CaseState, widgetId: string) {
return state.relations return state.relations
.filter(relation => relation.type === 'contains' && relation.fromWidgetId === widgetId) .filter(relation => relation.type === 'contains' && relation.fromExhibitId === widgetId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
.map(relation => relation.toWidgetId) .map(relation => relation.toExhibitId)
} }
export function folderIsOpen(folder: Evidence) { export function folderIsOpen(folder: FolderExhibit) {
return folder.config?.open === true return folder.isOpen
} }
export function relationPosition(state: CaseState, relation: WidgetRelation) { export function relationPosition(state: CaseState, relation: ExhibitRelation) {
const folder = state.evidence.find(widget => widget.id === relation.fromWidgetId) 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 order = relation.sortOrder || 0
const configuredX = Number(relation.config?.x)
const configuredY = Number(relation.config?.y)
return { return {
x: Number.isFinite(configuredX) ? configuredX : (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205, x: (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205,
y: Number.isFinite(configuredY) ? configuredY : (folder?.y || 100) - 30 + Math.floor(order / 3) * 185, y: (folder?.y || 100) - 30 + Math.floor(order / 3) * 185,
} }
} }
export function discardExhibit(state: CaseState, exhibitId: string): CaseState { 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 { return {
...state, ...state,
evidence: state.evidence exhibits: state.exhibits.filter(exhibit => exhibit.id !== exhibitId),
.filter(exhibit => exhibit.id !== exhibitId) relations: state.relations.filter(relation => relation.fromExhibitId !== exhibitId && relation.toExhibitId !== exhibitId),
.map(exhibit => ({ connections: state.connections.filter(connection => connection.fromExhibitId !== exhibitId && connection.toExhibitId !== exhibitId),
...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),
brief: { brief: {
...state.brief, ...state.brief,
concepts: state.brief.concepts.map(concept => concept.resolvedPartyExhibitId === exhibitId 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 { export function defaultTimelineView(range?: TimelineRange | null): BoardView {
const relations = Array.isArray(state.relations) return { id: crypto.randomUUID(), type: 'timeline', placement: { mode: 'docked', dockEdge: 'bottom', size: 112 }, visible: true, zIndex: 0,
? state.relations rangeMode: range ? 'fixed' : 'auto', range: range || undefined }
: state.evidence.flatMap(widget => (widget.containedDocumentIds || (widget.sourceDocumentId ? [widget.sourceDocumentId] : [])).map((documentId, index) => ({ }
id: `contains:${widget.id}:${documentId}`,
fromWidgetId: widget.id, type LegacyCaseState = {
toWidgetId: documentId, id: string
type: 'contains', title: string
sortOrder: index, subtitle: string
}))) viewport: Viewport
const normalized = { ...state, relations } brief?: CaseState['brief']
return { updatedAt?: string
...normalized, levelStatus?: string
brief: state.brief || { body: '', concepts: [] }, sourceTemplateVersionId?: string
documents: state.documents.map(document => ({ editingAllowed?: boolean
...document, revision?: number
fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'), exhibits?: Exhibit[]
metadata: document.metadata || {}, views?: BoardView[]
})), documents?: Array<Record<string, unknown>>
evidence: state.evidence.map(widget => ({ evidence?: Array<Record<string, unknown>>
...widget, timelineRange?: TimelineRange | null
type: widget.type === 'evidence' ? 'folder' : widget.type, relations?: Array<Record<string, unknown>>
config: widget.config || {}, connections?: Array<Record<string, unknown>>
containedDocumentIds: containedIds(normalized, widget.id), }
})),
function placement(item: Record<string, unknown>, 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<string, unknown>, { 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<string, unknown> | undefined)?.x ?? 100), y: Number((relation.config as Record<string, unknown> | 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<string, string> : {},
} 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<string, unknown> | 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 }
} }
+5 -6
View File
@@ -1,13 +1,12 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { EvidenceType, SourceFileType } from './types' import type { SourceFileType } from './types'
import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry' import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry'
describe('frontend exhibit registry', () => { describe('frontend exhibit registry', () => {
it('registers every API exhibit type and keeps legacy evidence on the folder renderer', () => { it('registers every normalized exhibit type', () => {
const types: EvidenceType[] = ['folder', 'evidence', 'note', 'event', 'party'] const types = ['folder', 'document', 'note', 'event', 'party'] as const
expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual(types.sort()) expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual([...types].sort())
expect(exhibitWidget('evidence')).toBe(exhibitWidget('folder')) expect(exhibitWidget('event').heading({} as never, { exhibits: [], relations: [], dispatch: () => {} })).toContain('THIS HAPPENED')
expect(exhibitWidget('event').heading({} as never, [])).toContain('THIS HAPPENED')
}) })
it('registers every normalized document type with an explicit renderer', () => { it('registers every normalized document type with an explicit renderer', () => {
+97 -80
View File
@@ -1,118 +1,135 @@
import type { ComponentType } from 'react' import type { ComponentType } from 'react'
import { BookOpen, Building2, CalendarClock, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil, UserRound } from 'lucide-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 type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
import { folderIsOpen } from './boardDomain'
export type ExhibitWidgetProps = { export type WidgetCommand =
exhibit: Evidence | { type: 'open-document'; documentId: string }
documents: CaseDocument[] | { type: 'toggle-folder'; folderId: string }
onOpenSource: (id: string) => void | { type: 'edit-folder'; folderId: string }
onToggleFolder: (id: string) => void | { type: 'edit-event'; eventId: string }
onEditFolder: (id: string) => void | { type: 'edit-party'; partyId: string }
onEditEvent: (id: string) => void | { type: 'edit-document'; documentId: string }
onEditParty: (id: string) => void | { 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 = { export type ExhibitWidgetDefinition = {
visualType: Exclude<EvidenceType, 'evidence'> modelKind: 'exhibit'
heading: (exhibit: Evidence, documents: CaseDocument[]) => string visualType: ExhibitType
connectionPoint: (exhibit: Evidence) => { x: number; y: number } 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<ExhibitWidgetProps> Component: ComponentType<ExhibitWidgetProps>
} }
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 <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p> return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
{!folderIsOpen(exhibit) && <div className="folder-documents">{documents.slice(0, 3).map(document => <button key={document.id} onClick={() => onOpenSource(document.id)} title={document.title}><FileText size={12}/><span>{document.title}</span>{(document.publishedAt || document.date) && <time>{(document.publishedAt || document.date).slice(0, 10)}</time>}</button>)}{documents.length > 3 && <small>+ {documents.length - 3} MORE FILES</small>}</div>} {!exhibit.isOpen && <div className="folder-documents">{documents.slice(0,3).map(document => <button key={document.id} onClick={() => context.dispatch({ type:'open-document',documentId:document.id })} title={document.title}><FileText size={12}/><span>{document.title}</span>{document.publishedAt && <time>{document.publishedAt.slice(0,10)}</time>}</button>)}{documents.length > 3 && <small>+ {documents.length - 3} MORE FILES</small>}</div>}
<div className="folder-actions"><button onClick={() => onToggleFolder(exhibit.id)}>{folderIsOpen(exhibit) ? <Folder size={12}/> : <FolderOpen size={12}/>} {folderIsOpen(exhibit) ? 'CLOSE' : 'OPEN'}</button><button onClick={() => onEditFolder(exhibit.id)}><Pencil size={12}/> EDIT</button></div> <div className="folder-actions"><button onClick={() => context.dispatch({ type:'toggle-folder',folderId:exhibit.id })}>{exhibit.isOpen ? <Folder size={12}/> : <FolderOpen size={12}/>} {exhibit.isOpen ? 'CLOSE' : 'OPEN'}</button><button onClick={() => context.dispatch({ type:'edit-folder',folderId:exhibit.id })}><Pencil size={12}/> EDIT</button></div>
</div> </div>
} }
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 <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p> return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
{exhibit.eventDate && <time>{exhibit.eventDate.replaceAll('-', ' / ')}</time>} {source && <button onClick={() => context.dispatch({ type:'open-document',documentId:source.toExhibitId })}><BookOpen size={13}/> VIEW SOURCE</button>}
{exhibit.sourceDocumentId && <button onClick={() => onOpenSource(exhibit.sourceDocumentId!)}><BookOpen size={13}/> VIEW SOURCE</button>}
</div> </div>
} }
function EventWidget({ exhibit, onEditEvent }: ExhibitWidgetProps) { function EventWidget({ exhibit, context }: ExhibitWidgetProps) {
const supportCount = exhibit.supportingEvidenceIds?.length || 0 if (exhibit.type !== 'event') return null
const supportCount = relationsFrom(context,exhibit.id,'supports').length
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p> return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
<time>{exhibit.eventDate ? new Date(exhibit.eventDate).toLocaleString() : 'UNDATED'}</time> <time>{exhibit.eventDate ? new Date(exhibit.eventDate).toLocaleString() : 'UNDATED'}</time>
<div className="event-actions"><span>{supportCount} SUPPORTING EXHIBIT{supportCount === 1 ? '' : 'S'}</span><button onClick={() => onEditEvent(exhibit.id)}><CalendarClock size={12}/> EDIT EVENT</button></div> <div className="event-actions"><span>{supportCount} SUPPORTING EXHIBIT{supportCount === 1 ? '' : 'S'}</span><button onClick={() => context.dispatch({ type:'edit-event',eventId:exhibit.id })}><CalendarClock size={12}/> EDIT EVENT</button></div>
</div> </div>
} }
function PartyWidget({ exhibit, onEditParty }: ExhibitWidgetProps) { function PartyWidget({ exhibit, context }: ExhibitWidgetProps) {
if (exhibit.type !== 'party') return null
const person = exhibit.partyKind === 'person' const person = exhibit.partyKind === 'person'
return <div className="card-content"><div className="party-identity">{person ? <UserRound size={28}/> : <Building2 size={28}/>}<div><h3>{exhibit.title}</h3><small>{person ? 'PERSON' : (exhibit.organizationKind || 'ORGANIZATION').replaceAll('_', ' ').toUpperCase()}</small></div></div> const evidenceCount = relationsFrom(context,exhibit.id,'concerns').length
return <div className="card-content"><div className="party-identity">{person ? <UserRound size={28}/> : <Building2 size={28}/>}<div><h3>{exhibit.title}</h3><small>{person ? 'PERSON' : (exhibit.organizationKind || 'ORGANIZATION').replaceAll('_',' ').toUpperCase()}</small></div></div>
<p>{exhibit.content || 'No dossier summary yet.'}</p> <p>{exhibit.content || 'No dossier summary yet.'}</p>
{(exhibit.aliases?.length || 0) > 0 && <div className="party-aliases">AKA · {exhibit.aliases!.join(' · ')}</div>} {exhibit.aliases.length > 0 && <div className="party-aliases">AKA · {exhibit.aliases.join(' · ')}</div>}
<div className="event-actions"><span>{exhibit.relatedEvidenceIds?.length || 0} ASSOCIATED EXHIBITS</span><button onClick={() => onEditParty(exhibit.id)}><Pencil size={12}/> EDIT DOSSIER</button></div> <div className="event-actions"><span>{evidenceCount} ASSOCIATED EXHIBITS</span><button onClick={() => context.dispatch({ type:'edit-party',partyId:exhibit.id })}><Pencil size={12}/> EDIT DOSSIER</button></div>
</div> </div>
} }
const standardPoint = (exhibit: Evidence) => ({ x: exhibit.x + exhibit.width / 2, y: exhibit.y + 68 }) function DocumentWidget({ exhibit }: ExhibitWidgetProps) {
const folderDefinition: ExhibitWidgetDefinition = { if (exhibit.type !== 'document') return null
visualType: 'folder', heading: (_exhibit, documents) => `EVIDENCE FOLDER / ${documents.length}`, return <strong>{exhibit.title}</strong>
connectionPoint: standardPoint, Component: FolderWidget,
} }
export const exhibitWidgetRegistry: Record<EvidenceType, ExhibitWidgetDefinition> = { const standardPorts = (exhibit: Exhibit) => [{ id:'centre',x:exhibit.x + exhibit.width / 2,y:exhibit.y + exhibit.height / 2 }]
folder: folderDefinition, const notePorts = (exhibit: Exhibit) => [{ id:'knot',x:exhibit.x + exhibit.width / 2,y:exhibit.y + 12 }]
evidence: folderDefinition, const standardCapabilities: WidgetCapabilities = { movable:true,resizable:false,connectable:true,discardable:true,dockable:false }
note: { visualType: 'note', heading: () => 'INVESTIGATOR / NOTE', connectionPoint: exhibit => ({ x: exhibit.x + 54, y: exhibit.y + 12 }), Component: StandardWidget }, const searchable = (exhibit: Exhibit) => exhibit.type === 'document'
event: { visualType: 'event', heading: () => 'EVENT / THIS HAPPENED', connectionPoint: standardPoint, Component: EventWidget }, ? [exhibit.title,...exhibit.body,...Object.values(exhibit.metadata)].join('\n').toLocaleLowerCase()
party: { visualType: 'party', heading: exhibit => exhibit.partyKind === 'person' ? 'PARTY / PERSON DOSSIER' : 'PARTY / ORGANIZATION DOSSIER', connectionPoint: standardPoint, Component: PartyWidget }, : [exhibit.title,exhibit.content].join('\n').toLocaleLowerCase()
export const exhibitWidgetRegistry: Record<ExhibitType, ExhibitWidgetDefinition> = {
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) { export function exhibitWidget(type: ExhibitType) { return exhibitWidgetRegistry[type] }
return exhibitWidgetRegistry[type] || exhibitWidgetRegistry.note
}
type DocumentWidgetProps = { document: CaseDocument; source: string; onMemoryCue?: (cue: string) => void } type DocumentWidgetProps = { document: CaseDocument; source: string; onMemoryCue?: (cue: string) => void }
export type DocumentWidgetDefinition = { export type DocumentWidgetDefinition = { label:string; Preview:ComponentType<DocumentWidgetProps>; Asset:ComponentType<DocumentWidgetProps> }
label: string
Preview: ComponentType<DocumentWidgetProps>
Asset: ComponentType<DocumentWidgetProps>
}
function ImagePreview({ document, source }: DocumentWidgetProps) { function ImagePreview({ document,source }: DocumentWidgetProps) { return document.assetId ? <img draggable={false} src={source} alt=""/> : <GenericPreview document={document} source={source}/> }
return document.assetId ? <img draggable={false} src={source} alt=""/> : <GenericPreview document={document} source={source}/> function GenericPreview({ document }: DocumentWidgetProps) { return <div><ImageIcon size={35}/><small>{document.fileType.toUpperCase()}</small></div> }
} function TextPreview({ document,onMemoryCue }: DocumentWidgetProps) {
function GenericPreview({ document }: DocumentWidgetProps) { const excerpt = document.body.filter(Boolean).slice(0,2).join(' ')
return <div><ImageIcon size={35}/><small>{document.kind}</small></div> return <div className="text-document-preview"><p className="text-source-excerpt">{excerpt || document.title}</p><textarea aria-label={`Memory cue for ${document.title}`} maxLength={48} placeholder="WRITE A MEMORY CUE…" value={document.metadata.memory_cue || ''} onPointerDown={event => event.stopPropagation()} onClick={event => event.stopPropagation()} onDoubleClick={event => event.stopPropagation()} onChange={event => onMemoryCue?.(event.target.value)}/></div>
}
function TextPreview({ document, onMemoryCue }: DocumentWidgetProps) {
const excerpt = document.body.filter(Boolean).slice(0, 2).join(' ')
return <div className="text-document-preview">
<p className="text-source-excerpt">{excerpt || document.title}</p>
<textarea aria-label={`Memory cue for ${document.title}`} maxLength={48} placeholder="WRITE A MEMORY CUE…" value={document.metadata.memory_cue || ''}
onPointerDown={event => event.stopPropagation()} onClick={event => event.stopPropagation()} onDoubleClick={event => event.stopPropagation()} onChange={event => onMemoryCue?.(event.target.value)}/>
</div>
}
function ImageAsset({ document, source }: DocumentWidgetProps) {
return <img className="document-image" src={source} alt={document.fileName || document.title}/>
}
function FrameAsset({ document, source }: DocumentWidgetProps) {
return <iframe className="document-frame" src={source} title={document.fileName || document.title} sandbox="allow-same-origin"/>
}
function GenericAsset({ document, source }: DocumentWidgetProps) {
return <div className="unsupported-file"><FileText size={42}/><b>{document.fileName || document.title}</b><span>{document.mimeType || 'Unknown file type'} · {document.fileSize ? `${Math.ceil(document.fileSize / 1024)} KB` : ''}</span><a href={source} download={document.fileName}>DOWNLOAD ORIGINAL</a></div>
} }
function ImageAsset({ document,source }: DocumentWidgetProps) { return <img className="document-image" src={source} alt={document.fileName || document.title}/> }
function FrameAsset({ document,source }: DocumentWidgetProps) { return <iframe className="document-frame" src={source} title={document.fileName || document.title} sandbox="allow-same-origin"/> }
function GenericAsset({ document,source }: DocumentWidgetProps) { return <div className="unsupported-file"><FileText size={42}/><b>{document.fileName || document.title}</b><span>{document.mimeType || 'Unknown file type'} · {document.fileSize ? `${Math.ceil(document.fileSize / 1024)} KB` : ''}</span><a href={source} download={document.fileName}>DOWNLOAD ORIGINAL</a></div> }
const genericDocument = (label: string): DocumentWidgetDefinition => ({ label, Preview: GenericPreview, Asset: GenericAsset }) const genericDocument = (label:string):DocumentWidgetDefinition => ({label,Preview:GenericPreview,Asset:GenericAsset})
export const documentWidgetRegistry: Record<SourceFileType, DocumentWidgetDefinition> = { export const documentWidgetRegistry:Record<SourceFileType,DocumentWidgetDefinition> = {
image: { label: 'Image', Preview: ImagePreview, Asset: ImageAsset }, image:{label:'Image',Preview:ImagePreview,Asset:ImageAsset},pdf:{label:'PDF',Preview:GenericPreview,Asset:FrameAsset},text:{label:'Text document',Preview:TextPreview,Asset:FrameAsset},
pdf: { label: 'PDF', Preview: GenericPreview, Asset: FrameAsset }, web_capture:genericDocument('Web capture'),email:genericDocument('Email'),article:genericDocument('Article'),filing:genericDocument('Company filing'),price_list:genericDocument('Price list'),file:genericDocument('Generic file'),
text: { label: 'Text document', Preview: TextPreview, Asset: FrameAsset },
web_capture: genericDocument('Web capture'),
email: genericDocument('Email'),
article: genericDocument('Article'),
filing: genericDocument('Company filing'),
price_list: genericDocument('Price list'),
file: genericDocument('Generic file'),
} }
export function documentWidget(type:SourceFileType) { return documentWidgetRegistry[type] || documentWidgetRegistry.file }
export function documentWidget(type: SourceFileType) { export function documentExhibits(exhibits:Exhibit[]):DocumentExhibit[] { return exhibits.filter((exhibit):exhibit is DocumentExhibit => exhibit.type === 'document') }
return documentWidgetRegistry[type] || documentWidgetRegistry.file export function evidenceExhibits(exhibits:Exhibit[]):Evidence[] { return exhibits.filter((exhibit):exhibit is Evidence => exhibit.type !== 'document') }
}
+97 -37
View File
@@ -1,8 +1,24 @@
export type EvidenceType = 'folder' | 'evidence' | 'note' | 'event' | 'party' export type ExhibitType = 'folder' | 'document' | 'note' | 'event' | 'party'
export type PartyKind = 'person' | 'organization' export type PartyKind = 'person' | 'organization'
export type OrganizationKind = 'business' | 'public_body' | 'association' | 'informal_group' | 'other' export type OrganizationKind = 'business' | 'public_body' | 'association' | 'informal_group' | 'other'
export type SourceFileType = 'image' | 'pdf' | 'web_capture' | 'email' | 'article' | 'filing' | 'price_list' | 'text' | 'file' export type SourceFileType = 'image' | 'pdf' | 'web_capture' | 'email' | 'article' | 'filing' | 'price_list' | 'text' | 'file'
export interface CanvasPlacement {
x: number
y: number
width: number
height: number
rotation: number
zIndex: number
hidden: boolean
}
export interface ExhibitBase extends CanvasPlacement {
id: string
type: ExhibitType
title: string
}
export interface DocumentRegion { export interface DocumentRegion {
id: string id: string
label: string label: string
@@ -10,12 +26,17 @@ export interface DocumentRegion {
date?: string date?: string
} }
export interface CaseDocument { export interface FolderExhibit extends ExhibitBase {
id: string type: 'folder'
title: string content: string
kind: string isOpen: boolean
date: string }
export interface DocumentExhibit extends ExhibitBase {
type: 'document'
publishedAt?: string publishedAt?: string
capturedAt?: string
sourceUri?: string
body: string[] body: string[]
regions: DocumentRegion[] regions: DocumentRegion[]
assetId?: string assetId?: string
@@ -26,40 +47,48 @@ export interface CaseDocument {
metadata: Record<string, string> metadata: Record<string, string>
} }
export interface Evidence { export interface NoteExhibit extends ExhibitBase {
id: string type: 'note'
type: EvidenceType
title: string
content: string content: string
sourceDocumentId?: string
sourceRegionId?: string
eventDate?: string
supportingEvidenceIds?: string[]
partyKind?: PartyKind
organizationKind?: OrganizationKind
aliases?: string[]
relatedEvidenceIds?: string[]
x: number
y: number
width: number
config?: Record<string, unknown>
/** Compatibility projection for clients predating the generic relation graph. */
containedDocumentIds?: string[]
} }
export interface WidgetRelation { export interface EventExhibit extends ExhibitBase {
id: string type: 'event'
fromWidgetId: string content: string
toWidgetId: string eventDate?: string
type: string
sortOrder?: number
config?: Record<string, unknown>
} }
export interface PartyExhibit extends ExhibitBase {
type: 'party'
content: string
partyKind: PartyKind
organizationKind?: OrganizationKind
aliases: string[]
}
export type Exhibit = FolderExhibit | DocumentExhibit | NoteExhibit | EventExhibit | PartyExhibit
export type Evidence = Exclude<Exhibit, DocumentExhibit>
export type CaseDocument = DocumentExhibit
export type EvidenceType = Evidence['type']
interface ExhibitRelationBase {
id: string
fromExhibitId: string
toExhibitId: string
sortOrder: number
note?: string
}
export interface FolderMembership extends ExhibitRelationBase { type: 'contains' }
export interface EventSupportRelation extends ExhibitRelationBase { type: 'supports' }
export interface PartyAssociationRelation extends ExhibitRelationBase { type: 'concerns' }
export interface ProvenanceRelation extends ExhibitRelationBase { type: 'source'; sourceRegionId?: string }
export type ExhibitRelation = FolderMembership | EventSupportRelation | PartyAssociationRelation | ProvenanceRelation
export interface Connection { export interface Connection {
id: string id: string
fromEvidenceId: string fromExhibitId: string
toEvidenceId: string toExhibitId: string
label?: string label?: string
/** Percentage from slack (0) to taut (100). */ /** Percentage from slack (0) to taut (100). */
tightness?: number tightness?: number
@@ -73,6 +102,31 @@ export interface Connection {
export interface Viewport { x: number; y: number; zoom: number } export interface Viewport { x: number; y: number; zoom: number }
export interface TimelineRange { start: string; end: string } export interface TimelineRange { start: string; end: string }
export type BoardViewPlacement =
| { mode: 'docked'; dockEdge: 'top' | 'right' | 'bottom' | 'left'; size: number }
| { mode: 'canvas' | 'window'; x: number; y: number; width: number; height: number }
export interface TimelineView {
id: string
type: 'timeline'
placement: BoardViewPlacement
visible: boolean
zIndex: number
rangeMode: 'auto' | 'fixed'
range?: TimelineRange
}
export type BoardView = TimelineView
export interface TemporalFact {
id: string
exhibitId: string
kind: 'published' | 'captured' | 'occurred' | 'region_date'
start: string
end?: string
label: string
}
export interface BriefConcept { export interface BriefConcept {
id: string id: string
label: string label: string
@@ -87,15 +141,21 @@ export interface CaseState {
id: string id: string
title: string title: string
subtitle: string subtitle: string
documents: CaseDocument[] exhibits: Exhibit[]
evidence: Evidence[] relations: ExhibitRelation[]
relations: WidgetRelation[]
connections: Connection[] connections: Connection[]
views: BoardView[]
viewport: Viewport viewport: Viewport
timelineRange?: TimelineRange | null
brief: LevelBrief brief: LevelBrief
revision: number
updatedAt?: string updatedAt?: string
levelStatus?: string levelStatus?: string
sourceTemplateVersionId?: string sourceTemplateVersionId?: string
editingAllowed?: boolean editingAllowed?: boolean
} }
export function isDocumentExhibit(exhibit: Exhibit): exhibit is DocumentExhibit { return exhibit.type === 'document' }
export function isEvidenceExhibit(exhibit: Exhibit): exhibit is Evidence { return exhibit.type !== 'document' }
export function isFolderExhibit(exhibit: Exhibit): exhibit is FolderExhibit { return exhibit.type === 'folder' }
export function isEventExhibit(exhibit: Exhibit): exhibit is EventExhibit { return exhibit.type === 'event' }
export function isPartyExhibit(exhibit: Exhibit): exhibit is PartyExhibit { return exhibit.type === 'party' }