Adding working board
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.';
|
||||||
@@ -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.';
|
||||||
Generated
+396
-1
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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() }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 EDIT EVIDENCE VIEW</nav>
|
{!minimized && <><nav>FILE EDIT EVIDENCE 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
@@ -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
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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' }
|
||||||
|
|||||||
Reference in New Issue
Block a user