Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35685f765a | ||
|
|
8f1f5a8743 | ||
|
|
eeaa4138fa | ||
|
|
d99deb3c06 | ||
|
|
703b9652c9 | ||
|
|
18705fe55f | ||
|
|
d722ecae9d | ||
|
|
21a74e243c | ||
|
|
ffa81284df | ||
|
|
b39d2efa75 | ||
|
|
5d59af1dd9 | ||
|
|
57c8c91f21 | ||
|
|
38f50848d0 | ||
|
|
a15b463589 | ||
|
|
6f183daca8 | ||
|
|
ccef0955d4 | ||
|
|
a4b22a574b | ||
|
|
f97dceb4fe | ||
|
|
46bb2ba5ec | ||
|
|
a51b508118 | ||
|
|
45c7c6a292 | ||
|
|
b284275e98 | ||
|
|
d46a425401 | ||
|
|
e333d3b634 | ||
|
|
edfa4c866c | ||
|
|
c8a870549d | ||
|
|
5599d330d8 | ||
|
|
9702b9c3d9 | ||
|
|
545eb013bd | ||
|
|
9a4da41b49 |
@@ -2,4 +2,5 @@ DATABASE_URL=postgres://osint:osint_secret@localhost:5433/osint_dev
|
||||
PORT=8787
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
LEVEL_EDITING_ENABLED=true
|
||||
JWT_SECRET=osint-local-dev-secret
|
||||
MAX_DOCUMENT_BYTES=26214400
|
||||
|
||||
@@ -3,3 +3,5 @@ dist/
|
||||
.env
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -30,32 +30,66 @@ npm run dev
|
||||
|
||||
Open `http://localhost:5173`; Vite proxies `/api` to port 8787.
|
||||
|
||||
## Test
|
||||
|
||||
Run the fast domain tests without external services:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
With the development PostgreSQL container running, exercise the migration ledger and persistence API against uniquely named disposable databases:
|
||||
|
||||
```bash
|
||||
npm run test:integration
|
||||
```
|
||||
|
||||
The integration suite drops only the temporary databases it creates. Override `TEST_DATABASE_URL` when PostgreSQL is not available at the development default on port 5433.
|
||||
|
||||
Run the isolated Chromium smoke test after installing its browser runtime once with `npx playwright install chromium`:
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
The smoke harness builds the application, creates a disposable PostgreSQL database and level, exercises move, hand-pan, desktop wheel zoom, mobile touch pinch, folder expansion, file movement, and reload persistence, then removes the test database.
|
||||
|
||||
The browser suite also imports and solves the bundled Glass Harbor mystery against that disposable database. Mystery manifests are database-authoring content rather than compiled frontend cases; see [`mysteries/README.md`](mysteries/README.md).
|
||||
|
||||
## Data and API
|
||||
|
||||
The server stores its tables and migration ledger in the dedicated `osint` schema of PostgreSQL. `DATABASE_URL` is required.
|
||||
|
||||
The accepted model stores each playable or editable level as an isolated mutable board of normalized exhibits. Immutable template-version boards are cloned to create levels, and mutable levels can be cloned back into new template versions. Assets remain immutable and reusable across those copies.
|
||||
|
||||
The running POC still uses the earlier `widgets` and `playthrough_*` tables while the exhibit-schema cutover is prepared. They are explicitly transitional; new domain concepts should follow the exhibit model rather than extending those tables.
|
||||
The running POC uses the canonical exhibit schema directly. The earlier JSON case store, `widgets`, and `playthrough_*` tables were removed in migration 006.
|
||||
|
||||
The API surface is:
|
||||
|
||||
- `GET /api/levels`
|
||||
- `POST /api/levels` (editor only)
|
||||
- `GET /api/templates`
|
||||
- `POST /api/templates/:slug/levels` (instantiate a version; editor only)
|
||||
- `GET /api/levels/:id`
|
||||
- `PUT /api/levels/:id`
|
||||
- `POST /api/levels/:id/reset`
|
||||
- `POST /api/levels/:id/templates` (save a new immutable version; editor only)
|
||||
- `POST /api/levels/:id/documents` (editor only)
|
||||
- `GET /api/assets/:id`
|
||||
- `GET /api/session` (verified session and admin capability summary)
|
||||
- `GET /api/health`
|
||||
|
||||
The browser also keeps a local emergency copy so a network interruption does not lose an in-progress board.
|
||||
|
||||
## Level editor
|
||||
|
||||
Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1`. If the database is empty, this surface creates the first blank level. The target model intentionally makes editing and playing the same operation against a mutable level; authoring additionally exposes “save as template”. The current playthrough overlay remains only until the exhibit-schema migration is complete.
|
||||
Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1` while signed in with a JWT carrying `role: "admin"`. The shared `auth_token` cookie is verified with `JWT_SECRET`; the legacy `isAdmin: true` claim remains compatible. If the database is empty, this surface creates the first blank level. Editing and playing are the same operation against a mutable level. Authoring actions live under the **Admin** menu. Saving a template creates a new immutable version; instantiating gives every exhibit a fresh ID while sharing immutable binary assets. Reset restores the exact template version from which a level was created.
|
||||
|
||||
For the standalone development Compose stack, visit `/api/dev/admin-session?returnTo=/?edit=1` once to receive a local signed admin cookie. This helper does not exist in production.
|
||||
|
||||
In edit mode, files can be dragged from the desktop onto the board or selected with **Import Document**. Images, PDFs, and text files render inside document windows; unknown formats remain downloadable source files. Extracted evidence becomes an editable folder widget. Its editor controls the title, annotation, contained documents, and each source document's publication time. The default upload limit is 25 MB and can be changed with `MAX_DOCUMENT_BYTES`.
|
||||
|
||||
Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. This is a capability switch, not authentication; add authentication before exposing production editing to untrusted users.
|
||||
Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. `JWT_SECRET` is inherited from that shared environment, and authoring endpoints additionally require a verified admin claim.
|
||||
|
||||
## POC exhibit and widget contract
|
||||
|
||||
@@ -64,19 +98,25 @@ The current POC defines four exhibit families and corresponding frontend widgets
|
||||
- **Folder** — a titled, annotated collection. Opening it expands its contained source files to their saved relation-bound board positions; closing it retracts and hides them.
|
||||
- **Source file** — immutable binary evidence plus user-editable title, publication time, file type, and arbitrary key/value metadata. Image is the first fully visual file renderer. PDF, web capture, email, article, filing, price list, text, and generic file are registered types with a generic fallback until their renderers are built.
|
||||
- **Note** — investigator-authored interpretation, visually distinct from source evidence.
|
||||
- **Event** — an investigator-authored “this happened” statement with narrative text, an occurrence time, and normalized links to its supporting evidence. Chronologically ordered events become the emerging story.
|
||||
- **Event** — an investigator-authored “this happened” statement with narrative text, an occurrence time, and normalized links to supporting exhibits. The editor manages citations, dashed support lines keep them visually distinct from red investigative thread, and chronologically ordered events form the reconstructed-story strip.
|
||||
|
||||
The planned **Party** family adds distinct Person and Organization exhibits (businesses are organizations). Their dossier-like widgets reveal normalized evidence associations, but parties remain identity objects rather than special folders.
|
||||
The **Party** family has distinct Person and Organization subtypes (businesses are organizations). Names begin as concepts in the level brief. The investigator classifies each concept, which creates the appropriate dossier exhibit and records the resolution. Expected classifications remain author-only. Dossiers store aliases and normalized evidence associations, but parties remain identity objects rather than special folders.
|
||||
|
||||
**Red thread** is a normalized connection between any two exhibits, including expanded source documents. Each connection may carry an investigator-authored relation tag, a persisted tightness percentage, and a presentation choice. `LUGGAGE` reuses the investigator note's expressive hanging tag, rotating to read before opening the editor; `COMPACT` keeps the original knot-and-label treatment and opens the editor directly. Both remain available while the POC establishes which works best. When a connected source document is retracted into its folder, the visible endpoint follows it to the folder until it is expanded again.
|
||||
|
||||
Folder ownership is stored as a normalized membership. The contained document exhibit owns its expanded `xpos` and `ypos`. For deterministic collapse behavior, one exhibit has at most one owning folder; two exhibits may reuse the same immutable asset when the same source file must appear in multiple folders.
|
||||
|
||||
An open folder draws a pale red containment band to each expanded file. Each dated file independently projects a grey line to the temporal index. This allows the player to arrange files until those grey lines are vertical, close the folder, and later reopen the same arrangement.
|
||||
|
||||
The frontend widget registry maps an exhibit type, and optionally a document type, to its React visualization. Widgets do not own investigation-domain data.
|
||||
The timeline uses an optional board-level start and end date. Click **Timeline** in the menu or the displayed range in the footer to adjust it; **Use Automatic Range** returns to evidence-derived bounds. Configured bounds are normalized board data and follow template cloning and reset. Evidence outside the visible interval remains available and is pinned to the nearest timeline edge.
|
||||
|
||||
The typed frontend widget registry maps each exhibit type, and each document type, to its React visualization. Adding a domain type now produces a compile-time requirement to register its renderer. Widgets do not own investigation-domain data.
|
||||
|
||||
On desktop, an ordinary mouse wheel or two-finger trackpad scroll zooms the board. Desktop pinch gestures are consumed so they cannot zoom both the browser and board. Touch devices use a two-finger pinch on the board; toolbar controls remain available on every device.
|
||||
|
||||
## Deploy at osint.glitch.university
|
||||
|
||||
The production service depends on `gu_common`: it joins the external `gnommo` Docker network, uses `gnommo-db`, and is routed by the shared nginx container. Deploy `gu_common` after its nginx configuration changes, then deploy this repository:
|
||||
The production service depends on `gu_common`: it joins the external `gnommo` Docker network, uses `gnommo-db`, shares its JWT secret and cross-subdomain authentication cookie, and is routed by the shared nginx container. Deploy `gu_common` after its nginx configuration changes, then deploy this repository:
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
@@ -86,4 +126,4 @@ The deploy script builds and syncs the application, reads production database cr
|
||||
|
||||
## Deliberate POC boundaries
|
||||
|
||||
There are no accounts, arbitrary uploads, real-world web browsing, OCR, or collaboration yet. The server data model and provenance fields leave room for those later without making them part of the first playability test.
|
||||
Authentication is supplied by the shared Glitch University account system. There is no OSINT-specific account model, real-world web browsing, OCR, or collaboration yet. The server data model and provenance fields leave room for those later without making them part of the first playability test.
|
||||
|
||||
@@ -23,10 +23,11 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: osint-board-app
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NODE_ENV: development
|
||||
PORT: 8787
|
||||
DATABASE_URL: postgres://osint:osint_secret@db:5432/osint_dev
|
||||
CORS_ORIGIN: http://localhost:8787
|
||||
JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret}
|
||||
LEVEL_EDITING_ENABLED: "true"
|
||||
MAX_DOCUMENT_BYTES: 26214400
|
||||
ports:
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
PORT: 8787
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@gnommo-db:5432/${POSTGRES_DB}
|
||||
CORS_ORIGIN: https://osint.${DOMAIN}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LEVEL_EDITING_ENABLED: ${LEVEL_EDITING_ENABLED:-false}
|
||||
MAX_DOCUMENT_BYTES: ${MAX_DOCUMENT_BYTES:-26214400}
|
||||
expose:
|
||||
|
||||
+28
-28
@@ -14,49 +14,49 @@ This is the ordered implementation roadmap following the accepted exhibit model.
|
||||
- [x] Track `.gitignore`, `.dockerignore`, and `.env.example`; confirm that secrets and generated artifacts cannot enter Git accidentally.
|
||||
- [x] Commit the current working POC and tag the checkpoint `poc-pre-exhibit-model`.
|
||||
- [x] Confirm `main` is pushed to the Ramanujan-hosted `origin` repository.
|
||||
- [ ] Keep `npm run build` green and make `npm test` run real tests rather than an empty suite.
|
||||
- [x] Keep `npm run build` green and make `npm test` run real tests rather than an empty suite.
|
||||
|
||||
## Milestone 1: focused safety net
|
||||
|
||||
- [ ] Test screen/board coordinate conversion across pan and zoom levels.
|
||||
- [ ] Test timeline date-to-pixel projection and recomputation after viewport resizing.
|
||||
- [ ] Test the interaction boundary between exhibit dragging, hand-tool panning, and board-only pinch zoom.
|
||||
- [ ] Test folder open/close behavior, retained file positions, and containment bands.
|
||||
- [ ] Test document upload, metadata persistence, board save/reload, and reset.
|
||||
- [ ] Run migrations and API integration tests against disposable PostgreSQL, not SQLite or mocked persistence.
|
||||
- [ ] Add one browser smoke test: open a level, drag an exhibit, zoom, open a folder, move a file, reload, and verify persistence.
|
||||
- [x] Test screen/board coordinate conversion across pan and zoom levels.
|
||||
- [x] Test timeline date-to-pixel projection and recomputation after viewport resizing.
|
||||
- [x] Test the interaction boundary between exhibit dragging, hand-tool panning, desktop wheel zoom, and mobile-only pinch zoom.
|
||||
- [x] Test folder open/close behavior, retained file positions, and containment-band state.
|
||||
- [x] Test document upload, metadata persistence, board save/reload, and reset.
|
||||
- [x] Run migrations and API integration tests against disposable PostgreSQL, not SQLite or mocked persistence.
|
||||
- [x] Add one browser smoke test: open an isolated level, drag an exhibit, pan, board-zoom, open a folder, move a file, reload, and verify persistence.
|
||||
|
||||
## Milestone 2: exhibit-schema cutover
|
||||
|
||||
- [ ] Add boards, exhibits, exhibit types, subtype tables, immutable template versions, and cloned mutable levels.
|
||||
- [ ] Migrate transitional `widgets`, `widget_relations`, and `playthrough_*` data with equivalence checks.
|
||||
- [ ] Implement template instantiation and “save level as template” as transactional clone operations.
|
||||
- [ ] Introduce a server-side repository/service boundary so SQL and cloning transactions do not live in Express route handlers.
|
||||
- [ ] Move the frontend to an exhibit/widget registry backed by the normalized API.
|
||||
- [ ] Remove transitional tables only after automated data-equivalence and behavior checks pass.
|
||||
- [x] Add boards, exhibits, exhibit types, subtype tables, immutable template versions, and mutable levels.
|
||||
- [x] Cut over the explicitly disposable POC database directly; no transitional data existed to backfill or compare.
|
||||
- [x] Implement template instantiation, version selection, reset, and “save level as template” as transactional clone operations.
|
||||
- [x] Introduce a server-side repository/service boundary so SQL and cloning transactions do not live in Express route handlers.
|
||||
- [x] Move exhibit and document rendering to a typed frontend widget registry backed by the normalized API.
|
||||
- [x] Remove transitional `widgets`, `widget_relations`, and `playthrough_*` tables in the canonical cutover migration.
|
||||
|
||||
## Milestone 3: events and parties
|
||||
|
||||
### Events and narrative
|
||||
|
||||
- [ ] Implement Event exhibits with occurrence time and investigator-authored narrative text.
|
||||
- [ ] Implement normalized Event-to-Evidence links and their board visualization.
|
||||
- [ ] Present chronologically ordered events as the emerging investigation story.
|
||||
- [x] Implement Event exhibits with occurrence time and investigator-authored narrative text.
|
||||
- [x] Implement normalized Event-to-Evidence links and their distinct board visualization.
|
||||
- [x] Present chronologically ordered events as the emerging investigation story.
|
||||
|
||||
### Party exhibits
|
||||
|
||||
- [ ] Add a Party exhibit supertype representing an investigation participant.
|
||||
- [ ] Add a Person subtype with display name, normalized aliases, and extensible structured identity fields.
|
||||
- [ ] Add an Organization subtype covering businesses, public bodies, associations, and informal groups.
|
||||
- [ ] Add normalized many-to-many Party-to-Evidence associations with an optional explanatory note.
|
||||
- [x] Add a Party exhibit supertype representing an investigation participant.
|
||||
- [x] Add a Person subtype with display name, normalized aliases, and extensible structured identity fields.
|
||||
- [x] Add an Organization subtype covering businesses, public bodies, associations, and informal groups.
|
||||
- [x] Add normalized many-to-many Party-to-Evidence associations with an optional explanatory note.
|
||||
- [ ] Add typed Party-to-Party relationships such as employment, ownership, membership, control, and representation.
|
||||
- [ ] Build distinct Person and Organization widgets that can open as dossiers and reveal associated evidence without using folder ownership semantics.
|
||||
- [ ] Include parties, aliases, evidence associations, and party relationships in template/level cloning.
|
||||
- [x] Build distinct Person and Organization dossier presentations that reveal associated evidence without using folder ownership semantics.
|
||||
- [x] Include brief concepts, parties, aliases, evidence associations, and party relationships in template/level cloning.
|
||||
|
||||
## Milestone 4: first playable mystery
|
||||
|
||||
- [ ] Design a small mystery that exercises documents, folders, notes, events, people, organizations, connections, and the timeline.
|
||||
- [ ] Create it as an immutable level-template version using the same supported operations available to an author.
|
||||
- [ ] Instantiate and solve a cloned level without modifying the template or relying on hard-coded case behavior.
|
||||
- [ ] Turn the successful solve path into an end-to-end acceptance test.
|
||||
- [ ] Perform a manual playability and visual-polish pass before production deployment.
|
||||
- [x] Design a small mystery that exercises documents, folders, notes, events, people, organizations, connections, and the timeline.
|
||||
- [x] Create it as an immutable level-template version using the same supported operations available to an author.
|
||||
- [x] Instantiate and solve a cloned level without modifying the template or relying on hard-coded case behavior.
|
||||
- [x] Turn the successful solve path into an end-to-end acceptance test.
|
||||
- [x] Perform a manual playability and visual-polish pass before production deployment.
|
||||
|
||||
+10
-15
@@ -1,6 +1,6 @@
|
||||
# GUPI OSINT Board: canonical exhibit data model
|
||||
|
||||
Status: accepted design foundation.
|
||||
Status: accepted design foundation; the core schema, template lifecycle, frontend registry, Event workflow, brief concepts, and Party subtypes are implemented. Interactive Party-to-Party relationships remain roadmap work.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
@@ -9,6 +9,7 @@ Status: accepted design foundation.
|
||||
- A **widget** is the frontend visualization and interaction implementation selected for an exhibit type.
|
||||
- A **document type** specializes a document exhibit: image, PDF, web capture, email, article, filing, price list, text, or generic file.
|
||||
- A **board** is a neutral container for exhibits. Both mutable levels and immutable template versions own boards.
|
||||
- A board may define a temporal viewport (`board_timeline_settings`). If absent, the client derives a range from dated evidence; if present, the range clones and resets with the board.
|
||||
- A **level** is a mutable board copy used for either play or authoring.
|
||||
- A **level template version** is an immutable board snapshot.
|
||||
|
||||
@@ -147,7 +148,7 @@ CREATE TABLE osint.event_exhibits (
|
||||
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
narrative_text TEXT NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL
|
||||
occurred_at TIMESTAMPTZ
|
||||
);
|
||||
```
|
||||
|
||||
@@ -158,10 +159,10 @@ The service validates that every base exhibit has exactly one subtype row matchi
|
||||
An event is an investigator-authored assertion: **“this happened.”** It is not source evidence and must not silently inherit a document's publication time.
|
||||
|
||||
- `narrative_text` states what the investigator believes happened.
|
||||
- `occurred_at` places that assertion in reconstructed time.
|
||||
- Nullable `occurred_at` places that assertion in reconstructed time when known. It must never inherit the exhibit's creation timestamp.
|
||||
- One event may cite several supporting exhibits.
|
||||
- One exhibit may support several events.
|
||||
- Events ordered by `occurred_at` form the emerging case narrative; there is no duplicated story-text record.
|
||||
- Dated events ordered by `occurred_at` form the temporal narrative. Undated events remain visible in the reconstructed story without affecting the timeline range; there is no duplicated story-text record.
|
||||
|
||||
Supporting evidence is an explicit normalized relationship:
|
||||
|
||||
@@ -180,7 +181,7 @@ A deferred constraint trigger verifies that both exhibits belong to the same boa
|
||||
|
||||
In the frontend, the Event widget shows its occurrence time, narrative text, and evidence count. Opening or selecting it reveals its supporting exhibits. Lines between an event and its evidence visualize `event_evidence`; they are not ordinary folder containment bands.
|
||||
|
||||
### Party semantics (planned)
|
||||
### Party and brief-concept semantics
|
||||
|
||||
A party is a person or organization that participates in the investigation. Businesses are organizations. A party is a first-class exhibit, not a special folder: identity and evidence association must not be represented as file ownership.
|
||||
|
||||
@@ -193,6 +194,8 @@ The intended normalized shape is a `party_exhibits` supertype with one-to-one `p
|
||||
|
||||
The frontend provides distinct Person and Organization widgets through the exhibit registry. They may visually behave like dossiers—opening one can reveal associated evidence—but that interaction is derived from `party_evidence`; it does not turn the party into a folder or cause evidence to be owned by or disappear into the party.
|
||||
|
||||
A name appearing in the level brief begins as a normalized `brief_concept`, not as an exhibit. The author may record the expected Party classification, which is omitted from play-mode API responses. When the investigator classifies a concept as Person or Organization, the level creates a Party exhibit and records it in `resolved_party_exhibit_id`. This keeps the reasoning action explicit: the game does not pre-create a correctly typed party and merely hide its widget.
|
||||
|
||||
## Assets and document content
|
||||
|
||||
`assets` stores immutable uploaded bytes, checksum, MIME type, original filename, and size. Multiple cloned document exhibits may reference one asset.
|
||||
@@ -317,14 +320,6 @@ The following are computed and must not become duplicate source-of-truth tables:
|
||||
|
||||
`save_level_as_template(level_id)` runs the same clone operation into a new immutable template-version board.
|
||||
|
||||
## Migration direction
|
||||
## Implemented cutover
|
||||
|
||||
The current `widgets`, `widget_relations`, and `playthrough_*` tables are transitional. The cutover should:
|
||||
|
||||
1. Introduce boards, exhibit tables, subtype tables, templates, and typed metadata.
|
||||
2. Convert existing authored widgets to exhibits.
|
||||
3. Materialize each existing playthrough as its own cloned mutable level.
|
||||
4. Move document layout from relation JSON into document exhibit coordinates.
|
||||
5. Convert `contains` relations to folder memberships and generic connections to exhibit connections.
|
||||
6. Update the API to read and write exhibits directly.
|
||||
7. Remove the transitional widget/playthrough tables only after data equivalence checks pass.
|
||||
Migration 006 made this the sole persistence model. Because the POC database contained no canonical or legacy content worth preserving, the cutover intentionally dropped the JSON case store, `widgets`, `widget_relations`, and `playthrough_*` tables without a backfill period. Template save, version selection, instantiation, and reset use one transactional board-cloning service. Exhibit and document types resolve through the typed frontend registry; the remaining model work is the planned exhibit families and richer behavior.
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { expect, test, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
async function dragBy(page: Page, locator: Locator, deltaX: number, deltaY: number) {
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error('Drag target is not visible')
|
||||
const start = { x: box.x + Math.min(30, box.width / 3), y: box.y + Math.min(14, box.height / 4) }
|
||||
await page.mouse.move(start.x, start.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(start.x + deltaX / 2, start.y + deltaY / 2, { steps: 3 })
|
||||
await page.mouse.move(start.x + deltaX, start.y + deltaY, { steps: 3 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
async function boardPosition(locator: Locator) {
|
||||
return locator.evaluate(element => ({
|
||||
left: Number.parseFloat((element as HTMLElement).style.left),
|
||||
top: Number.parseFloat((element as HTMLElement).style.top),
|
||||
}))
|
||||
}
|
||||
|
||||
async function waitForSave(page: Page, action: () => Promise<void>) {
|
||||
const response = page.waitForResponse(candidate => candidate.request().method() === 'PUT' && candidate.url().includes('/api/levels/') && candidate.ok())
|
||||
await action()
|
||||
await response
|
||||
}
|
||||
|
||||
test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch, and reload persistence', async ({ page }) => {
|
||||
await page.goto('/?level=e2e-level&edit=1')
|
||||
await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'ADMIN', exact: true })).toBeVisible()
|
||||
expect((await page.locator('.menubar nav > button, .menubar nav > .admin-menu > button').allTextContents()).map(label => label.replace(/\d+$/, ''))).toEqual(['EVIDENCE', 'CASE BRIEF', 'TIMELINE', 'HELP', 'ADMIN'])
|
||||
const temporalLayering = await page.evaluate(() => ({
|
||||
links: Number.parseInt(getComputedStyle(document.querySelector('.temporal-links')!).zIndex, 10),
|
||||
timeline: Number.parseInt(getComputedStyle(document.querySelector('.timeline')!).zIndex, 10),
|
||||
}))
|
||||
expect(temporalLayering.links).toBeGreaterThan(temporalLayering.timeline)
|
||||
expect(await page.locator('.timeline .year').first().evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThanOrEqual(12)
|
||||
expect(await page.locator('.timeline-label button').evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThanOrEqual(11)
|
||||
await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/)
|
||||
await expect(page.locator('.brief-panel')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Case brief', exact: true })).toContainText('2')
|
||||
await page.getByRole('button', { name: 'Minimize brief', exact: true }).click()
|
||||
await expect(page.locator('.brief-panel')).toHaveClass(/\bminimized\b/)
|
||||
await expect(page.locator('.brief-panel > p')).toBeHidden()
|
||||
await expect(page.getByRole('button', { name: 'Close brief', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Restore brief', exact: true }).click()
|
||||
await expect(page.locator('.brief-panel')).not.toHaveClass(/\bminimized\b/)
|
||||
await page.getByRole('button', { name: 'BEGIN INVESTIGATION', exact: true }).click()
|
||||
|
||||
await page.getByRole('button', { name: 'EVIDENCE', exact: true }).click()
|
||||
const documentRow = page.locator('[data-document-row-id="22222222-2222-4222-8222-222222222222"]')
|
||||
await documentRow.click()
|
||||
await expect(documentRow).toHaveClass(/\bselected\b/)
|
||||
await expect(page.locator('[data-temporal-id="widget:11111111-1111-4111-8111-111111111111"]')).toHaveClass(/\bdocument-located\b/)
|
||||
await expect(page.locator('.document-locator-beam .document-locator-ray')).toBeVisible()
|
||||
await documentRow.dblclick()
|
||||
await expect(page.getByRole('button', { name: 'Close document', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Close document', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Close documents', exact: true }).click()
|
||||
|
||||
page.once('dialog', dialog => dialog.accept('Disposable working theory'))
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'NEW NOTE', exact: true }).click())
|
||||
const note = page.locator('.evidence-card.note').filter({ hasText: 'Disposable working theory' })
|
||||
const trash = page.locator('.board-trash')
|
||||
await expect(note).toBeVisible()
|
||||
const noteBox = await note.boundingBox(), trashBox = await trash.boundingBox()
|
||||
if (!noteBox || !trashBox) throw new Error('Note or exhibit trash is not visible')
|
||||
const noteDragStart = { x: noteBox.x + noteBox.width / 2, y: noteBox.y + noteBox.height / 2 }
|
||||
const discardSave = page.waitForResponse(candidate => candidate.request().method() === 'PUT' && candidate.url().includes('/api/levels/') && candidate.ok())
|
||||
await page.mouse.move(noteDragStart.x, noteDragStart.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(trashBox.x + trashBox.width / 2, trashBox.y + trashBox.height / 2, { steps: 8 })
|
||||
await expect(trash).toHaveClass(/\bactive\b/)
|
||||
await page.mouse.up()
|
||||
await discardSave
|
||||
await expect(note).toHaveCount(0)
|
||||
await page.reload()
|
||||
await expect(page.locator('.evidence-card.note')).toHaveCount(0)
|
||||
|
||||
const folder = page.locator('[data-temporal-id="widget:11111111-1111-4111-8111-111111111111"]')
|
||||
const file = page.locator('[data-temporal-id="file:contains:11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222"]')
|
||||
const board = page.locator('.board')
|
||||
const boardViewport = page.locator('.board-viewport')
|
||||
const containmentBand = page.locator('.folder-bands line')
|
||||
await expect(folder).toBeVisible()
|
||||
await expect(containmentBand).toHaveClass(/\bclosed\b/)
|
||||
const folderBefore = await boardPosition(folder)
|
||||
|
||||
await waitForSave(page, () => dragBy(page, folder, 70, 42))
|
||||
const movedFolder = await boardPosition(folder)
|
||||
expect(movedFolder.left).toBeGreaterThan(folderBefore.left)
|
||||
expect(movedFolder.top).toBeGreaterThan(folderBefore.top)
|
||||
|
||||
await page.reload()
|
||||
await expect(folder).toBeVisible()
|
||||
expect(await boardPosition(folder)).toEqual(movedFolder)
|
||||
|
||||
await waitForSave(page, () => folder.getByRole('button', { name: 'OPEN', exact: true }).click())
|
||||
await expect(file).toHaveClass(/\bopen\b/)
|
||||
await expect(containmentBand).toHaveClass(/\bopen\b/)
|
||||
const fileBefore = await boardPosition(file)
|
||||
await waitForSave(page, () => dragBy(page, file, 56, 35))
|
||||
const movedFile = await boardPosition(file)
|
||||
expect(movedFile.left).toBeGreaterThan(fileBefore.left)
|
||||
expect(movedFile.top).toBeGreaterThan(fileBefore.top)
|
||||
|
||||
await page.reload()
|
||||
await expect(file).toHaveClass(/\bopen\b/)
|
||||
expect(await boardPosition(file)).toEqual(movedFile)
|
||||
|
||||
const folderBeforePan = await boardPosition(folder)
|
||||
const transformBeforePan = await board.getAttribute('style')
|
||||
await expect(page.getByRole('button', { name: 'MOVE', exact: true })).toHaveClass(/\bactive\b/)
|
||||
const viewportBox = await boardViewport.boundingBox()
|
||||
if (!viewportBox) throw new Error('Board viewport is not visible')
|
||||
await waitForSave(page, async () => {
|
||||
await boardViewport.dispatchEvent('pointerdown', { pointerId: 31, pointerType: 'mouse', isPrimary: true, button: 0, clientX: viewportBox.x + 240, clientY: viewportBox.y + 230 })
|
||||
await boardViewport.dispatchEvent('pointermove', { pointerId: 31, pointerType: 'mouse', isPrimary: true, button: 0, clientX: viewportBox.x + 290, clientY: viewportBox.y + 195 })
|
||||
await boardViewport.dispatchEvent('pointerup', { pointerId: 31, pointerType: 'mouse', isPrimary: true, button: 0, clientX: viewportBox.x + 290, clientY: viewportBox.y + 195 })
|
||||
})
|
||||
expect(await boardPosition(folder)).toEqual(folderBeforePan)
|
||||
const transformAfterPan = await board.getAttribute('style')
|
||||
expect(transformAfterPan).not.toEqual(transformBeforePan)
|
||||
|
||||
await page.reload()
|
||||
await expect(folder).toBeVisible()
|
||||
expect(await board.getAttribute('style')).toEqual(transformAfterPan)
|
||||
|
||||
const transformBeforeWheel = await board.getAttribute('style')
|
||||
const browserMetricsBeforeWheel = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))
|
||||
const folderAnchorBefore = await folder.boundingBox()
|
||||
if (!folderAnchorBefore) throw new Error('Folder is not visible for cursor-centered zoom')
|
||||
const wheelAnchor = { x: folderAnchorBefore.x + folderAnchorBefore.width / 2, y: folderAnchorBefore.y + folderAnchorBefore.height / 2 }
|
||||
await page.mouse.move(wheelAnchor.x, wheelAnchor.y)
|
||||
await page.mouse.wheel(0, -80)
|
||||
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforeWheel)
|
||||
const folderAnchorAfter = await folder.boundingBox()
|
||||
if (!folderAnchorAfter) throw new Error('Folder disappeared after zoom')
|
||||
expect(folderAnchorAfter.x + folderAnchorAfter.width / 2).toBeCloseTo(wheelAnchor.x, 0)
|
||||
expect(folderAnchorAfter.y + folderAnchorAfter.height / 2).toBeCloseTo(wheelAnchor.y, 0)
|
||||
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforeWheel)
|
||||
|
||||
const transformBeforeDesktopPinch = await board.getAttribute('style')
|
||||
await page.keyboard.down('Control')
|
||||
await page.mouse.wheel(0, -80)
|
||||
await page.keyboard.up('Control')
|
||||
await page.waitForTimeout(100)
|
||||
expect(await board.getAttribute('style')).toEqual(transformBeforeDesktopPinch)
|
||||
expect(await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio }))).toEqual(browserMetricsBeforeWheel)
|
||||
|
||||
const transformBeforeTouchPinch = await board.getAttribute('style')
|
||||
const touchOrigin = { x: viewportBox.x + 350, y: viewportBox.y + 240 }
|
||||
await boardViewport.dispatchEvent('pointerdown', { pointerId: 41, pointerType: 'touch', isPrimary: true, button: 0, clientX: touchOrigin.x, clientY: touchOrigin.y })
|
||||
await boardViewport.dispatchEvent('pointerdown', { pointerId: 42, pointerType: 'touch', isPrimary: false, button: 0, clientX: touchOrigin.x + 100, clientY: touchOrigin.y })
|
||||
await boardViewport.dispatchEvent('pointermove', { pointerId: 42, pointerType: 'touch', isPrimary: false, button: 0, clientX: touchOrigin.x + 150, clientY: touchOrigin.y })
|
||||
await expect.poll(() => board.getAttribute('style')).not.toEqual(transformBeforeTouchPinch)
|
||||
await boardViewport.dispatchEvent('pointerup', { pointerId: 42, pointerType: 'touch', isPrimary: false, button: 0, clientX: touchOrigin.x + 150, clientY: touchOrigin.y })
|
||||
await boardViewport.dispatchEvent('pointerup', { pointerId: 41, pointerType: 'touch', isPrimary: true, button: 0, clientX: touchOrigin.x, clientY: touchOrigin.y })
|
||||
|
||||
await page.getByRole('button', { name: 'Case brief', exact: true }).click()
|
||||
await expect(page.locator('.brief-panel')).toContainText('Ada Lovelace')
|
||||
const personConcept = page.locator('.brief-concepts section').filter({ hasText: 'Ada Lovelace' })
|
||||
await waitForSave(page, () => personConcept.getByRole('button', { name: 'PERSON', exact: true }).click())
|
||||
await expect(page.locator('.brief-panel')).toBeVisible()
|
||||
await expect(page.locator('.evidence-card.party.arriving')).toContainText('Ada Lovelace')
|
||||
await personConcept.getByRole('button', { name: 'EDIT DOSSIER', exact: true }).click()
|
||||
await expect(page.getByText('Edit person dossier')).toBeVisible()
|
||||
await page.getByLabel('Party aliases').fill('A. A. L.')
|
||||
await page.locator('.party-editor .folder-members input[type="checkbox"]').first().check()
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click())
|
||||
await expect(page.locator('.evidence-card.party')).toContainText('Ada Lovelace')
|
||||
|
||||
const organizationConcept = page.locator('.brief-concepts section').filter({ hasText: 'Difference Engine Bureau' })
|
||||
await waitForSave(page, () => organizationConcept.getByRole('button', { name: 'ORGANIZATION', exact: true }).click())
|
||||
await expect(page.locator('.brief-panel')).toBeVisible()
|
||||
await organizationConcept.getByRole('button', { name: 'EDIT DOSSIER', exact: true }).click()
|
||||
await page.getByLabel('Organization type').selectOption('public_body')
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click())
|
||||
await page.getByRole('button', { name: 'CREATE PARTY NOT LISTED ABOVE', exact: true }).click()
|
||||
await expect(page.getByText('Create party dossier', { exact: true })).toBeVisible()
|
||||
await page.getByLabel('Party type').selectOption('person')
|
||||
await page.getByLabel('Party name').fill('Mara Elise Voss')
|
||||
await page.getByLabel('Party summary').fill('Identified by the investigator outside the supplied concept list.')
|
||||
await page.locator('.party-editor .folder-members input[type="checkbox"]').first().check()
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click())
|
||||
await expect(page.locator('.brief-panel')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'RETURN TO BOARD', exact: true }).click()
|
||||
await page.reload()
|
||||
await expect(page.locator('.brief-panel')).toBeHidden()
|
||||
await expect(page.locator('.evidence-card.party')).toHaveCount(3)
|
||||
await expect(page.locator('.evidence-card.party')).toContainText(['Ada Lovelace', 'Difference Engine Bureau', 'Mara Elise Voss'])
|
||||
|
||||
await page.getByRole('button', { name: 'NEW EVENT', exact: true }).click()
|
||||
await expect(page.getByText('Edit reconstructed event')).toBeVisible()
|
||||
await page.getByLabel('Event title').fill('The browser clue was connected')
|
||||
await page.getByLabel('Event narrative').fill('The investigator connected the folder to a dated source.')
|
||||
await page.locator('.event-support-list input[type="checkbox"]').first().check()
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE EVENT', exact: true }).click())
|
||||
await page.reload()
|
||||
await expect(page.locator('.evidence-card.event')).toContainText('The browser clue was connected')
|
||||
await expect(page.locator('.evidence-card.event')).toContainText('UNDATED')
|
||||
await expect(page.locator('.story-strip')).toContainText('The investigator connected the folder')
|
||||
await expect(page.locator('.story-strip')).toContainText('UNDATED')
|
||||
await expect(page.locator('.timeline .marker')).toHaveCount(1)
|
||||
await expect(page.locator('.event-support-lines line')).toHaveCount(1)
|
||||
|
||||
page.once('dialog', dialog => dialog.accept('Browser Template'))
|
||||
const templateSaved = page.waitForResponse(candidate => candidate.request().method() === 'POST' && candidate.url().includes('/templates?edit=1') && candidate.ok())
|
||||
await page.getByRole('button', { name: 'ADMIN', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'SAVE AS TEMPLATE', exact: true }).click()
|
||||
await templateSaved
|
||||
await expect(page.locator('.terminal-status')).toContainText('VERSION 1')
|
||||
|
||||
let promptIndex = 0
|
||||
const prompts = ['browser-template', 'Browser Template Clone']
|
||||
const promptHandler = (dialog: { accept(promptText?: string): Promise<void> }) => dialog.accept(prompts[promptIndex++])
|
||||
page.on('dialog', promptHandler)
|
||||
await page.getByRole('button', { name: 'ADMIN', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'NEW FROM TEMPLATE', exact: true }).click()
|
||||
await page.waitForURL(url => url.searchParams.get('level')?.startsWith('browser-template-') === true)
|
||||
page.off('dialog', promptHandler)
|
||||
await expect(page.getByRole('heading', { name: 'Browser Template Clone' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Reset' })).toBeEnabled()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const portraitToolbar = await page.locator('.board-actions').boundingBox()
|
||||
if (!portraitToolbar) throw new Error('Portrait toolbar is not visible')
|
||||
expect(portraitToolbar.height).toBeGreaterThan(portraitToolbar.width * 2)
|
||||
expect(portraitToolbar.x).toBeGreaterThan(280)
|
||||
const portraitMove = await page.getByRole('button', { name: 'MOVE', exact: true }).boundingBox()
|
||||
const portraitParty = await page.getByRole('button', { name: 'NEW PARTY', exact: true }).boundingBox()
|
||||
expect(portraitMove).not.toBeNull()
|
||||
expect(portraitParty).not.toBeNull()
|
||||
expect(portraitParty!.y).toBeGreaterThan(portraitMove!.y)
|
||||
|
||||
await page.setViewportSize({ width: 844, height: 390 })
|
||||
const landscapeToolbar = await page.locator('.board-actions').boundingBox()
|
||||
if (!landscapeToolbar) throw new Error('Landscape toolbar is not visible')
|
||||
expect(landscapeToolbar.width).toBeGreaterThan(landscapeToolbar.height * 2)
|
||||
})
|
||||
|
||||
test('hides the admin menu without a verified admin JWT', async ({ browser }) => {
|
||||
const context = await browser.newContext({ baseURL: 'http://127.0.0.1:18788', viewport: { width: 1280, height: 720 }, extraHTTPHeaders: { Cookie: 'anonymous_session=1' } })
|
||||
const page = await context.newPage()
|
||||
await page.goto('/?level=e2e-level&edit=1')
|
||||
await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'ADMIN', exact: true })).toHaveCount(0)
|
||||
expect((await page.locator('.menubar nav > button').allTextContents()).map(label => label.replace(/\d+$/, ''))).toEqual(['EVIDENCE', 'CASE BRIEF', 'TIMELINE', 'HELP'])
|
||||
await context.close()
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
async function dragBy(page: Page, locator: ReturnType<Page['locator']>, deltaX: number, deltaY: number) {
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error('Drag target is not visible')
|
||||
const start = { x: box.x + box.width / 2, y: box.y + Math.min(18, box.height / 4) }
|
||||
await page.mouse.move(start.x, start.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(start.x + deltaX, start.y + deltaY, { steps: 8 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
async function waitForSave(page: Page, action: () => Promise<void>) {
|
||||
const response = page.waitForResponse(candidate => candidate.request().method() === 'PUT' && candidate.url().includes('/api/levels/') && candidate.ok())
|
||||
await action()
|
||||
await response
|
||||
}
|
||||
|
||||
async function classify(page: Page, name: string, kind: 'PERSON' | 'ORGANIZATION', summary: string) {
|
||||
if (!await page.locator('.brief-panel').isVisible()) await page.getByRole('button', { name: 'Case brief', exact: true }).click()
|
||||
const concept = page.locator('.brief-concepts section').filter({ hasText: name })
|
||||
await waitForSave(page, () => concept.getByRole('button', { name: kind, exact: true }).click())
|
||||
await expect(page.locator('.brief-panel')).toBeVisible()
|
||||
await expect(concept).toHaveClass(/\bjust-resolved\b/)
|
||||
await concept.getByRole('button', { name: 'EDIT DOSSIER', exact: true }).click()
|
||||
await page.getByLabel('Party summary').fill(summary)
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE DOSSIER', exact: true }).click())
|
||||
}
|
||||
|
||||
async function createEvent(page: Page, title: string, narrative: string, occurredAt: string, sources: string[]) {
|
||||
await page.getByRole('button', { name: 'NEW EVENT', exact: true }).click()
|
||||
await page.getByLabel('Event title').fill(title)
|
||||
await page.getByLabel('Event narrative').fill(narrative)
|
||||
await page.getByLabel('Occurred at').fill(occurredAt)
|
||||
for (const source of sources) {
|
||||
await page.locator('.event-support-list .folder-member').filter({ hasText: source }).locator('input[type="checkbox"]').check()
|
||||
}
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE EVENT', exact: true }).click())
|
||||
}
|
||||
|
||||
test('a cloned Glass Harbor level can be solved without modifying its template', async ({ page, request }) => {
|
||||
test.setTimeout(90_000)
|
||||
await expect.poll(async () => {
|
||||
const seededLevels = await (await request.get('/api/levels')).json() as { id: string }[]
|
||||
return seededLevels.some(level => level.id.startsWith('glass-harbor-case-'))
|
||||
}).toBe(true)
|
||||
const levels = await (await request.get('/api/levels')).json() as { id: string }[]
|
||||
const playable = levels.find(level => level.id.startsWith('glass-harbor-case-'))
|
||||
if (!playable) throw new Error('Glass Harbor playable clone was not seeded')
|
||||
await page.goto(`/?level=${playable.id}`)
|
||||
await expect(page.getByRole('heading', { name: 'The Glass Harbor Diversion' })).toBeVisible()
|
||||
await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/)
|
||||
await page.locator('.open-files').click()
|
||||
await page.getByRole('searchbox', { name: 'Search inside documents', exact: true }).fill('Elias')
|
||||
await expect(page.locator('.doc-row')).toHaveCount(1)
|
||||
await expect(page.locator('.doc-row')).toContainText('Carrier Dispatch Manifest · H&F 14')
|
||||
await page.getByRole('button', { name: 'Clear document search', exact: true }).click()
|
||||
await expect(page.locator('.doc-row')).toHaveCount(8)
|
||||
await page.getByRole('button', { name: 'Close documents', exact: true }).click()
|
||||
await expect(page.locator('.evidence-card.folder')).toHaveCount(3)
|
||||
await expect(page.locator('.timeline .marker')).toHaveCount(8)
|
||||
await expect(page.locator('.timeline-label button')).toHaveText('1987-10-01 — 1987-10-31')
|
||||
const dispatchMarker = await page.getByRole('button', { name: '1987-10-16 — Carrier Dispatch Manifest · H&F 14', exact: true }).boundingBox()
|
||||
const auctionMarker = await page.getByRole('button', { name: '1987-10-24 — Meridian Maritime Auction · Lot 117', exact: true }).boundingBox()
|
||||
expect(dispatchMarker).not.toBeNull()
|
||||
expect(auctionMarker).not.toBeNull()
|
||||
expect(auctionMarker!.x - dispatchMarker!.x).toBeGreaterThan(100)
|
||||
|
||||
await page.getByRole('button', { name: 'TIMELINE', exact: true }).click()
|
||||
await page.getByLabel('Timeline start date').fill('1987-10-10')
|
||||
await page.getByLabel('Timeline end date').fill('1987-10-26')
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'APPLY RANGE', exact: true }).click())
|
||||
await expect(page.locator('.timeline-label button')).toHaveText('1987-10-10 — 1987-10-26')
|
||||
|
||||
await classify(page, 'Mara Voss', 'PERSON', 'Restoration project officer who sponsored the Warehouse 3 access pass.')
|
||||
await classify(page, 'Elias Vale', 'PERSON', 'Driver of H&F 14; transported CO-771 to Warehouse 3.')
|
||||
await classify(page, 'Greyhaven Preservation Society', 'ORGANIZATION', 'Commissioned the North Quay restoration and owned the missing lens.')
|
||||
await classify(page, 'Calder Optical Works', 'ORGANIZATION', 'Supplier that released sealed crate CO-771 to the approved carrier.')
|
||||
await classify(page, 'Harbor & Fell Logistics', 'ORGANIZATION', 'Approved carrier whose vehicle delivered the crate to the wrong address.')
|
||||
await classify(page, 'Voss Antiquities Ltd', 'ORGANIZATION', 'Mara Voss’s company, based at Warehouse 3 and named as auction consignor.')
|
||||
await page.getByRole('button', { name: 'RETURN TO BOARD', exact: true }).click()
|
||||
|
||||
await createEvent(page, 'CO-771 left Calder for North Quay', 'Calder released the sealed lens to Elias Vale in H&F 14 with no alternate delivery authority.', '1987-10-16T16:40', ['Carrier Dispatch Manifest'])
|
||||
await createEvent(page, 'CO-771 was diverted to Warehouse 3', 'Mara Voss sponsored access; H&F 14 entered loaded and left empty. The next-day memorandum was retrospective.', '1987-10-17T22:08', ['Old Glass Harbor Gate Ledger', 'Warehouse 3 Security Photograph', 'Delivery Redirection Memorandum'])
|
||||
await createEvent(page, 'Voss Antiquities offered the lens for sale', 'The auction description and seller reference identify the missing assembly and the company that stood to profit.', '1987-10-24T12:00', ['Company Register Extract', 'Meridian Maritime Auction'])
|
||||
|
||||
page.once('dialog', dialog => dialog.accept('The redirection memorandum was written after the crate had already moved.'))
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'NEW NOTE', exact: true }).click())
|
||||
|
||||
const elias = page.locator('.evidence-card.party').filter({ has: page.getByRole('heading', { name: 'Elias Vale', exact: true }) })
|
||||
const movement = page.locator('.evidence-card.folder').filter({ hasText: 'MOVEMENT RECORDS' })
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'Zoom out', exact: true }).click())
|
||||
await waitForSave(page, () => movement.getByRole('button', { name: 'OPEN', exact: true }).click())
|
||||
const manifest = page.locator('.source-file-widget.open').filter({ hasText: 'Carrier Dispatch Manifest' })
|
||||
await expect(manifest.locator('.text-source-excerpt')).toContainText('HARBOR & FELL LOGISTICS')
|
||||
await expect(manifest.locator('.source-file-preview svg')).toHaveCount(0)
|
||||
const manifestCue = page.getByRole('textbox', { name: 'Memory cue for Carrier Dispatch Manifest · H&F 14', exact: true })
|
||||
await waitForSave(page, () => manifestCue.fill('ELIAS · H&F 14'))
|
||||
await elias.click()
|
||||
await page.getByRole('button', { name: 'Red thread', exact: true }).click()
|
||||
const boardBox = await page.locator('.board-viewport').boundingBox()
|
||||
if (!boardBox) throw new Error('Board is not visible')
|
||||
await page.mouse.move(boardBox.x + boardBox.width / 2, boardBox.y + boardBox.height / 2)
|
||||
await expect(page.locator('.connections .thread-preview path')).toBeVisible()
|
||||
const manifestBox = await manifest.boundingBox()
|
||||
if (!manifestBox) throw new Error('Carrier Dispatch Manifest is not visible')
|
||||
await page.mouse.move(manifestBox.x + manifestBox.width / 2, manifestBox.y + manifestBox.height / 2)
|
||||
await manifest.click()
|
||||
const threadEditor = page.locator('.thread-editor')
|
||||
await expect(threadEditor.getByText('Elias Vale', { exact: true })).toBeVisible()
|
||||
await expect(threadEditor.getByText('Carrier Dispatch Manifest · H&F 14', { exact: true })).toBeVisible()
|
||||
await page.getByLabel('Thread tag').fill('Proof Elias is the driver')
|
||||
await page.getByRole('slider', { name: 'Thread tightness', exact: true }).fill('85')
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click())
|
||||
await expect(page.locator('.connections g.tightening path')).toBeVisible()
|
||||
|
||||
const procurement = page.locator('.evidence-card.folder').filter({ hasText: 'PROCUREMENT & OWNERSHIP' })
|
||||
await waitForSave(page, () => procurement.getByRole('button', { name: 'OPEN', exact: true }).click())
|
||||
const companyRegister = page.locator('.source-file-widget.open').filter({ hasText: 'Company Register Extract' })
|
||||
const memorandum = page.locator('.source-file-widget.open').filter({ hasText: 'Delivery Redirection Memorandum' })
|
||||
const photograph = page.locator('.source-file-widget.open').filter({ hasText: 'Warehouse 3 Security Photograph' })
|
||||
await companyRegister.click()
|
||||
await expect(companyRegister).toHaveClass(/\bselected\b/)
|
||||
await page.getByRole('button', { name: 'Red thread', exact: true }).click()
|
||||
await expect(companyRegister).toHaveClass(/\blinking\b/)
|
||||
await memorandum.click()
|
||||
await expect(threadEditor.getByText('Company Register Extract · Voss Antiquities Ltd', { exact: true })).toBeVisible()
|
||||
await expect(threadEditor.getByText('Delivery Redirection Memorandum', { exact: true })).toBeVisible()
|
||||
await page.getByLabel('Thread tag').fill('Proves Voss owns Warehouse 3')
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click())
|
||||
await page.getByRole('button', { name: 'Red thread', exact: true }).click()
|
||||
await photograph.click()
|
||||
await expect(threadEditor.getByText('Company Register Extract · Voss Antiquities Ltd', { exact: true })).toBeVisible()
|
||||
await expect(threadEditor.getByText('Warehouse 3 Security Photograph', { exact: true })).toBeVisible()
|
||||
await page.getByLabel('Thread tag').fill('Proves Voss owns Warehouse 3')
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click())
|
||||
|
||||
await page.reload()
|
||||
await expect(page.locator('.evidence-card.party')).toHaveCount(6)
|
||||
await expect(page.locator('.evidence-card.event')).toHaveCount(3)
|
||||
await expect(page.locator('.evidence-card.note')).toHaveCount(1)
|
||||
await expect(page.getByRole('textbox', { name: 'Memory cue for Carrier Dispatch Manifest · H&F 14', exact: true })).toHaveValue('ELIAS · H&F 14')
|
||||
await expect(page.locator('.story-strip')).toContainText('CO-771 was diverted to Warehouse 3')
|
||||
await expect(page.locator('.event-support-lines line')).toHaveCount(6)
|
||||
await expect(page.locator('.connections path')).toHaveCount(3)
|
||||
const relationTag = page.locator('.thread-tag').filter({ hasText: 'Proof Elias is the driver' })
|
||||
await expect(relationTag).toContainText('Proof Elias is the driver')
|
||||
const tagBefore = await relationTag.boundingBox()
|
||||
await waitForSave(page, () => dragBy(page, relationTag, 75, -18))
|
||||
const tagAfter = await relationTag.boundingBox()
|
||||
expect(tagBefore).not.toBeNull()
|
||||
expect(tagAfter).not.toBeNull()
|
||||
expect(Math.abs(tagAfter!.x - tagBefore!.x) + Math.abs(tagAfter!.y - tagBefore!.y)).toBeGreaterThan(20)
|
||||
await relationTag.click()
|
||||
await expect(relationTag).toHaveClass(/\bexpanded\b/)
|
||||
await expect(relationTag).toHaveAttribute('aria-expanded', 'true')
|
||||
await relationTag.click()
|
||||
await expect(page.getByText('Edit red thread')).toBeVisible()
|
||||
expect(await page.getByRole('slider', { name: 'Tag position', exact: true }).inputValue()).not.toBe('50')
|
||||
await page.locator('.tag-style-picker input[value="compact"]').check()
|
||||
await waitForSave(page, () => page.getByRole('button', { name: 'SAVE THREAD', exact: true }).click())
|
||||
await expect(relationTag).toHaveClass(/\bcompact\b/)
|
||||
await expect(relationTag).not.toHaveClass(/\bluggage-tag\b/)
|
||||
await relationTag.click()
|
||||
await expect(page.getByText('Edit red thread')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Close thread editor', exact: true }).click()
|
||||
|
||||
const solved = await (await request.get(`/api/levels/${playable.id}`)).json()
|
||||
const eliasThread = solved.connections.find((connection: { label?: string }) => connection.label === 'Proof Elias is the driver')
|
||||
expect(eliasThread).toEqual(expect.objectContaining({ tightness: 85, tagStyle: 'compact', tagPosition: expect.any(Number), tagOffset: expect.any(Number) }))
|
||||
expect(eliasThread.tagPosition).not.toBe(50)
|
||||
expect(Math.abs(eliasThread.tagOffset)).toBeLessThanOrEqual(19)
|
||||
expect(solved.connections.filter((connection: { label?: string }) => connection.label === 'Proves Voss owns Warehouse 3')).toHaveLength(2)
|
||||
|
||||
const template = await (await request.get('/api/templates')).json() as { slug: string; currentVersion: number }[]
|
||||
expect(template).toContainEqual(expect.objectContaining({ slug: 'glass-harbor', currentVersion: 1 }))
|
||||
const authoringLevels = levels.filter(level => level.id.startsWith('glass-harbor-authoring-'))
|
||||
expect(authoringLevels).toHaveLength(1)
|
||||
const untouched = await (await request.get(`/api/levels/${authoringLevels[0].id}?edit=1`)).json()
|
||||
expect(untouched.evidence.filter((item: { type: string }) => item.type === 'party')).toHaveLength(0)
|
||||
expect(untouched.evidence.filter((item: { type: string }) => item.type === 'event')).toHaveLength(0)
|
||||
expect(untouched.brief.concepts.every((concept: { resolvedPartyExhibitId?: string }) => !concept.resolvedPartyExhibitId)).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,261 @@
|
||||
-- Canonical exhibit model. The POC data predating this migration is disposable:
|
||||
-- there is deliberately no compatibility view, backfill, or playthrough overlay.
|
||||
DROP TABLE IF EXISTS osint.playthrough_widget_relation_state CASCADE;
|
||||
DROP TABLE IF EXISTS osint.playthrough_widget_relations CASCADE;
|
||||
DROP TABLE IF EXISTS osint.playthrough_widget_state CASCADE;
|
||||
DROP TABLE IF EXISTS osint.playthrough_connections CASCADE;
|
||||
DROP TABLE IF EXISTS osint.playthrough_widgets CASCADE;
|
||||
DROP TABLE IF EXISTS osint.playthroughs CASCADE;
|
||||
DROP TABLE IF EXISTS osint.level_connections CASCADE;
|
||||
DROP TABLE IF EXISTS osint.widget_regions CASCADE;
|
||||
DROP TABLE IF EXISTS osint.widget_relations CASCADE;
|
||||
DROP TABLE IF EXISTS osint.widgets CASCADE;
|
||||
DROP TABLE IF EXISTS osint.assets CASCADE;
|
||||
DROP TABLE IF EXISTS osint.levels CASCADE;
|
||||
DROP TABLE IF EXISTS osint.cases CASCADE;
|
||||
|
||||
CREATE TABLE osint.boards (
|
||||
id UUID PRIMARY KEY,
|
||||
board_kind TEXT NOT NULL CHECK (board_kind IN ('level', 'template_version')),
|
||||
revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE osint.assets (
|
||||
id UUID PRIMARY KEY,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
byte_size BIGINT NOT NULL CHECK (byte_size >= 0),
|
||||
content BYTEA NOT NULL,
|
||||
checksum_sha256 TEXT NOT NULL CHECK (checksum_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (checksum_sha256, byte_size)
|
||||
);
|
||||
|
||||
CREATE TABLE osint.level_templates (
|
||||
id UUID PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
current_version_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE osint.level_template_versions (
|
||||
id UUID PRIMARY KEY,
|
||||
template_id UUID NOT NULL REFERENCES osint.level_templates(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL CHECK (version > 0),
|
||||
board_id UUID NOT NULL UNIQUE REFERENCES osint.boards(id) ON DELETE RESTRICT,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT NOT NULL DEFAULT '',
|
||||
created_from_level_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (template_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE osint.levels (
|
||||
id UUID PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
board_id UUID NOT NULL UNIQUE REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||
source_template_version_id UUID REFERENCES osint.level_template_versions(id) ON DELETE RESTRICT,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'complete', 'archived')),
|
||||
viewport_x DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
viewport_y DOUBLE PRECISION NOT NULL DEFAULT 28,
|
||||
viewport_zoom DOUBLE PRECISION NOT NULL DEFAULT 0.7 CHECK (viewport_zoom > 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE osint.level_templates
|
||||
ADD CONSTRAINT level_templates_current_version_fk
|
||||
FOREIGN KEY (current_version_id) REFERENCES osint.level_template_versions(id) ON DELETE SET NULL;
|
||||
ALTER TABLE osint.level_template_versions
|
||||
ADD CONSTRAINT level_template_versions_source_level_fk
|
||||
FOREIGN KEY (created_from_level_id) REFERENCES osint.levels(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE osint.exhibit_types (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
is_spatial BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
INSERT INTO osint.exhibit_types (id, name) VALUES
|
||||
('folder', 'Folder'), ('document', 'Document'), ('note', 'Note'), ('event', 'Event');
|
||||
|
||||
CREATE TABLE osint.exhibits (
|
||||
id UUID PRIMARY KEY,
|
||||
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||
exhibit_type_id TEXT NOT NULL REFERENCES osint.exhibit_types(id),
|
||||
origin_exhibit_id UUID REFERENCES osint.exhibits(id) ON DELETE SET NULL,
|
||||
xpos DOUBLE PRECISION NOT NULL DEFAULT 100,
|
||||
ypos DOUBLE PRECISION NOT NULL DEFAULT 100,
|
||||
width DOUBLE PRECISION NOT NULL DEFAULT 240 CHECK (width > 0),
|
||||
height DOUBLE PRECISION NOT NULL DEFAULT 160 CHECK (height > 0),
|
||||
rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
z_index INTEGER NOT NULL DEFAULT 0,
|
||||
hidden BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (board_id, id)
|
||||
);
|
||||
CREATE INDEX exhibits_board_idx ON osint.exhibits (board_id, z_index, created_at);
|
||||
|
||||
CREATE TABLE osint.folder_exhibits (
|
||||
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
label_text TEXT NOT NULL DEFAULT '',
|
||||
is_open BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE osint.document_types (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
INSERT INTO osint.document_types (id, name) VALUES
|
||||
('image', 'Image'), ('pdf', 'PDF'), ('web_capture', 'Web capture'),
|
||||
('email', 'Email'), ('article', 'Article'), ('filing', 'Filing'),
|
||||
('price_list', 'Price list'), ('text', 'Text'), ('file', 'Generic file');
|
||||
|
||||
CREATE TABLE osint.document_exhibits (
|
||||
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
document_type_id TEXT NOT NULL REFERENCES osint.document_types(id),
|
||||
asset_id UUID REFERENCES osint.assets(id) ON DELETE RESTRICT,
|
||||
title TEXT NOT NULL,
|
||||
published_at TIMESTAMPTZ,
|
||||
captured_at TIMESTAMPTZ,
|
||||
source_uri TEXT
|
||||
);
|
||||
CREATE INDEX document_exhibits_published_idx ON osint.document_exhibits (published_at);
|
||||
|
||||
CREATE TABLE osint.image_documents (
|
||||
exhibit_id UUID PRIMARY KEY REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||
pixel_width INTEGER CHECK (pixel_width > 0),
|
||||
pixel_height INTEGER CHECK (pixel_height > 0),
|
||||
alt_text TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE osint.note_exhibits (
|
||||
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
note_text TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE osint.event_exhibits (
|
||||
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
narrative_text TEXT NOT NULL DEFAULT '',
|
||||
occurred_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE osint.document_content_blocks (
|
||||
id UUID PRIMARY KEY,
|
||||
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL CHECK (sort_order >= 0),
|
||||
content TEXT NOT NULL,
|
||||
UNIQUE (document_exhibit_id, sort_order)
|
||||
);
|
||||
|
||||
CREATE TABLE osint.document_regions (
|
||||
id UUID PRIMARY KEY,
|
||||
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||
region_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
excerpt TEXT NOT NULL,
|
||||
occurred_at TIMESTAMPTZ,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||
UNIQUE (document_exhibit_id, region_key)
|
||||
);
|
||||
|
||||
CREATE TABLE osint.folder_memberships (
|
||||
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||
folder_exhibit_id UUID NOT NULL REFERENCES osint.folder_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||
child_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||
PRIMARY KEY (folder_exhibit_id, child_exhibit_id),
|
||||
UNIQUE (board_id, child_exhibit_id),
|
||||
CHECK (folder_exhibit_id <> child_exhibit_id)
|
||||
);
|
||||
|
||||
CREATE TABLE osint.event_evidence (
|
||||
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||
event_exhibit_id UUID NOT NULL REFERENCES osint.event_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||
evidence_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||
note TEXT,
|
||||
PRIMARY KEY (event_exhibit_id, evidence_exhibit_id),
|
||||
CHECK (event_exhibit_id <> evidence_exhibit_id)
|
||||
);
|
||||
|
||||
CREATE TABLE osint.connection_types (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
directed BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
INSERT INTO osint.connection_types (id, name) VALUES ('thread', 'Red thread');
|
||||
|
||||
CREATE TABLE osint.exhibit_connections (
|
||||
id UUID PRIMARY KEY,
|
||||
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||
connection_type_id TEXT NOT NULL REFERENCES osint.connection_types(id),
|
||||
from_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
to_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
label TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CHECK (from_exhibit_id <> to_exhibit_id)
|
||||
);
|
||||
|
||||
CREATE TABLE osint.exhibit_sources (
|
||||
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
source_document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||
source_region_id UUID REFERENCES osint.document_regions(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE osint.metadata_fields (
|
||||
id UUID PRIMARY KEY,
|
||||
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||
field_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
value_type TEXT NOT NULL CHECK (value_type IN ('text', 'timestamp', 'number', 'boolean')),
|
||||
UNIQUE (board_id, field_key)
|
||||
);
|
||||
CREATE TABLE osint.exhibit_metadata_text_values (
|
||||
exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
field_id UUID NOT NULL REFERENCES osint.metadata_fields(id) ON DELETE CASCADE,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (exhibit_id, field_id)
|
||||
);
|
||||
CREATE TABLE osint.exhibit_metadata_timestamp_values (
|
||||
exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
field_id UUID NOT NULL REFERENCES osint.metadata_fields(id) ON DELETE CASCADE,
|
||||
value TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (exhibit_id, field_id)
|
||||
);
|
||||
CREATE TABLE osint.exhibit_metadata_number_values (
|
||||
exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
field_id UUID NOT NULL REFERENCES osint.metadata_fields(id) ON DELETE CASCADE,
|
||||
value NUMERIC NOT NULL,
|
||||
PRIMARY KEY (exhibit_id, field_id)
|
||||
);
|
||||
CREATE TABLE osint.exhibit_metadata_boolean_values (
|
||||
exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
field_id UUID NOT NULL REFERENCES osint.metadata_fields(id) ON DELETE CASCADE,
|
||||
value BOOLEAN NOT NULL,
|
||||
PRIMARY KEY (exhibit_id, field_id)
|
||||
);
|
||||
|
||||
-- Relationships carry board_id so cross-board references can be rejected by FKs.
|
||||
ALTER TABLE osint.folder_memberships
|
||||
ADD CONSTRAINT folder_membership_folder_board_fk FOREIGN KEY (board_id, folder_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT folder_membership_child_board_fk FOREIGN KEY (board_id, child_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE;
|
||||
ALTER TABLE osint.event_evidence
|
||||
ADD CONSTRAINT event_evidence_event_board_fk FOREIGN KEY (board_id, event_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT event_evidence_evidence_board_fk FOREIGN KEY (board_id, evidence_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE;
|
||||
ALTER TABLE osint.exhibit_connections
|
||||
ADD CONSTRAINT exhibit_connections_from_board_fk FOREIGN KEY (board_id, from_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE,
|
||||
ADD CONSTRAINT exhibit_connections_to_board_fk FOREIGN KEY (board_id, to_exhibit_id) REFERENCES osint.exhibits(board_id, id) ON DELETE CASCADE;
|
||||
|
||||
COMMENT ON TABLE osint.exhibits IS 'Canonical domain objects; frontend widgets are projections selected by exhibit_type_id.';
|
||||
COMMENT ON TABLE osint.level_template_versions IS 'Immutable template snapshots. Application code must clone, never update, their boards.';
|
||||
COMMENT ON TABLE osint.assets IS 'Immutable shared binary content referenced by document exhibits.';
|
||||
@@ -0,0 +1,83 @@
|
||||
INSERT INTO osint.exhibit_types (id,name) VALUES ('party','Party');
|
||||
|
||||
CREATE TABLE osint.party_exhibits (
|
||||
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
party_kind TEXT NOT NULL CHECK (party_kind IN ('person','organization')),
|
||||
display_name TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE osint.person_parties (
|
||||
exhibit_id UUID PRIMARY KEY REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||
given_name TEXT,
|
||||
family_name TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE osint.organization_parties (
|
||||
exhibit_id UUID PRIMARY KEY REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||
organization_kind TEXT NOT NULL DEFAULT 'business'
|
||||
CHECK (organization_kind IN ('business','public_body','association','informal_group','other'))
|
||||
);
|
||||
|
||||
CREATE TABLE osint.party_aliases (
|
||||
id UUID PRIMARY KEY,
|
||||
party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||
alias TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||
UNIQUE (party_exhibit_id,alias)
|
||||
);
|
||||
|
||||
CREATE TABLE osint.party_evidence (
|
||||
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||
party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||
evidence_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||
note TEXT,
|
||||
PRIMARY KEY (party_exhibit_id,evidence_exhibit_id),
|
||||
CHECK (party_exhibit_id <> evidence_exhibit_id),
|
||||
FOREIGN KEY (board_id,party_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (board_id,evidence_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE osint.party_relationship_types (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
directed BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
INSERT INTO osint.party_relationship_types (id,name,directed) VALUES
|
||||
('employment','Employment',TRUE), ('ownership','Ownership',TRUE),
|
||||
('membership','Membership',TRUE), ('control','Control',TRUE),
|
||||
('representation','Representation',TRUE), ('associated','Associated',FALSE);
|
||||
|
||||
CREATE TABLE osint.party_relationships (
|
||||
id UUID PRIMARY KEY,
|
||||
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||
relationship_type_id TEXT NOT NULL REFERENCES osint.party_relationship_types(id),
|
||||
from_party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||
to_party_exhibit_id UUID NOT NULL REFERENCES osint.party_exhibits(exhibit_id) ON DELETE CASCADE,
|
||||
note TEXT,
|
||||
CHECK (from_party_exhibit_id <> to_party_exhibit_id),
|
||||
FOREIGN KEY (board_id,from_party_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (board_id,to_party_exhibit_id) REFERENCES osint.exhibits(board_id,id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE osint.level_briefs (
|
||||
board_id UUID PRIMARY KEY REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE osint.brief_concepts (
|
||||
id UUID PRIMARY KEY,
|
||||
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||
origin_concept_id UUID REFERENCES osint.brief_concepts(id) ON DELETE SET NULL,
|
||||
label TEXT NOT NULL,
|
||||
context_text TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||
expected_party_kind TEXT CHECK (expected_party_kind IN ('person','organization')),
|
||||
resolved_party_exhibit_id UUID REFERENCES osint.party_exhibits(exhibit_id) ON DELETE SET NULL,
|
||||
UNIQUE (board_id,label),
|
||||
FOREIGN KEY (board_id,resolved_party_exhibit_id) REFERENCES osint.exhibits(board_id,id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE osint.brief_concepts IS 'Named concepts in the level brief which may be classified into Party exhibits by the investigator.';
|
||||
COMMENT ON COLUMN osint.brief_concepts.expected_party_kind IS 'Author-only expected classification; omitted from play-mode API projections.';
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE osint.board_timeline_settings (
|
||||
board_id UUID PRIMARY KEY REFERENCES osint.boards(id) ON DELETE CASCADE,
|
||||
range_start DATE NOT NULL,
|
||||
range_end DATE NOT NULL,
|
||||
CHECK (range_end > range_start)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE osint.board_timeline_settings IS 'Optional authored temporal viewport. Absence means derive the timeline range from dated exhibits.';
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE osint.exhibit_connections
|
||||
ADD COLUMN tightness SMALLINT NOT NULL DEFAULT 65
|
||||
CHECK (tightness BETWEEN 0 AND 100);
|
||||
|
||||
COMMENT ON COLUMN osint.exhibit_connections.label IS 'Investigator-authored tag describing the meaning of this thread';
|
||||
COMMENT ON COLUMN osint.exhibit_connections.tightness IS 'Visual thread tautness percentage from 0 (slack) to 100 (taut)';
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE osint.exhibit_connections
|
||||
ADD COLUMN tag_style TEXT NOT NULL DEFAULT 'luggage'
|
||||
CHECK (tag_style IN ('luggage', 'compact'));
|
||||
|
||||
COMMENT ON COLUMN osint.exhibit_connections.tag_style IS 'Player-selected relation label presentation while both POC treatments are evaluated';
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE osint.exhibit_connections
|
||||
ADD COLUMN tag_position_percent SMALLINT NOT NULL DEFAULT 50
|
||||
CHECK (tag_position_percent BETWEEN 0 AND 100),
|
||||
ADD COLUMN tag_lateral_offset SMALLINT NOT NULL DEFAULT 0
|
||||
CHECK (ABS(tag_lateral_offset) <= 10 + ROUND((100 - tightness) * 0.6));
|
||||
|
||||
COMMENT ON COLUMN osint.exhibit_connections.tag_position_percent IS 'Position of the relation tag along its red thread, measured from the source exhibit';
|
||||
COMMENT ON COLUMN osint.exhibit_connections.tag_lateral_offset IS 'Signed perpendicular tag displacement, limited by thread tightness';
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE osint.event_exhibits
|
||||
ALTER COLUMN occurred_at DROP NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN osint.event_exhibits.occurred_at IS 'Optional reconstructed occurrence time; never inferred from exhibit creation time';
|
||||
@@ -0,0 +1,17 @@
|
||||
# Mystery content packages
|
||||
|
||||
Mysteries live outside the React application as authoring manifests and immutable source assets. Importing a manifest exercises the same public operations as the level editor: it creates a mutable authoring level, uploads assets, saves normalized exhibits, freezes an immutable template version, and instantiates a separate playable level.
|
||||
|
||||
With the local Docker stack running and editing enabled:
|
||||
|
||||
```bash
|
||||
npm run mystery:import -- mysteries/glass-harbor/mystery.json
|
||||
```
|
||||
|
||||
The command prints the template version, authoring-level ID, playable-level ID, and player URL. Importing the same manifest again creates a new immutable version; it does not rewrite an earlier version.
|
||||
|
||||
## The Glass Harbor Diversion
|
||||
|
||||
This compact first mystery contains eight dated source documents in three folders, six unresolved Party concepts, and one fictional archival photograph. It intentionally starts without Party or Event exhibits: classification, dossier association, event reconstruction, working notes, and red-thread theory are the player’s work.
|
||||
|
||||
The source image was generated with the built-in image-generation tool for this fictional case. Its final production prompt asked for a degraded 1987 harbor-security photograph of truck `H&F 14` unloading a `CALDER OPTICAL / FRAGILE` crate at Warehouse 3, with period-correct details and no real people or brands.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"slug": "glass-harbor",
|
||||
"name": "The Glass Harbor Diversion",
|
||||
"title": "The Glass Harbor Diversion",
|
||||
"subtitle": "Greyhaven file 87-10 · missing lighthouse optics",
|
||||
"timelineRange": { "start": "1987-10-01", "end": "1987-10-31" },
|
||||
"brief": {
|
||||
"body": "A replacement Fresnel lens purchased for North Quay Lighthouse vanished between dispatch and installation. Determine who arranged the diversion, which organization stood to benefit, and when the shipment changed course. Classify every named concept, associate each party with the evidence that supports its dossier, reconstruct the decisive events, and make your conclusion visible with red thread. The terminal will not announce a winner: a solved board is a defensible account of what happened.",
|
||||
"concepts": [
|
||||
{ "label": "Mara Voss", "context": "Named in restoration correspondence and harbor records.", "expectedPartyKind": "person" },
|
||||
{ "label": "Elias Vale", "context": "Named on the carrier dispatch manifest.", "expectedPartyKind": "person" },
|
||||
{ "label": "Greyhaven Preservation Society", "context": "Commissioned the North Quay restoration.", "expectedPartyKind": "organization" },
|
||||
{ "label": "Calder Optical Works", "context": "Supplied the missing Fresnel assembly.", "expectedPartyKind": "organization" },
|
||||
{ "label": "Harbor & Fell Logistics", "context": "Transported the consignment from Calder.", "expectedPartyKind": "organization" },
|
||||
{ "label": "Voss Antiquities Ltd", "context": "Appears in company and auction records.", "expectedPartyKind": "organization" }
|
||||
]
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"key": "company-register",
|
||||
"title": "Company Register Extract · Voss Antiquities Ltd",
|
||||
"fileType": "filing",
|
||||
"publishedAt": "1987-01-08T10:00:00.000Z",
|
||||
"body": [
|
||||
"GREYHAVEN COMPANIES REGISTRY — CERTIFIED EXTRACT 87/0118",
|
||||
"VOSS ANTIQUITIES LTD · dealer in architectural and maritime salvage.",
|
||||
"Sole director and beneficial owner: Mara Elise Voss.",
|
||||
"Registered trading premises: Warehouse 3, Old Glass Harbor, Greyhaven.",
|
||||
"Company status: active. Last annual return filed 8 January 1987."
|
||||
],
|
||||
"metadata": { "registry_number": "GH-44109", "certified_by": "Greyhaven Companies Registry" }
|
||||
},
|
||||
{
|
||||
"key": "contract-award",
|
||||
"title": "North Quay Restoration · Contract Award",
|
||||
"fileType": "text",
|
||||
"publishedAt": "1987-10-02T14:00:00.000Z",
|
||||
"body": [
|
||||
"GREYHAVEN PRESERVATION SOCIETY — CONTRACT 31/NQ",
|
||||
"Calder Optical Works shall supply one restored third-order Fresnel assembly, serial CO-771, for permanent installation at North Quay Lighthouse.",
|
||||
"Harbor & Fell Logistics is the approved carrier. Delivery is to the North Quay works compound no later than 20 October 1987.",
|
||||
"The Society's project officer, Mara Voss, may acknowledge delivery but may not alter the delivery address without a countersignature from the Society treasurer.",
|
||||
"Insured replacement value: 48,000 kroner."
|
||||
],
|
||||
"metadata": { "contract": "31/NQ", "serial": "CO-771" }
|
||||
},
|
||||
{
|
||||
"key": "dispatch-manifest",
|
||||
"title": "Carrier Dispatch Manifest · H&F 14",
|
||||
"fileType": "text",
|
||||
"publishedAt": "1987-10-16T16:40:00.000Z",
|
||||
"body": [
|
||||
"HARBOR & FELL LOGISTICS — OUTBOUND MANIFEST 16-440",
|
||||
"Vehicle: H&F 14. Driver: Elias Vale. Cargo: one sealed Calder Optical crate, serial CO-771.",
|
||||
"Collected from Calder Optical Works at 16:40 on 16 October 1987.",
|
||||
"Consignee and destination: Greyhaven Preservation Society, North Quay Lighthouse works compound.",
|
||||
"Special instructions: hold sealed; no alternate delivery authority lodged at time of dispatch."
|
||||
],
|
||||
"metadata": { "vehicle": "H&F 14", "driver": "Elias Vale", "serial": "CO-771" }
|
||||
},
|
||||
{
|
||||
"key": "gate-ledger",
|
||||
"title": "Old Glass Harbor Gate Ledger · Page 214",
|
||||
"fileType": "text",
|
||||
"publishedAt": "1987-10-17T22:08:00.000Z",
|
||||
"body": [
|
||||
"OLD GLASS HARBOR — NIGHT GATE LEDGER · 17 OCTOBER 1987",
|
||||
"22:08 — Vehicle H&F 14 admitted. Driver signed E. Vale.",
|
||||
"Declared load: Calder Optical crate CO-771. Destination inside harbor: Warehouse 3.",
|
||||
"Temporary access sponsor: M. Voss. Telephone authorization logged 21:54.",
|
||||
"22:31 — H&F 14 departed. Vehicle recorded empty."
|
||||
],
|
||||
"metadata": { "ledger_page": "214", "gate_officer": "T. Soren", "load_serial": "CO-771" }
|
||||
},
|
||||
{
|
||||
"key": "warehouse-photo",
|
||||
"title": "Warehouse 3 Security Photograph",
|
||||
"fileType": "image",
|
||||
"publishedAt": "1987-10-17T22:12:00.000Z",
|
||||
"asset": "assets/warehouse-3-security.png",
|
||||
"metadata": { "camera": "Old Glass Harbor C-3", "frame_time": "1987-10-17 22:12", "negative": "C3-871017-44" }
|
||||
},
|
||||
{
|
||||
"key": "redirect-memo",
|
||||
"title": "Delivery Redirection Memorandum",
|
||||
"fileType": "email",
|
||||
"publishedAt": "1987-10-18T09:15:00.000Z",
|
||||
"body": [
|
||||
"TO: Harbor & Fell Logistics / Calder Optical Works",
|
||||
"FROM: Mara Voss, North Quay project officer",
|
||||
"Owing to overnight water ingress at the lighthouse works compound, consignment CO-771 is to be held temporarily at Warehouse 3, Old Glass Harbor.",
|
||||
"The driver was advised of this change before departure from Calder Optical Works. Formal notice follows for your files.",
|
||||
"Signed M. Voss · 18 October 1987 · 09:15. No Society treasurer countersignature appears on this copy."
|
||||
],
|
||||
"metadata": { "received_stamp": "18 OCT 1987 09:15", "countersignature": "absent" }
|
||||
},
|
||||
{
|
||||
"key": "inventory-notice",
|
||||
"title": "North Quay Missing Inventory Notice",
|
||||
"fileType": "text",
|
||||
"publishedAt": "1987-10-21T08:30:00.000Z",
|
||||
"body": [
|
||||
"GREYHAVEN PRESERVATION SOCIETY — EXCEPTION NOTICE",
|
||||
"The Fresnel assembly CO-771 was not present when the North Quay installation team assembled on 20 October.",
|
||||
"Calder Optical Works confirms collection by the approved carrier. Harbor & Fell confirms completion of the driver's assigned shift but holds no signed North Quay receipt.",
|
||||
"Warehouse 3 was inspected at 08:00 on 21 October. No Calder crate or optical assembly was found.",
|
||||
"Project officer Mara Voss reported that she believed delivery remained pending."
|
||||
],
|
||||
"metadata": { "case_reference": "GPS/NQ/EX-4", "serial": "CO-771" }
|
||||
},
|
||||
{
|
||||
"key": "auction-catalogue",
|
||||
"title": "Meridian Maritime Auction · Lot 117",
|
||||
"fileType": "article",
|
||||
"publishedAt": "1987-10-24T12:00:00.000Z",
|
||||
"body": [
|
||||
"MERIDIAN MARITIME AUCTION — ADVANCE CATALOGUE · 24 OCTOBER 1987",
|
||||
"LOT 117 — Restored third-order Fresnel lighthouse assembly, late nineteenth century, complete in fitted transit frame.",
|
||||
"Consignor: Voss Antiquities Ltd. Seller reference: MV-3/771.",
|
||||
"Insurance estimate: 45,000–52,000 kroner. Private preview by appointment before export sale.",
|
||||
"Catalogue correction: lot withdrawn from public view on 26 October at consignor's request."
|
||||
],
|
||||
"metadata": { "lot": "117", "seller_reference": "MV-3/771", "estimate": "45,000–52,000 NOK" }
|
||||
}
|
||||
],
|
||||
"folders": [
|
||||
{
|
||||
"key": "procurement-file",
|
||||
"title": "PROCUREMENT & OWNERSHIP",
|
||||
"content": "Authority, ownership, destination, and declared value.",
|
||||
"x": 270,
|
||||
"y": 250,
|
||||
"width": 270,
|
||||
"members": ["company-register", "contract-award"]
|
||||
},
|
||||
{
|
||||
"key": "movement-file",
|
||||
"title": "MOVEMENT RECORDS",
|
||||
"content": "Dispatch, harbor access, image capture, and later explanation.",
|
||||
"x": 680,
|
||||
"y": 320,
|
||||
"width": 280,
|
||||
"members": ["dispatch-manifest", "gate-ledger", "warehouse-photo", "redirect-memo"]
|
||||
},
|
||||
{
|
||||
"key": "aftermath-file",
|
||||
"title": "AFTERMATH",
|
||||
"content": "The missing inventory report and the first attempted resale.",
|
||||
"x": 1090,
|
||||
"y": 250,
|
||||
"width": 270,
|
||||
"members": ["inventory-notice", "auction-catalogue"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+284
-103
@@ -8,19 +8,24 @@
|
||||
"name": "gupi-osint-board",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "16.5.0",
|
||||
"express": "5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "0.468.0",
|
||||
"multer": "2.0.2",
|
||||
"multer": "^2.2.0",
|
||||
"pg": "8.16.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tsx": "4.20.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/cors": "2.8.18",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "2.0.0",
|
||||
"@types/node": "22.15.30",
|
||||
"@types/pg": "8.15.4",
|
||||
@@ -29,11 +34,11 @@
|
||||
"@vitejs/plugin-react": "4.5.2",
|
||||
"concurrently": "9.1.2",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "6.3.5",
|
||||
"vitest": "3.2.3"
|
||||
"vite": "^6.4.3",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "^20.0.0 || >=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -801,6 +806,22 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
|
||||
@@ -1235,6 +1256,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie-parser": {
|
||||
"version": "1.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz",
|
||||
"integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.18.tgz",
|
||||
@@ -1291,6 +1322,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||
@@ -1400,15 +1449,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.3.tgz",
|
||||
"integrity": "sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.3",
|
||||
"@vitest/utils": "3.2.3",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
@@ -1417,13 +1466,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.3.tgz",
|
||||
"integrity": "sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
|
||||
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.3",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
@@ -1457,13 +1506,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.3.tgz",
|
||||
"integrity": "sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
|
||||
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.3",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
@@ -1472,13 +1521,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.3.tgz",
|
||||
"integrity": "sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
|
||||
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.3",
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -1486,23 +1535,10 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.3.tgz",
|
||||
"integrity": "sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.3.tgz",
|
||||
"integrity": "sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
|
||||
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1513,27 +1549,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.3.tgz",
|
||||
"integrity": "sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.3",
|
||||
"loupe": "^3.1.3",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils/node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.3.tgz",
|
||||
"integrity": "sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
|
||||
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
@@ -1679,6 +1702,12 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -1936,6 +1965,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
@@ -2027,6 +2075,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -2516,6 +2573,61 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
|
||||
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.2",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -2523,6 +2635,48 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
@@ -2618,27 +2772,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2646,21 +2779,22 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/media-typer": {
|
||||
@@ -2931,6 +3065,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
@@ -3720,9 +3901,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3795,9 +3976,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.3.tgz",
|
||||
"integrity": "sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3818,20 +3999,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.3.tgz",
|
||||
"integrity": "sha512-E6U2ZFXe3N/t4f5BwUaVCKRLHqUpk1CBWeMh78UT4VaTPH/2dyvH6ALl29JTovEPu9dVKr/K/J4PkXgrMbw4Ww==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
|
||||
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.3",
|
||||
"@vitest/mocker": "3.2.3",
|
||||
"@vitest/pretty-format": "^3.2.3",
|
||||
"@vitest/runner": "3.2.3",
|
||||
"@vitest/snapshot": "3.2.3",
|
||||
"@vitest/spy": "3.2.3",
|
||||
"@vitest/utils": "3.2.3",
|
||||
"@vitest/expect": "3.2.7",
|
||||
"@vitest/mocker": "3.2.7",
|
||||
"@vitest/pretty-format": "^3.2.7",
|
||||
"@vitest/runner": "3.2.7",
|
||||
"@vitest/snapshot": "3.2.7",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
@@ -3842,10 +4023,10 @@
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.2",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tinypool": "^1.1.0",
|
||||
"tinypool": "^1.1.1",
|
||||
"tinyrainbow": "^2.0.0",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||
"vite-node": "3.2.3",
|
||||
"vite-node": "3.2.4",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3861,8 +4042,8 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.3",
|
||||
"@vitest/ui": "3.2.3",
|
||||
"@vitest/browser": "3.2.7",
|
||||
"@vitest/ui": "3.2.7",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
|
||||
+15
-6
@@ -8,36 +8,45 @@
|
||||
"dev:web": "vite",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"build": "tsc -b && vite build",
|
||||
"e2e:serve": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} tsx server/e2eHarness.ts",
|
||||
"start": "NODE_ENV=production tsx server/index.ts",
|
||||
"migrate:up": "tsx server/migrate.ts",
|
||||
"test": "vitest run"
|
||||
"mystery:import": "tsx scripts/importMysteryTemplate.ts",
|
||||
"test": "vitest run --exclude '**/*.integration.test.ts' --exclude 'e2e/**'",
|
||||
"test:integration": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} vitest run server/*.integration.test.ts",
|
||||
"test:e2e": "npm run build && playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "16.5.0",
|
||||
"express": "5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "0.468.0",
|
||||
"multer": "2.0.2",
|
||||
"multer": "^2.2.0",
|
||||
"pg": "8.16.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tsx": "4.20.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/cors": "2.8.18",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.15.30",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "2.0.0",
|
||||
"@types/node": "22.15.30",
|
||||
"@types/pg": "8.15.4",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@vitejs/plugin-react": "4.5.2",
|
||||
"concurrently": "9.1.2",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "6.3.5",
|
||||
"vitest": "3.2.3"
|
||||
"vite": "^6.4.3",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "^20.0.0 || >=22.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
const port = 18788
|
||||
const adminToken = jwt.sign({ sub: 'e2e-admin', role: 'admin' }, 'osint-e2e-jwt-secret')
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 30_000,
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${port}`,
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
extraHTTPHeaders: { Cookie: `auth_token=${adminToken}` },
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run e2e:serve',
|
||||
url: `http://127.0.0.1:${port}/api/health`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
gracefulShutdown: { signal: 'SIGTERM', timeout: 10_000 },
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { CaseDocument, CaseState, PartyKind, SourceFileType } from '../src/types.js'
|
||||
|
||||
type MysteryDocument = {
|
||||
key: string
|
||||
title: string
|
||||
fileType: SourceFileType
|
||||
publishedAt: string
|
||||
body?: string[]
|
||||
metadata?: Record<string, string>
|
||||
asset?: string
|
||||
}
|
||||
type MysteryManifest = {
|
||||
slug: string
|
||||
name: string
|
||||
title: string
|
||||
subtitle: string
|
||||
timelineRange?: { start: string; end: string }
|
||||
brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] }
|
||||
documents: MysteryDocument[]
|
||||
folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
|
||||
}
|
||||
|
||||
function requireOk(response: Response, action: string) {
|
||||
if (response.ok) return response
|
||||
return response.text().then(body => { throw new Error(`${action} failed (${response.status}): ${body}`) })
|
||||
}
|
||||
|
||||
function documentKind(type: SourceFileType) {
|
||||
return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase()
|
||||
}
|
||||
|
||||
async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument, authorization?: string) {
|
||||
if (!document.asset) return undefined
|
||||
const assetPath = path.resolve(manifestDir, document.asset)
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([await readFile(assetPath)]), path.basename(assetPath))
|
||||
const response = await requireOk(await fetch(`${baseUrl}/api/levels/${levelId}/documents?edit=1`, { method: 'POST', headers: authorization ? { authorization } : undefined, body: form }), `Upload ${document.asset}`)
|
||||
return await response.json() as CaseDocument
|
||||
}
|
||||
|
||||
export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) {
|
||||
const absoluteManifest = path.resolve(manifestPath)
|
||||
const manifest = JSON.parse(await readFile(absoluteManifest, 'utf8')) as MysteryManifest
|
||||
const manifestDir = path.dirname(absoluteManifest)
|
||||
const authoringId = `${manifest.slug}-authoring-${Date.now()}`
|
||||
const authorization = adminJwt ? `Bearer ${adminJwt}` : undefined
|
||||
const headers = { 'content-type': 'application/json', ...(authorization ? { authorization } : {}) }
|
||||
const createdResponse = await requireOk(await fetch(`${baseUrl}/api/levels`, {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({ id: authoringId, title: manifest.title, subtitle: manifest.subtitle }),
|
||||
}), 'Create authoring level')
|
||||
const state = await createdResponse.json() as CaseState
|
||||
|
||||
const documents = new Map<string, CaseDocument>()
|
||||
for (const source of manifest.documents) {
|
||||
const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source, authorization)
|
||||
documents.set(source.key, {
|
||||
id: uploaded?.id || randomUUID(), title: source.title, kind: documentKind(source.fileType),
|
||||
date: source.publishedAt.slice(0, 10), publishedAt: source.publishedAt,
|
||||
body: source.body || [], regions: [], assetId: uploaded?.assetId,
|
||||
fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize,
|
||||
fileType: source.fileType, metadata: source.metadata || {},
|
||||
})
|
||||
}
|
||||
|
||||
const folderIds = new Map(manifest.folders.map(folder => [folder.key, randomUUID()]))
|
||||
state.brief = { body: manifest.brief.body, concepts: manifest.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) }
|
||||
state.timelineRange = manifest.timelineRange
|
||||
state.documents = [...documents.values()]
|
||||
state.evidence = manifest.folders.map(folder => ({
|
||||
id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content,
|
||||
x: folder.x, y: folder.y, width: folder.width, config: { open: false },
|
||||
containedDocumentIds: folder.members.map(key => documents.get(key)!.id),
|
||||
}))
|
||||
state.relations = manifest.folders.flatMap((folder, folderIndex) => folder.members.map((key, memberIndex) => {
|
||||
const document = documents.get(key)
|
||||
if (!document) throw new Error(`Folder ${folder.key} refers to unknown document ${key}`)
|
||||
return {
|
||||
id: `contains:${folderIds.get(folder.key)}:${document.id}`,
|
||||
fromWidgetId: folderIds.get(folder.key)!, toWidgetId: document.id, type: 'contains', sortOrder: memberIndex,
|
||||
config: { x: folder.x + 70 + memberIndex * 205, y: folder.y + 230 + folderIndex * 35 },
|
||||
}
|
||||
}))
|
||||
state.connections = []
|
||||
state.viewport = { x: 0, y: 28, zoom: 0.7 }
|
||||
|
||||
await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT', headers, body: JSON.stringify(state),
|
||||
}), 'Save authored mystery')
|
||||
const templateResponse = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
||||
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }),
|
||||
}), 'Freeze mystery template')
|
||||
const template = await templateResponse.json() as { slug: string; currentVersion: number }
|
||||
const playableId = `${manifest.slug}-case-${Date.now()}`
|
||||
const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${manifest.slug}/levels?edit=1`, {
|
||||
method: 'POST', headers, body: JSON.stringify({ id: playableId, title: manifest.title }),
|
||||
}), 'Instantiate playable mystery')
|
||||
const playable = await playableResponse.json() as CaseState
|
||||
return { manifest, template, authoringLevelId: state.id, playableLevel: playable }
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''
|
||||
if (invokedPath === fileURLToPath(import.meta.url)) {
|
||||
const manifestPath = process.argv[2]
|
||||
if (!manifestPath) throw new Error('Usage: npm run mystery:import -- <manifest.json>')
|
||||
const result = await importMysteryTemplate(manifestPath, process.env.OSINT_BOARD_URL)
|
||||
console.log(JSON.stringify({
|
||||
template: `${result.template.slug}@v${result.template.currentVersion}`,
|
||||
authoringLevelId: result.authoringLevelId,
|
||||
playableLevelId: result.playableLevel.id,
|
||||
playUrl: `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/?level=${encodeURIComponent(result.playableLevel.id)}`,
|
||||
}, null, 2))
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { createServer } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import pg from 'pg'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { CaseState } from '../src/types.js'
|
||||
import { runMigrations } from './migrations.js'
|
||||
|
||||
const { Client } = pg
|
||||
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
|
||||
const suite = baseDatabaseUrl ? describe : describe.skip
|
||||
const databaseName = `osint_api_test_${process.pid}_${Date.now()}`
|
||||
let adminClient: InstanceType<typeof Client>
|
||||
let appServer: Awaited<typeof import('./index.js')>['server']
|
||||
let appPool: Awaited<typeof import('./index.js')>['pool']
|
||||
let baseUrl = ''
|
||||
let adminAuthorization = ''
|
||||
|
||||
function adminFetch(url: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('authorization', adminAuthorization)
|
||||
return fetch(url, { ...init, headers })
|
||||
}
|
||||
|
||||
async function availablePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const probe = createServer()
|
||||
probe.once('error', reject)
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const address = probe.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
probe.close(error => error ? reject(error) : resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
suite('level persistence API', () => {
|
||||
beforeAll(async () => {
|
||||
const adminUrl = new URL(baseDatabaseUrl!)
|
||||
adminUrl.pathname = '/postgres'
|
||||
adminClient = new Client({ connectionString: adminUrl.toString() })
|
||||
await adminClient.connect()
|
||||
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
|
||||
const testUrl = new URL(baseDatabaseUrl!)
|
||||
testUrl.pathname = `/${databaseName}`
|
||||
const databaseUrl = testUrl.toString()
|
||||
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
||||
await runMigrations(databaseUrl, migrationsDir, () => undefined)
|
||||
|
||||
const port = await availablePort()
|
||||
process.env.DATABASE_URL = databaseUrl
|
||||
process.env.LEVEL_EDITING_ENABLED = 'true'
|
||||
process.env.JWT_SECRET = 'osint-integration-jwt-secret'
|
||||
process.env.PORT = String(port)
|
||||
const serverModule = await import('./index.js')
|
||||
appServer = serverModule.server
|
||||
appPool = serverModule.pool
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
adminAuthorization = `Bearer ${jwt.sign({ sub: 'integration-admin', role: 'admin' }, process.env.JWT_SECRET)}`
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
|
||||
if (appPool) await appPool.end()
|
||||
if (!adminClient) return
|
||||
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
||||
await adminClient.end()
|
||||
})
|
||||
|
||||
it('persists one normalized level across authoring and play views', async () => {
|
||||
expect(await (await fetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: false, isAdmin: false })
|
||||
expect(await (await adminFetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: true, isAdmin: true })
|
||||
expect((await fetch(`${baseUrl}/api/levels`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status).toBe(403)
|
||||
const createResponse = await adminFetch(`${baseUrl}/api/levels`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
|
||||
})
|
||||
expect(createResponse.status).toBe(201)
|
||||
const state = await createResponse.json() as CaseState
|
||||
state.viewport = { x: 91, y: -42, zoom: 0.85 }
|
||||
state.timelineRange = { start: '2021-04-01', end: '2021-04-30' }
|
||||
const documentId = randomUUID()
|
||||
const folderId = randomUUID()
|
||||
const noteId = randomUUID()
|
||||
const eventId = randomUUID()
|
||||
const personConceptId = randomUUID()
|
||||
const organizationConceptId = randomUUID()
|
||||
state.brief = { body: 'Identify Ada Lovelace and Analytical Engines Ltd in the source material.', concepts: [
|
||||
{ id: personConceptId, label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' },
|
||||
{ id: organizationConceptId, label: 'Analytical Engines Ltd', context: 'Issued the filing.', expectedPartyKind: 'organization' },
|
||||
] }
|
||||
state.documents = [{ id: documentId, title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {} }]
|
||||
state.evidence = [
|
||||
{ id: folderId, type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: [documentId] },
|
||||
{ id: noteId, type: 'note', title: 'Extract', content: 'Date matters', sourceDocumentId: documentId, sourceRegionId: 'stamp', x: 420, y: 300, width: 108 },
|
||||
{ id: eventId, type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', supportingEvidenceIds: [documentId, noteId], x: 520, y: 610, width: 270 },
|
||||
]
|
||||
state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
|
||||
state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }]
|
||||
|
||||
const saveResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(state),
|
||||
})
|
||||
expect(await saveResponse.json()).toEqual({ ok: true, mode: 'author' })
|
||||
|
||||
const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
expect(loaded.viewport).toEqual(state.viewport)
|
||||
expect(loaded.timelineRange).toEqual(state.timelineRange)
|
||||
expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } })
|
||||
expect(loaded.relations[0]).toMatchObject({ id: `contains:${folderId}:${documentId}`, config: { x: 1051, y: 417 } })
|
||||
expect(loaded.connections).toContainEqual(expect.objectContaining({ fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }))
|
||||
expect(loaded.brief.concepts).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }),
|
||||
expect.objectContaining({ label: 'Analytical Engines Ltd', expectedPartyKind: 'organization' }),
|
||||
]))
|
||||
const legacyClientState = structuredClone(loaded)
|
||||
delete legacyClientState.timelineRange
|
||||
const legacySave = await fetch(`${baseUrl}/api/levels/${state.id}`, {
|
||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(legacyClientState),
|
||||
})
|
||||
expect(legacySave.ok).toBe(true)
|
||||
expect((await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState).timelineRange).toEqual(state.timelineRange)
|
||||
|
||||
const upload = new FormData()
|
||||
upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt')
|
||||
const uploadResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload })
|
||||
expect(uploadResponse.status).toBe(201)
|
||||
const uploaded = await uploadResponse.json() as CaseState['documents'][number]
|
||||
expect(uploaded).toMatchObject({ title: 'smoke-evidence.txt', fileName: 'smoke-evidence.txt', mimeType: 'text/plain', fileType: 'text' })
|
||||
expect(uploaded.assetId).toBeTruthy()
|
||||
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence')
|
||||
|
||||
const withUpload = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
const uploadedDocument = withUpload.documents.find(document => document.id === uploaded.id)!
|
||||
uploadedDocument.title = 'Renamed smoke evidence'
|
||||
uploadedDocument.publishedAt = '2022-06-15T10:30:00.000Z'
|
||||
uploadedDocument.metadata = { witness: 'Integration test', confidence: 'high' }
|
||||
const metadataSave = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(withUpload),
|
||||
})
|
||||
expect(metadataSave.ok).toBe(true)
|
||||
const afterMetadataSave = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
expect(afterMetadataSave.documents.find(document => document.id === uploaded.id)).toMatchObject({
|
||||
title: 'Renamed smoke evidence',
|
||||
publishedAt: '2022-06-15T10:30:00.000Z',
|
||||
metadata: { witness: 'Integration test', confidence: 'high' },
|
||||
})
|
||||
|
||||
const undatedState = structuredClone(afterMetadataSave)
|
||||
const undatedEvent = undatedState.evidence.find(exhibit => exhibit.id === eventId)!
|
||||
delete undatedEvent.eventDate
|
||||
const undatedSave = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(undatedState),
|
||||
})
|
||||
expect(undatedSave.ok).toBe(true)
|
||||
const loadedUndated = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
expect(loadedUndated.evidence.find(exhibit => exhibit.id === eventId)?.eventDate).toBeUndefined()
|
||||
expect((await appPool.query<{ occurred_at: Date | null }>('SELECT occurred_at FROM osint.event_exhibits WHERE exhibit_id=$1', [eventId])).rows[0].occurred_at).toBeNull()
|
||||
loadedUndated.evidence.find(exhibit => exhibit.id === eventId)!.eventDate = '2021-04-18T14:30:00Z'
|
||||
expect((await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(loadedUndated),
|
||||
})).ok).toBe(true)
|
||||
|
||||
const playerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
||||
expect(playerState.brief.concepts.every(concept => concept.expectedPartyKind === undefined)).toBe(true)
|
||||
const personPartyId = randomUUID()
|
||||
const organizationPartyId = randomUUID()
|
||||
playerState.evidence.push(
|
||||
{ id: personPartyId, type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as the correspondent.', aliases: ['A. A. L.'], relatedEvidenceIds: [documentId, noteId], x: 720, y: 250, width: 280 },
|
||||
{ id: organizationPartyId, type: 'party', partyKind: 'organization', organizationKind: 'business', title: 'Analytical Engines Ltd', content: 'Issued the filing.', aliases: ['AEL'], relatedEvidenceIds: [documentId], x: 1020, y: 250, width: 280 },
|
||||
)
|
||||
playerState.brief.concepts = playerState.brief.concepts.map(concept => ({ ...concept,
|
||||
resolvedPartyExhibitId: concept.id === personConceptId ? personPartyId : organizationPartyId }))
|
||||
playerState.viewport = { x: -150, y: 88, zoom: 1.1 }
|
||||
playerState.evidence[0] = { ...playerState.evidence[0], x: 812, y: 533 }
|
||||
const playerSave = await fetch(`${baseUrl}/api/levels/${state.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(playerState),
|
||||
})
|
||||
expect(await playerSave.json()).toEqual({ ok: true, mode: 'play' })
|
||||
const savedPlayerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
||||
expect(savedPlayerState.viewport).toEqual(playerState.viewport)
|
||||
expect(savedPlayerState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
||||
const sameLevelInEditView = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
expect(sameLevelInEditView.viewport).toEqual(playerState.viewport)
|
||||
expect(sameLevelInEditView.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
||||
|
||||
const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string; sources: string; connections: string; events: string; event_evidence: string; parties: string; people: string; organizations: string; party_evidence: string; concepts: string; hidden_answers: string }>(`SELECT
|
||||
(SELECT COUNT(*) FROM osint.exhibits)::text AS exhibits,
|
||||
(SELECT COUNT(*) FROM osint.document_exhibits)::text AS documents,
|
||||
(SELECT COUNT(*) FROM osint.folder_exhibits)::text AS folders,
|
||||
(SELECT COUNT(*) FROM osint.folder_memberships)::text AS memberships,
|
||||
(SELECT COUNT(*) FROM osint.exhibit_metadata_text_values)::text AS metadata,
|
||||
(SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources,
|
||||
(SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections,
|
||||
(SELECT COUNT(*) FROM osint.event_exhibits)::text AS events,
|
||||
(SELECT COUNT(*) FROM osint.event_evidence)::text AS event_evidence,
|
||||
(SELECT COUNT(*) FROM osint.party_exhibits)::text AS parties,
|
||||
(SELECT COUNT(*) FROM osint.person_parties)::text AS people,
|
||||
(SELECT COUNT(*) FROM osint.organization_parties)::text AS organizations,
|
||||
(SELECT COUNT(*) FROM osint.party_evidence)::text AS party_evidence,
|
||||
(SELECT COUNT(*) FROM osint.brief_concepts)::text AS concepts,
|
||||
(SELECT COUNT(*) FROM osint.brief_concepts WHERE expected_party_kind IS NOT NULL)::text AS hidden_answers`)
|
||||
expect(normalized.rows[0]).toEqual({ exhibits: '7', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1', events: '1', event_evidence: '2', parties: '2', people: '1', organizations: '1', party_evidence: '3', concepts: '2', hidden_answers: '2' })
|
||||
|
||||
const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' })
|
||||
expect(resetResponse.ok).toBe(true)
|
||||
const resetState = await resetResponse.json() as CaseState
|
||||
expect(resetState.viewport).toEqual(playerState.viewport)
|
||||
expect(resetState.timelineRange).toEqual(state.timelineRange)
|
||||
expect(resetState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
||||
|
||||
const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }),
|
||||
})
|
||||
expect(templateResponse.status).toBe(201)
|
||||
expect(await templateResponse.json()).toMatchObject({ slug: 'smoke-template', currentVersion: 1, versionCount: 1 })
|
||||
expect(await (await fetch(`${baseUrl}/api/templates`)).json()).toEqual([
|
||||
expect.objectContaining({ slug: 'smoke-template', currentVersion: 1, versionCount: 1 }),
|
||||
])
|
||||
|
||||
const changedSource = structuredClone(savedPlayerState)
|
||||
changedSource.title = 'Changed after template freeze'
|
||||
changedSource.evidence[0].x = 999
|
||||
await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(changedSource),
|
||||
})
|
||||
const cloneResponse = await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'smoke-template-copy', title: 'Playable copy' }),
|
||||
})
|
||||
expect(cloneResponse.status).toBe(201)
|
||||
const clone = await cloneResponse.json() as CaseState
|
||||
expect(clone).toMatchObject({ id: 'smoke-template-copy', title: 'Playable copy', sourceTemplateVersionId: expect.any(String) })
|
||||
expect(clone.timelineRange).toEqual(state.timelineRange)
|
||||
expect(clone.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 })
|
||||
expect(clone.documents.find(item => item.title === 'Renamed smoke evidence')?.assetId).toBe(uploaded.assetId)
|
||||
expect(clone.documents[0].id).not.toBe(savedPlayerState.documents[0].id)
|
||||
expect(clone.evidence.map(item => item.id)).not.toContain(folderId)
|
||||
expect(clone.connections).toHaveLength(1)
|
||||
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12, toEvidenceId: clone.documents[0].id })
|
||||
expect(clone.evidence.find(item => item.type === 'note')).toMatchObject({ sourceRegionId: 'stamp' })
|
||||
const clonedEvent = clone.evidence.find(item => item.type === 'event')!
|
||||
expect(clonedEvent).toMatchObject({ title: 'The meeting occurred', eventDate: '2021-04-18T14:30:00.000Z' })
|
||||
expect(clonedEvent.supportingEvidenceIds).toHaveLength(2)
|
||||
expect(clonedEvent.supportingEvidenceIds).not.toContain(documentId)
|
||||
expect(clonedEvent.supportingEvidenceIds).not.toContain(noteId)
|
||||
expect(clone.evidence.filter(item => item.type === 'party')).toHaveLength(2)
|
||||
expect(clone.brief.concepts.every(concept => Boolean(concept.resolvedPartyExhibitId))).toBe(true)
|
||||
expect(clone.brief.concepts.map(concept => concept.resolvedPartyExhibitId)).not.toContain(personPartyId)
|
||||
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
||||
expect(authoredClone.brief.concepts.map(concept => concept.expectedPartyKind).sort()).toEqual(['organization', 'person'])
|
||||
|
||||
const clonedFolder = clone.evidence.find(item => item.type === 'folder')!
|
||||
clonedFolder.x = 1234
|
||||
clone.viewport = { x: 333, y: 222, zoom: 1.2 }
|
||||
await fetch(`${baseUrl}/api/levels/${clone.id}`, {
|
||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(clone),
|
||||
})
|
||||
const cloneReset = await (await fetch(`${baseUrl}/api/levels/${clone.id}/reset`, { method: 'POST' })).json() as CaseState
|
||||
expect(cloneReset).toMatchObject({ title: 'API Smoke Level', viewport: { x: 0, y: 28, zoom: 0.7 } })
|
||||
expect(cloneReset.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 })
|
||||
expect(cloneReset.evidence.map(item => item.id)).not.toContain(clonedFolder.id)
|
||||
|
||||
const versionTwoResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }),
|
||||
})
|
||||
expect(await versionTwoResponse.json()).toMatchObject({ currentVersion: 2, versionCount: 2 })
|
||||
const oldVersion = await (await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'old-version-copy', version: 1 }),
|
||||
})).json() as CaseState
|
||||
const currentVersion = await (await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'current-version-copy' }),
|
||||
})).json() as CaseState
|
||||
expect(oldVersion.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812 })
|
||||
expect(currentVersion.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 999 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import jwt, { type JwtPayload } from 'jsonwebtoken'
|
||||
|
||||
export type OsintClaims = JwtPayload & { role?: string; isAdmin?: boolean }
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request { authClaims?: OsintClaims }
|
||||
}
|
||||
}
|
||||
|
||||
export function authenticateJwt(req: Request, _res: Response, next: NextFunction) {
|
||||
const authorization = req.headers.authorization
|
||||
const token = req.cookies?.auth_token || (authorization?.startsWith('Bearer ') ? authorization.slice(7) : undefined)
|
||||
const secret = process.env.JWT_SECRET
|
||||
if (token && secret) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, secret)
|
||||
if (typeof decoded !== 'string') req.authClaims = decoded as OsintClaims
|
||||
} catch { /* An absent, expired, or invalid cookie is an anonymous session. */ }
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
export function hasAdminClaim(req: Request) {
|
||||
return req.authClaims?.role === 'admin' || req.authClaims?.isAdmin === true
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
if (!hasAdminClaim(req)) return res.status(403).json({ error: 'Administrator claim required' })
|
||||
next()
|
||||
}
|
||||
|
||||
export function createDevelopmentAdminToken() {
|
||||
if (process.env.NODE_ENV === 'production') throw new Error('Development sessions are disabled in production')
|
||||
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
|
||||
return jwt.sign({ sub: 'osint-local-admin', role: 'admin', isAdmin: true }, process.env.JWT_SECRET, { expiresIn: '7d' })
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
type IdMap = Map<string, string>
|
||||
|
||||
function mapped(ids: IdMap, sourceId: string, label: string) {
|
||||
const id = ids.get(sourceId)
|
||||
if (!id) throw new Error(`Could not map ${label} ${sourceId}`)
|
||||
return id
|
||||
}
|
||||
|
||||
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.brief_concepts WHERE board_id=$1', [boardId])
|
||||
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId])
|
||||
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId])
|
||||
await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [boardId])
|
||||
await client.query('UPDATE osint.boards SET revision=0,updated_at=NOW() WHERE id=$1', [boardId])
|
||||
}
|
||||
|
||||
/** Clone a complete normalized board. The target board must be empty. */
|
||||
export async function cloneBoard(client: PoolClient, sourceBoardId: string, targetBoardId: string) {
|
||||
const exhibitIds: IdMap = new Map()
|
||||
const regionIds: IdMap = new Map()
|
||||
const fieldIds: IdMap = new Map()
|
||||
|
||||
const timeline = await client.query<{ range_start: string; range_end: string }>(
|
||||
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [sourceBoardId])
|
||||
if (timeline.rows[0]) await client.query(
|
||||
'INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)',
|
||||
[targetBoardId, timeline.rows[0].range_start, timeline.rows[0].range_end])
|
||||
|
||||
const exhibits = await client.query<{
|
||||
id: string; exhibit_type_id: string; xpos: number; ypos: number; width: number; height: number
|
||||
rotation: number; z_index: number; hidden: boolean
|
||||
}>(`SELECT id,exhibit_type_id,xpos,ypos,width,height,rotation,z_index,hidden
|
||||
FROM osint.exhibits WHERE board_id=$1 ORDER BY created_at,id`, [sourceBoardId])
|
||||
for (const row of exhibits.rows) {
|
||||
const id = randomUUID(); exhibitIds.set(row.id, id)
|
||||
await client.query(`INSERT INTO osint.exhibits
|
||||
(id,board_id,exhibit_type_id,origin_exhibit_id,xpos,ypos,width,height,rotation,z_index,hidden)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
||||
[id, targetBoardId, row.exhibit_type_id, row.id, row.xpos, row.ypos, row.width, row.height, row.rotation, row.z_index, row.hidden])
|
||||
}
|
||||
|
||||
const folders = await client.query<{ exhibit_id: string; title: string; label_text: string; is_open: boolean }>(
|
||||
`SELECT f.* FROM osint.folder_exhibits f JOIN osint.exhibits e ON e.id=f.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||
for (const row of folders.rows) await client.query(
|
||||
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
|
||||
[mapped(exhibitIds, row.exhibit_id, 'folder'), row.title, row.label_text, row.is_open])
|
||||
|
||||
const documents = await client.query<{
|
||||
exhibit_id: string; document_type_id: string; asset_id: string | null; title: string
|
||||
published_at: Date | null; captured_at: Date | null; source_uri: string | null
|
||||
}>(`SELECT d.* FROM osint.document_exhibits d JOIN osint.exhibits e ON e.id=d.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||
for (const row of documents.rows) 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)`,
|
||||
[mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri])
|
||||
|
||||
const images = await client.query<{ exhibit_id: string; pixel_width: number | null; pixel_height: number | null; alt_text: string }>(
|
||||
`SELECT i.* FROM osint.image_documents i JOIN osint.exhibits e ON e.id=i.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||
for (const row of images.rows) await client.query(
|
||||
'INSERT INTO osint.image_documents (exhibit_id,pixel_width,pixel_height,alt_text) VALUES ($1,$2,$3,$4)',
|
||||
[mapped(exhibitIds, row.exhibit_id, 'image'), row.pixel_width, row.pixel_height, row.alt_text])
|
||||
|
||||
const notes = await client.query<{ exhibit_id: string; title: string; note_text: string }>(
|
||||
`SELECT n.* FROM osint.note_exhibits n JOIN osint.exhibits e ON e.id=n.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||
for (const row of notes.rows) await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)',
|
||||
[mapped(exhibitIds, row.exhibit_id, 'note'), row.title, row.note_text])
|
||||
|
||||
const events = await client.query<{ exhibit_id: string; title: string; narrative_text: string; occurred_at: Date | null }>(
|
||||
`SELECT ev.* FROM osint.event_exhibits ev JOIN osint.exhibits e ON e.id=ev.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||
for (const row of events.rows) await client.query(
|
||||
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
|
||||
[mapped(exhibitIds, row.exhibit_id, 'event'), row.title, row.narrative_text, row.occurred_at])
|
||||
|
||||
const parties = await client.query<{ exhibit_id: string; party_kind: string; display_name: string; summary: string }>(
|
||||
`SELECT p.* FROM osint.party_exhibits p JOIN osint.exhibits e ON e.id=p.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||
for (const row of parties.rows) await client.query(
|
||||
'INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)',
|
||||
[mapped(exhibitIds, row.exhibit_id, 'party'), row.party_kind, row.display_name, row.summary])
|
||||
const people = await client.query<{ exhibit_id: string; given_name: string | null; family_name: string | null }>(
|
||||
`SELECT p.* FROM osint.person_parties p JOIN osint.exhibits e ON e.id=p.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||
for (const row of people.rows) await client.query('INSERT INTO osint.person_parties (exhibit_id,given_name,family_name) VALUES ($1,$2,$3)',
|
||||
[mapped(exhibitIds, row.exhibit_id, 'person'), row.given_name, row.family_name])
|
||||
const organizations = await client.query<{ exhibit_id: string; organization_kind: string }>(
|
||||
`SELECT o.* FROM osint.organization_parties o JOIN osint.exhibits e ON e.id=o.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||
for (const row of organizations.rows) await client.query('INSERT INTO osint.organization_parties (exhibit_id,organization_kind) VALUES ($1,$2)',
|
||||
[mapped(exhibitIds, row.exhibit_id, 'organization'), row.organization_kind])
|
||||
const aliases = await client.query<{ party_exhibit_id: string; alias: string; sort_order: number }>(
|
||||
`SELECT a.party_exhibit_id,a.alias,a.sort_order FROM osint.party_aliases a JOIN osint.exhibits e ON e.id=a.party_exhibit_id
|
||||
WHERE e.board_id=$1 ORDER BY a.sort_order`, [sourceBoardId])
|
||||
for (const row of aliases.rows) await client.query('INSERT INTO osint.party_aliases (id,party_exhibit_id,alias,sort_order) VALUES ($1,$2,$3,$4)',
|
||||
[randomUUID(), mapped(exhibitIds, row.party_exhibit_id, 'party alias'), row.alias, row.sort_order])
|
||||
|
||||
const blocks = await client.query<{ document_exhibit_id: string; sort_order: number; content: string }>(
|
||||
`SELECT b.document_exhibit_id,b.sort_order,b.content FROM osint.document_content_blocks b
|
||||
JOIN osint.exhibits e ON e.id=b.document_exhibit_id WHERE e.board_id=$1 ORDER BY b.sort_order`, [sourceBoardId])
|
||||
for (const row of blocks.rows) await client.query(
|
||||
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)',
|
||||
[randomUUID(), mapped(exhibitIds, row.document_exhibit_id, 'content document'), row.sort_order, row.content])
|
||||
|
||||
const regions = await client.query<{
|
||||
id: string; document_exhibit_id: string; region_key: string; label: string; excerpt: string; occurred_at: Date | null; sort_order: number
|
||||
}>(`SELECT r.* FROM osint.document_regions r JOIN osint.exhibits e ON e.id=r.document_exhibit_id
|
||||
WHERE e.board_id=$1 ORDER BY r.sort_order`, [sourceBoardId])
|
||||
for (const row of regions.rows) {
|
||||
const id = randomUUID(); regionIds.set(row.id, id)
|
||||
await client.query(`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)`,
|
||||
[id, mapped(exhibitIds, row.document_exhibit_id, 'region document'), row.region_key, row.label, row.excerpt, row.occurred_at, row.sort_order])
|
||||
}
|
||||
|
||||
const fields = await client.query<{ id: string; field_key: string; label: string; value_type: string }>(
|
||||
'SELECT id,field_key,label,value_type FROM osint.metadata_fields WHERE board_id=$1 ORDER BY field_key', [sourceBoardId])
|
||||
for (const row of fields.rows) {
|
||||
const id = randomUUID(); fieldIds.set(row.id, id)
|
||||
await client.query('INSERT INTO osint.metadata_fields (id,board_id,field_key,label,value_type) VALUES ($1,$2,$3,$4,$5)',
|
||||
[id, targetBoardId, row.field_key, row.label, row.value_type])
|
||||
}
|
||||
for (const [table, cast] of [
|
||||
['exhibit_metadata_text_values', 'text'], ['exhibit_metadata_timestamp_values', 'timestamptz'],
|
||||
['exhibit_metadata_number_values', 'numeric'], ['exhibit_metadata_boolean_values', 'boolean'],
|
||||
] as const) {
|
||||
const values = await client.query<{ exhibit_id: string; field_id: string; value: unknown }>(
|
||||
`SELECT v.exhibit_id,v.field_id,v.value::${cast} AS value FROM osint.${table} v
|
||||
JOIN osint.exhibits e ON e.id=v.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||
for (const row of values.rows) await client.query(`INSERT INTO osint.${table} (exhibit_id,field_id,value) VALUES ($1,$2,$3)`,
|
||||
[mapped(exhibitIds, row.exhibit_id, 'metadata exhibit'), mapped(fieldIds, row.field_id, 'metadata field'), row.value])
|
||||
}
|
||||
|
||||
const memberships = await client.query<{ folder_exhibit_id: string; child_exhibit_id: string; sort_order: number }>(
|
||||
'SELECT folder_exhibit_id,child_exhibit_id,sort_order FROM osint.folder_memberships WHERE board_id=$1', [sourceBoardId])
|
||||
for (const row of memberships.rows) await client.query(`INSERT INTO osint.folder_memberships
|
||||
(board_id,folder_exhibit_id,child_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
|
||||
[targetBoardId, mapped(exhibitIds, row.folder_exhibit_id, 'membership folder'), mapped(exhibitIds, row.child_exhibit_id, 'membership child'), row.sort_order])
|
||||
|
||||
const eventEvidence = await client.query<{ event_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
|
||||
'SELECT event_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.event_evidence WHERE board_id=$1', [sourceBoardId])
|
||||
for (const row of eventEvidence.rows) await client.query(`INSERT INTO osint.event_evidence
|
||||
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
|
||||
[targetBoardId, mapped(exhibitIds, row.event_exhibit_id, 'event'), mapped(exhibitIds, row.evidence_exhibit_id, 'event evidence'), row.sort_order, row.note])
|
||||
|
||||
const partyEvidence = await client.query<{ party_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
|
||||
'SELECT party_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.party_evidence WHERE board_id=$1', [sourceBoardId])
|
||||
for (const row of partyEvidence.rows) await client.query(`INSERT INTO osint.party_evidence
|
||||
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
|
||||
[targetBoardId, mapped(exhibitIds, row.party_exhibit_id, 'party'), mapped(exhibitIds, row.evidence_exhibit_id, 'party evidence'), row.sort_order, row.note])
|
||||
const partyRelationships = await client.query<{ relationship_type_id: string; from_party_exhibit_id: string; to_party_exhibit_id: string; note: string | null }>(
|
||||
'SELECT relationship_type_id,from_party_exhibit_id,to_party_exhibit_id,note FROM osint.party_relationships WHERE board_id=$1', [sourceBoardId])
|
||||
for (const row of partyRelationships.rows) await client.query(`INSERT INTO osint.party_relationships
|
||||
(id,board_id,relationship_type_id,from_party_exhibit_id,to_party_exhibit_id,note) VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||
[randomUUID(), targetBoardId, row.relationship_type_id, mapped(exhibitIds, row.from_party_exhibit_id, 'related party'),
|
||||
mapped(exhibitIds, row.to_party_exhibit_id, 'related party'), row.note])
|
||||
|
||||
const connections = await client.query<{ connection_type_id: string; from_exhibit_id: string; to_exhibit_id: string; label: string | null; tightness: number; tag_style: string; tag_position_percent: number; tag_lateral_offset: number }>(
|
||||
'SELECT connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset FROM osint.exhibit_connections WHERE board_id=$1', [sourceBoardId])
|
||||
for (const row of connections.rows) 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,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
[randomUUID(), targetBoardId, row.connection_type_id, mapped(exhibitIds, row.from_exhibit_id, 'connection source'), mapped(exhibitIds, row.to_exhibit_id, 'connection target'), row.label, row.tightness, row.tag_style, row.tag_position_percent, row.tag_lateral_offset])
|
||||
|
||||
const sources = await client.query<{ exhibit_id: string; source_document_exhibit_id: string; source_region_id: string | null }>(
|
||||
`SELECT s.exhibit_id,s.source_document_exhibit_id,s.source_region_id FROM osint.exhibit_sources s
|
||||
JOIN osint.exhibits e ON e.id=s.exhibit_id WHERE e.board_id=$1`, [sourceBoardId])
|
||||
for (const row of sources.rows) await client.query(`INSERT INTO osint.exhibit_sources
|
||||
(exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)`,
|
||||
[mapped(exhibitIds, row.exhibit_id, 'sourced exhibit'), mapped(exhibitIds, row.source_document_exhibit_id, 'source document'),
|
||||
row.source_region_id ? mapped(regionIds, row.source_region_id, 'source region') : null])
|
||||
|
||||
const brief = await client.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [sourceBoardId])
|
||||
if (brief.rows[0]) await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [targetBoardId, brief.rows[0].body])
|
||||
const concepts = await client.query<{
|
||||
id: string; label: string; context_text: string; sort_order: number; expected_party_kind: string | null; resolved_party_exhibit_id: string | null
|
||||
}>('SELECT id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts WHERE board_id=$1 ORDER BY sort_order,id', [sourceBoardId])
|
||||
for (const row of concepts.rows) await client.query(`INSERT INTO osint.brief_concepts
|
||||
(id,board_id,origin_concept_id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
||||
[randomUUID(), targetBoardId, row.id, row.label, row.context_text, row.sort_order, row.expected_party_kind,
|
||||
row.resolved_party_exhibit_id ? mapped(exhibitIds, row.resolved_party_exhibit_id, 'resolved party') : null])
|
||||
|
||||
return exhibitIds
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { once } from 'node:events'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import pg from 'pg'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import type { CaseState } from '../src/types.js'
|
||||
import { importMysteryTemplate } from '../scripts/importMysteryTemplate.js'
|
||||
import { runMigrations } from './migrations.js'
|
||||
|
||||
const { Client } = pg
|
||||
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
|
||||
if (!baseDatabaseUrl) throw new Error('TEST_DATABASE_URL is required for the browser harness')
|
||||
|
||||
const databaseName = `osint_e2e_${process.pid}_${Date.now()}`
|
||||
const adminUrl = new URL(baseDatabaseUrl)
|
||||
adminUrl.pathname = '/postgres'
|
||||
const adminClient = new Client({ connectionString: adminUrl.toString() })
|
||||
await adminClient.connect()
|
||||
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
|
||||
|
||||
const testUrl = new URL(baseDatabaseUrl)
|
||||
testUrl.pathname = `/${databaseName}`
|
||||
const databaseUrl = testUrl.toString()
|
||||
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
||||
await runMigrations(databaseUrl, migrationsDir, () => undefined)
|
||||
|
||||
const port = Number(process.env.E2E_PORT || 18788)
|
||||
process.env.DATABASE_URL = databaseUrl
|
||||
process.env.LEVEL_EDITING_ENABLED = 'true'
|
||||
process.env.JWT_SECRET = 'osint-e2e-jwt-secret'
|
||||
process.env.PORT = String(port)
|
||||
process.env.OSINT_MANAGED_SERVER = 'true'
|
||||
const { server, pool } = await import('./index.js')
|
||||
if (!server.listening) await once(server, 'listening')
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
const adminToken = jwt.sign({ sub: 'e2e-admin', role: 'admin' }, process.env.JWT_SECRET)
|
||||
const adminHeaders = { 'content-type': 'application/json', authorization: `Bearer ${adminToken}` }
|
||||
const documentId = '22222222-2222-4222-8222-222222222222'
|
||||
const folderId = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
const created = await fetch(`${baseUrl}/api/levels`, {
|
||||
method: 'POST',
|
||||
headers: adminHeaders,
|
||||
body: JSON.stringify({ id: 'e2e-level', title: 'Browser Safety Test', subtitle: 'Disposable test level' }),
|
||||
})
|
||||
if (!created.ok) throw new Error(`Could not create browser test level: ${created.status}`)
|
||||
const state = await created.json() as CaseState
|
||||
state.brief = { body: 'Classify the named people and organizations in this investigation.', concepts: [
|
||||
{ id: '44444444-4444-4444-8444-444444444444', label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' },
|
||||
{ id: '55555555-5555-4555-8555-555555555555', label: 'Difference Engine Bureau', context: 'Issued the archive notice.', expectedPartyKind: 'organization' },
|
||||
] }
|
||||
state.documents = [{
|
||||
id: documentId, title: 'Dated source image', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00.000Z',
|
||||
body: [], regions: [], fileType: 'image', metadata: {},
|
||||
}]
|
||||
state.evidence = [{
|
||||
id: folderId, type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence',
|
||||
x: 600, y: 360, width: 260, config: { open: false }, containedDocumentIds: [documentId],
|
||||
}]
|
||||
state.relations = [{
|
||||
id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0,
|
||||
config: { x: 980, y: 360 },
|
||||
}]
|
||||
state.connections = []
|
||||
state.viewport = { x: 0, y: 28, zoom: 0.7 }
|
||||
const saved = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT',
|
||||
headers: adminHeaders,
|
||||
body: JSON.stringify(state),
|
||||
})
|
||||
if (!saved.ok) throw new Error(`Could not seed browser test level: ${saved.status}`)
|
||||
|
||||
await importMysteryTemplate(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'glass-harbor', 'mystery.json'), baseUrl, adminToken)
|
||||
|
||||
let shuttingDown = false
|
||||
async function shutdown(exitCode: number) {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
if (server.listening) await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
await pool.end()
|
||||
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
||||
await adminClient.end()
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => void shutdown(0))
|
||||
process.on('SIGINT', () => void shutdown(0))
|
||||
process.on('uncaughtException', error => { console.error(error); void shutdown(1) })
|
||||
process.on('unhandledRejection', error => { console.error(error); void shutdown(1) })
|
||||
console.log(`Browser safety harness ready on ${baseUrl}`)
|
||||
+71
-217
@@ -1,13 +1,15 @@
|
||||
import 'dotenv/config'
|
||||
import cors from 'cors'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import express from 'express'
|
||||
import fs from 'node:fs'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import multer from 'multer'
|
||||
import pg, { type PoolClient } from 'pg'
|
||||
import type { CaseDocument, CaseState, Connection, Evidence, WidgetRelation } from '../src/types.js'
|
||||
import pg from 'pg'
|
||||
import type { CaseState } from '../src/types.js'
|
||||
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin } from './auth.js'
|
||||
import { createLevelRepository } from './levelRepository.js'
|
||||
|
||||
const { Pool } = pg
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
@@ -16,177 +18,22 @@ if (!databaseUrl) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const pool = new Pool({ connectionString: databaseUrl })
|
||||
export const pool = new Pool({ connectionString: databaseUrl })
|
||||
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
|
||||
|
||||
type WidgetRow = {
|
||||
id: string; widget_type: 'document' | Evidence['type']; title: string; content: string
|
||||
config: { kind?: string; date?: string; body?: string[]; fileType?: CaseDocument['fileType']; metadata?: Record<string, string>; [key: string]: unknown }; source_widget_id?: string; source_region_key?: string
|
||||
event_date?: string; published_at?: string; x?: number; y?: number; width?: number; sort_order: number; asset_id?: string
|
||||
original_name?: string; mime_type?: string; byte_size?: number
|
||||
}
|
||||
const levels = createLevelRepository(pool, editingEnabled)
|
||||
|
||||
function wantsEdit(req: express.Request) {
|
||||
return editingEnabled && req.query.edit === '1'
|
||||
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
|
||||
}
|
||||
function slug(value: unknown, fallback: string) {
|
||||
return String(value || fallback).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
async function assembleLevel(levelId: string, playthroughId = `default:${levelId}`, authorMode = false): Promise<CaseState | null> {
|
||||
const levelResult = await pool.query<{ id: string; title: string; subtitle: string; status: string }>(
|
||||
'SELECT id, title, subtitle, status FROM osint.levels WHERE id = $1', [levelId],
|
||||
)
|
||||
const level = levelResult.rows[0]
|
||||
if (!level) return null
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO osint.playthroughs (id, level_id) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`,
|
||||
[playthroughId, levelId],
|
||||
)
|
||||
const [widgetsResult, regionsResult, authoredConnections, authoredRelations, playthroughResult, generatedResult, playerConnections, playerRelations, stateResult, relationStateResult] = await Promise.all([
|
||||
pool.query<WidgetRow>(`SELECT w.id, w.widget_type, w.title, w.content, w.config, w.source_widget_id, w.source_region_key,
|
||||
w.event_date::text, w.published_at::text, w.x, w.y, w.width, w.sort_order, w.asset_id, a.original_name, a.mime_type, a.byte_size
|
||||
FROM osint.widgets w LEFT JOIN osint.assets a ON a.id = w.asset_id
|
||||
WHERE w.level_id = $1 ORDER BY w.sort_order, w.id`, [levelId]),
|
||||
pool.query<{ document_widget_id: string; region_key: string; label: string; excerpt: string; event_date?: string }>(
|
||||
`SELECT r.document_widget_id, r.region_key, r.label, r.excerpt, r.event_date::text
|
||||
FROM osint.widget_regions r JOIN osint.widgets w ON w.id = r.document_widget_id
|
||||
WHERE w.level_id = $1 ORDER BY r.sort_order, r.id`, [levelId]),
|
||||
pool.query<{ id: string; from_widget_id: string; to_widget_id: string }>(
|
||||
'SELECT id, from_widget_id, to_widget_id FROM osint.level_connections WHERE level_id = $1', [levelId]),
|
||||
pool.query<{ id: string; from_widget_id: string; to_widget_id: string; relation_type: string; sort_order: number; config: Record<string, unknown> }>(
|
||||
`SELECT id, from_widget_id, to_widget_id, relation_type, sort_order, config FROM osint.widget_relations
|
||||
WHERE level_id = $1 ORDER BY sort_order, id`, [levelId]),
|
||||
pool.query<{ viewport: CaseState['viewport']; updated_at: Date }>(
|
||||
'SELECT viewport, updated_at FROM osint.playthroughs WHERE id = $1', [playthroughId]),
|
||||
pool.query<WidgetRow>(`SELECT id, widget_type, title, content, config, source_widget_id,
|
||||
source_region_key, event_date::text, x, y, width, 0 AS sort_order
|
||||
FROM osint.playthrough_widgets WHERE playthrough_id = $1 ORDER BY created_at, id`, [playthroughId]),
|
||||
pool.query<{ id: string; from_widget_id: string; to_widget_id: string }>(
|
||||
'SELECT id, from_widget_id, to_widget_id FROM osint.playthrough_connections WHERE playthrough_id = $1', [playthroughId]),
|
||||
pool.query<{ id: string; from_widget_id: string; to_widget_id: string; relation_type: string; sort_order: number; config: Record<string, unknown> }>(
|
||||
`SELECT id, from_widget_id, to_widget_id, relation_type, sort_order, config FROM osint.playthrough_widget_relations
|
||||
WHERE playthrough_id = $1 ORDER BY sort_order, id`, [playthroughId]),
|
||||
pool.query<{ widget_id: string; x: number; y: number; width: number; hidden: boolean; config: Record<string, unknown> }>(
|
||||
'SELECT widget_id, x, y, width, hidden, config FROM osint.playthrough_widget_state WHERE playthrough_id = $1', [playthroughId]),
|
||||
pool.query<{ relation_id: string; config: Record<string, unknown> }>(
|
||||
'SELECT relation_id, config FROM osint.playthrough_widget_relation_state WHERE playthrough_id = $1', [playthroughId]),
|
||||
])
|
||||
|
||||
const stateByWidget = new Map(authorMode ? [] : stateResult.rows.map(row => [row.widget_id, row]))
|
||||
const documents: CaseDocument[] = widgetsResult.rows.filter(w => w.widget_type === 'document').map(w => { const override = stateByWidget.get(w.id)?.config || {}; const publishedAt = String(override.publishedAt || w.published_at || ''); return ({
|
||||
id: w.id, title: String(override.title || w.title), kind: w.config.kind || 'DOCUMENT', date: publishedAt.slice(0, 10) || w.config.date || '', publishedAt: publishedAt || undefined,
|
||||
body: w.config.body || [], fileType: (override.fileType || w.config.fileType || (w.mime_type?.startsWith('image/') ? 'image' : 'file')) as CaseDocument['fileType'], metadata: (override.metadata || w.config.metadata || {}) as Record<string, string>,
|
||||
assetId: w.asset_id, fileName: w.original_name, mimeType: w.mime_type, fileSize: w.byte_size,
|
||||
regions: regionsResult.rows.filter(r => r.document_widget_id === w.id).map(r => ({ id: r.region_key, label: r.label, excerpt: r.excerpt, date: r.event_date })),
|
||||
}) })
|
||||
const relationState = new Map(authorMode ? [] : relationStateResult.rows.map(row => [row.relation_id, row.config]))
|
||||
const containedByFolder = new Map<string, string[]>()
|
||||
for (const relation of [...authoredRelations.rows, ...(authorMode ? [] : playerRelations.rows)]) {
|
||||
if (relation.relation_type !== 'contains') continue
|
||||
containedByFolder.set(relation.from_widget_id, [...(containedByFolder.get(relation.from_widget_id) || []), relation.to_widget_id])
|
||||
}
|
||||
const toEvidence = (w: WidgetRow): Evidence => {
|
||||
const override = stateByWidget.get(w.id)
|
||||
const runtimeConfig = { ...w.config, ...(override?.config || {}) }
|
||||
return { id: w.id, type: w.widget_type as Evidence['type'], title: String(override?.config?.title || w.title), content: String(override?.config?.content ?? w.content), config: runtimeConfig,
|
||||
sourceDocumentId: w.source_widget_id, sourceRegionId: w.source_region_key, eventDate: w.widget_type === 'event' ? w.event_date : undefined,
|
||||
containedDocumentIds: containedByFolder.get(w.id) || (w.source_widget_id ? [w.source_widget_id] : []),
|
||||
x: override?.x ?? w.x ?? 100, y: override?.y ?? w.y ?? 100, width: override?.width ?? w.width ?? 240 }
|
||||
}
|
||||
const authoredEvidence = widgetsResult.rows.filter(w => w.widget_type !== 'document' && !stateByWidget.get(w.id)?.hidden).map(toEvidence)
|
||||
const evidence = [...authoredEvidence, ...(authorMode ? [] : generatedResult.rows.map(toEvidence))]
|
||||
const connections: Connection[] = [...authoredConnections.rows, ...(authorMode ? [] : playerConnections.rows)].map(c => ({
|
||||
id: c.id, fromEvidenceId: c.from_widget_id, toEvidenceId: c.to_widget_id,
|
||||
}))
|
||||
const relations: WidgetRelation[] = [...authoredRelations.rows, ...(authorMode ? [] : playerRelations.rows)].map(relation => ({
|
||||
id: relation.id, fromWidgetId: relation.from_widget_id, toWidgetId: relation.to_widget_id, type: relation.relation_type,
|
||||
sortOrder: relation.sort_order, config: { ...relation.config, ...(relationState.get(relation.id) || {}) },
|
||||
}))
|
||||
const playthrough = playthroughResult.rows[0]
|
||||
return { id: level.id, title: level.title, subtitle: level.subtitle, documents, evidence, relations, connections,
|
||||
viewport: playthrough.viewport, updatedAt: playthrough.updated_at.toISOString(), levelStatus: level.status, editingAllowed: editingEnabled }
|
||||
}
|
||||
|
||||
async function savePlaythrough(client: PoolClient, state: CaseState) {
|
||||
const playthroughId = `default:${state.id}`
|
||||
const authored = await client.query<{ id: string }>('SELECT id FROM osint.widgets WHERE level_id = $1', [state.id])
|
||||
const authoredIds = new Set(authored.rows.map(row => row.id))
|
||||
const authoredRelations = await client.query<{ id: string }>('SELECT id FROM osint.widget_relations WHERE level_id = $1', [state.id])
|
||||
const authoredRelationIds = new Set(authoredRelations.rows.map(row => row.id))
|
||||
await client.query('UPDATE osint.playthroughs SET viewport = $2::jsonb, updated_at = NOW() WHERE id = $1', [playthroughId, JSON.stringify(state.viewport)])
|
||||
await client.query('DELETE FROM osint.playthrough_widget_state WHERE playthrough_id = $1', [playthroughId])
|
||||
await client.query('DELETE FROM osint.playthrough_widget_relation_state WHERE playthrough_id = $1', [playthroughId])
|
||||
await client.query('DELETE FROM osint.playthrough_widget_relations WHERE playthrough_id = $1', [playthroughId])
|
||||
await client.query('DELETE FROM osint.playthrough_widgets WHERE playthrough_id = $1', [playthroughId])
|
||||
for (const document of state.documents) {
|
||||
await client.query(`INSERT INTO osint.playthrough_widget_state (playthrough_id, widget_id, x, y, width, config)
|
||||
VALUES ($1,$2,0,0,0,$3::jsonb)`, [playthroughId, document.id, JSON.stringify({ title: document.title, publishedAt: document.publishedAt || null, fileType: document.fileType, metadata: document.metadata })])
|
||||
}
|
||||
for (const widget of state.evidence) {
|
||||
if (authoredIds.has(widget.id)) {
|
||||
await client.query(`INSERT INTO osint.playthrough_widget_state (playthrough_id, widget_id, x, y, width, config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`, [playthroughId, widget.id, widget.x, widget.y, widget.width, JSON.stringify({ ...(widget.config || {}), title: widget.title, content: widget.content })])
|
||||
} else {
|
||||
await client.query(`INSERT INTO osint.playthrough_widgets
|
||||
(id, playthrough_id, widget_type, title, content, config, source_widget_id, source_region_key, event_date, x, y, width)
|
||||
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12)`, [widget.id, playthroughId, widget.type, widget.title, widget.content, JSON.stringify(widget.config || {}),
|
||||
widget.sourceDocumentId || null, widget.sourceRegionId || null, widget.eventDate || null, widget.x, widget.y, widget.width])
|
||||
}
|
||||
}
|
||||
const fallbackRelations: WidgetRelation[] = state.evidence.flatMap(widget => (widget.containedDocumentIds || []).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index })))
|
||||
for (const relation of (state.relations || fallbackRelations).filter(relation => authoredRelationIds.has(relation.id))) {
|
||||
await client.query(`INSERT INTO osint.playthrough_widget_relation_state (playthrough_id, relation_id, config)
|
||||
VALUES ($1,$2,$3::jsonb)`, [playthroughId, relation.id, JSON.stringify(relation.config || {})])
|
||||
}
|
||||
for (const relation of (state.relations || fallbackRelations).filter(relation => !authoredRelationIds.has(relation.id))) {
|
||||
await client.query(`INSERT INTO osint.playthrough_widget_relations
|
||||
(id, playthrough_id, from_widget_id, to_widget_id, relation_type, sort_order, config)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)`,
|
||||
[relation.id, playthroughId, relation.fromWidgetId, relation.toWidgetId, relation.type, relation.sortOrder || 0, JSON.stringify(relation.config || {})])
|
||||
}
|
||||
await client.query('DELETE FROM osint.playthrough_connections WHERE playthrough_id = $1', [playthroughId])
|
||||
const authoredConnections = await client.query<{ id: string }>('SELECT id FROM osint.level_connections WHERE level_id = $1', [state.id])
|
||||
const authoredConnectionIds = new Set(authoredConnections.rows.map(row => row.id))
|
||||
for (const connection of state.connections.filter(c => !authoredConnectionIds.has(c.id))) {
|
||||
await client.query(`INSERT INTO osint.playthrough_connections (id, playthrough_id, from_widget_id, to_widget_id)
|
||||
VALUES ($1, $2, $3, $4)`, [connection.id, playthroughId, connection.fromEvidenceId, connection.toEvidenceId])
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAuthoredLevel(client: PoolClient, state: CaseState) {
|
||||
await client.query('UPDATE osint.levels SET title = $2, subtitle = $3, updated_at = NOW() WHERE id = $1', [state.id, state.title, state.subtitle])
|
||||
await client.query('DELETE FROM osint.level_connections WHERE level_id = $1', [state.id])
|
||||
await client.query('DELETE FROM osint.widget_relations WHERE level_id = $1', [state.id])
|
||||
await client.query('DELETE FROM osint.widgets WHERE level_id = $1', [state.id])
|
||||
for (const [index, doc] of state.documents.entries()) {
|
||||
await client.query(`INSERT INTO osint.widgets (id, level_id, widget_type, title, config, published_at, asset_id, sort_order)
|
||||
VALUES ($1,$2,'document',$3,$4::jsonb,$5,$6,$7)`, [doc.id, state.id, doc.title, JSON.stringify({ kind: doc.kind, body: doc.body, fileType: doc.fileType, metadata: doc.metadata }), doc.publishedAt || doc.date || null, doc.assetId || null, index])
|
||||
for (const [regionIndex, region] of doc.regions.entries()) {
|
||||
await client.query(`INSERT INTO osint.widget_regions (id, document_widget_id, region_key, label, excerpt, event_date, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [`${doc.id}:${region.id}`, doc.id, region.id, region.label, region.excerpt, region.date || null, regionIndex])
|
||||
}
|
||||
}
|
||||
for (const [index, widget] of state.evidence.entries()) {
|
||||
await client.query(`INSERT INTO osint.widgets
|
||||
(id, level_id, widget_type, title, content, config, source_widget_id, source_region_key, event_date, x, y, width, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12,$13)`, [widget.id, state.id, widget.type, widget.title, widget.content, JSON.stringify(widget.config || {}),
|
||||
widget.sourceDocumentId || null, widget.sourceRegionId || null, widget.eventDate || null, widget.x, widget.y, widget.width, index])
|
||||
}
|
||||
const fallbackRelations: WidgetRelation[] = state.evidence.flatMap(widget => (widget.containedDocumentIds || []).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index })))
|
||||
for (const relation of state.relations || fallbackRelations) {
|
||||
await client.query(`INSERT INTO osint.widget_relations
|
||||
(id, level_id, from_widget_id, to_widget_id, relation_type, sort_order, config)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)`,
|
||||
[relation.id, state.id, relation.fromWidgetId, relation.toWidgetId, relation.type, relation.sortOrder || 0, JSON.stringify(relation.config || {})])
|
||||
}
|
||||
for (const connection of state.connections) {
|
||||
await client.query(`INSERT INTO osint.level_connections (id, level_id, from_widget_id, to_widget_id)
|
||||
VALUES ($1,$2,$3,$4)`, [connection.id, state.id, connection.fromEvidenceId, connection.toEvidenceId])
|
||||
}
|
||||
}
|
||||
|
||||
const app = express()
|
||||
export const app = express()
|
||||
app.disable('x-powered-by')
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || true }))
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || true, credentials: true }))
|
||||
app.use(cookieParser())
|
||||
app.use(authenticateJwt)
|
||||
app.use(express.json({ limit: '2mb' }))
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
@@ -197,26 +44,50 @@ app.get('/api/health', async (_req, res) => {
|
||||
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', schema: 'osint', editingEnabled }) }
|
||||
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
|
||||
})
|
||||
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) }))
|
||||
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
|
||||
const requestedReturn = String(req.query.returnTo || '/')
|
||||
const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
|
||||
res.cookie('auth_token', createDevelopmentAdminToken(), { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 7 * 24 * 60 * 60 * 1000 })
|
||||
res.redirect(returnTo)
|
||||
})
|
||||
app.get('/api/levels', async (_req, res, next) => {
|
||||
try { const result = await pool.query('SELECT id, title, subtitle, status, updated_at AS "updatedAt" FROM osint.levels ORDER BY updated_at DESC'); res.json(result.rows) }
|
||||
try { res.json(await levels.listLevels()) }
|
||||
catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/levels', async (req, res, next) => {
|
||||
app.get('/api/templates', async (_req, res, next) => {
|
||||
try { res.json(await levels.listTemplates()) }
|
||||
catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/templates/:slug/levels', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||
const title = String(req.body?.title || '').trim() || undefined
|
||||
const levelSlug = slug(req.body?.id, `${req.params.slug}-${Date.now()}`)
|
||||
const level = await levels.instantiateTemplate(String(req.params.slug), { id: levelSlug, title, version: req.body?.version })
|
||||
level ? res.status(201).json(level) : res.status(404).json({ error: 'Template version not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/levels', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||
const title = String(req.body?.title || 'Untitled Investigation').trim()
|
||||
const id = String(req.body?.id || `level-${Date.now()}`).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-')
|
||||
await pool.query('INSERT INTO osint.levels (id, title, subtitle) VALUES ($1, $2, $3)', [id, title, String(req.body?.subtitle || '')])
|
||||
const level = await assembleLevel(id, `default:${id}`, true)
|
||||
res.status(201).json(level)
|
||||
const id = slug(req.body?.id, `level-${Date.now()}`)
|
||||
res.status(201).json(await levels.createLevel({ id, title, subtitle: String(req.body?.subtitle || '') }))
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/levels/:id/templates', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||
const name = String(req.body?.name || 'Untitled Template').trim()
|
||||
const templateSlug = slug(req.body?.slug, name)
|
||||
const template = await levels.saveLevelAsTemplate(String(req.params.id), { slug: templateSlug, name })
|
||||
template ? res.status(201).json(template) : res.status(404).json({ error: 'Level not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.get('/api/assets/:id', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query<{ original_name: string; mime_type: string; byte_size: string; content: Buffer }>(
|
||||
'SELECT original_name, mime_type, byte_size, content FROM osint.assets WHERE id = $1', [req.params.id],
|
||||
)
|
||||
const asset = result.rows[0]
|
||||
const asset = await levels.getAsset(req.params.id)
|
||||
if (!asset) return res.status(404).json({ error: 'Asset not found' })
|
||||
const inline = asset.mime_type === 'application/pdf' || asset.mime_type.startsWith('image/') || asset.mime_type.startsWith('text/')
|
||||
res.setHeader('Content-Type', asset.mime_type || 'application/octet-stream')
|
||||
@@ -226,54 +97,34 @@ app.get('/api/assets/:id', async (req, res, next) => {
|
||||
res.send(asset.content)
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => {
|
||||
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||
if (!req.file) return res.status(400).json({ error: 'A file is required' })
|
||||
const client = await pool.connect()
|
||||
app.post('/api/levels/:id/documents', requireAdmin, upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
const level = await client.query('SELECT id FROM osint.levels WHERE id = $1', [req.params.id])
|
||||
if (!level.rows[0]) return res.status(404).json({ error: 'Level not found' })
|
||||
const assetId = `asset-${randomUUID()}`
|
||||
const widgetId = `document-${randomUUID()}`
|
||||
const checksum = createHash('sha256').update(req.file.buffer).digest('hex')
|
||||
const kind = req.file.mimetype === 'application/pdf' ? 'PDF' : req.file.mimetype.startsWith('image/') ? 'IMAGE' : 'FILE'
|
||||
const fileType: CaseDocument['fileType'] = req.file.mimetype.startsWith('image/') ? 'image' : req.file.mimetype === 'application/pdf' ? 'pdf' : 'file'
|
||||
await client.query('BEGIN')
|
||||
await client.query(`INSERT INTO osint.assets (id, level_id, original_name, mime_type, byte_size, content, checksum_sha256)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [assetId, req.params.id, req.file.originalname, req.file.mimetype || 'application/octet-stream', req.file.size, req.file.buffer, checksum])
|
||||
await client.query(`INSERT INTO osint.widgets (id, level_id, widget_type, title, config, asset_id, sort_order)
|
||||
VALUES ($1,$2,'document',$3,$4::jsonb,$5,(SELECT COALESCE(MAX(sort_order),-1)+1 FROM osint.widgets WHERE level_id=$2 AND widget_type='document'))`,
|
||||
[widgetId, req.params.id, req.file.originalname, JSON.stringify({ kind, body: [], fileType, metadata: {} }), assetId])
|
||||
await client.query('UPDATE osint.levels SET updated_at = NOW() WHERE id = $1', [req.params.id])
|
||||
await client.query('COMMIT')
|
||||
res.status(201).json({ id: widgetId, title: req.file.originalname, kind, fileType, metadata: {}, date: '', body: [], regions: [], assetId,
|
||||
fileName: req.file.originalname, mimeType: req.file.mimetype, fileSize: req.file.size })
|
||||
} catch (error) { await client.query('ROLLBACK'); next(error) } finally { client.release() }
|
||||
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||
if (!req.file) return res.status(400).json({ error: 'A file is required' })
|
||||
const document = await levels.uploadDocument(String(req.params.id), req.file)
|
||||
document ? res.status(201).json(document) : res.status(404).json({ error: 'Level not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.get('/api/levels/:id', async (req, res, next) => {
|
||||
try { const level = await assembleLevel(req.params.id, `default:${req.params.id}`, wantsEdit(req)); level ? res.json(level) : res.status(404).json({ error: 'Level not found' }) }
|
||||
catch (error) { next(error) }
|
||||
try {
|
||||
const level = await levels.getLevel(req.params.id, wantsEdit(req))
|
||||
level ? res.json(level) : res.status(404).json({ error: 'Level not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.put('/api/levels/:id', async (req, res, next) => {
|
||||
const state = req.body as CaseState
|
||||
if (!state || state.id !== req.params.id || !Array.isArray(state.evidence) || !Array.isArray(state.connections)) return res.status(400).json({ error: 'Invalid level state' })
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
if (wantsEdit(req)) await saveAuthoredLevel(client, state)
|
||||
else await savePlaythrough(client, state)
|
||||
await client.query('COMMIT'); res.json({ ok: true, mode: wantsEdit(req) ? 'author' : 'play' })
|
||||
} catch (error) { await client.query('ROLLBACK'); next(error) } finally { client.release() }
|
||||
const authorMode = wantsEdit(req)
|
||||
await levels.saveLevel(state, authorMode)
|
||||
res.json({ ok: true, mode: authorMode ? 'author' : 'play' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/levels/:id/reset', async (req, res, next) => {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
const playthroughId = `default:${req.params.id}`
|
||||
await client.query('BEGIN')
|
||||
await client.query('DELETE FROM osint.playthroughs WHERE id = $1', [playthroughId])
|
||||
await client.query('COMMIT')
|
||||
const level = await assembleLevel(req.params.id); level ? res.json(level) : res.status(404).json({ error: 'Level not found' })
|
||||
} catch (error) { await client.query('ROLLBACK'); next(error) } finally { client.release() }
|
||||
const level = await levels.resetLevel(req.params.id)
|
||||
level ? res.json(level) : res.status(404).json({ error: 'Level not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
@@ -285,6 +136,9 @@ app.use((error: unknown, _req: express.Request, res: express.Response, _next: ex
|
||||
const here = path.dirname(fileURLToPath(import.meta.url)); const dist = path.resolve(here, '..', 'dist')
|
||||
if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get('*splat', (_req, res) => res.sendFile(path.join(dist, 'index.html'))) }
|
||||
const port = Number(process.env.PORT || 8787)
|
||||
const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
|
||||
export const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
|
||||
async function shutdown() { server.close(); await pool.end(); process.exit(0) }
|
||||
process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown)
|
||||
if (!process.env.VITEST && process.env.OSINT_MANAGED_SERVER !== 'true') {
|
||||
process.on('SIGTERM', shutdown)
|
||||
process.on('SIGINT', shutdown)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import type { Pool, PoolClient } from 'pg'
|
||||
import type { BriefConcept, CaseDocument, CaseState, Evidence, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from '../src/types.js'
|
||||
import { clearBoard, cloneBoard } from './boardClone.js'
|
||||
|
||||
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
||||
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer }
|
||||
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
|
||||
|
||||
export interface LevelRepository {
|
||||
listLevels(): Promise<unknown[]>
|
||||
createLevel(input: { id: string; title: string; subtitle: string }): Promise<CaseState>
|
||||
listTemplates(): Promise<TemplateSummary[]>
|
||||
instantiateTemplate(templateSlug: string, input: { id: string; title?: string; version?: number }): Promise<CaseState | null>
|
||||
saveLevelAsTemplate(levelId: string, input: { slug: string; name: string }): Promise<TemplateSummary | null>
|
||||
getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null>
|
||||
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
|
||||
resetLevel(levelId: string): Promise<CaseState | null>
|
||||
getAsset(assetId: string): Promise<AssetRecord | null>
|
||||
uploadDocument(levelId: string, file: UploadedDocument): Promise<CaseDocument | null>
|
||||
}
|
||||
|
||||
type LevelRow = {
|
||||
id: string; slug: string; board_id: string; title: string; subtitle: string; status: string
|
||||
viewport_x: number; viewport_y: number; viewport_zoom: number; updated_at: Date
|
||||
source_template_version_id: string | null
|
||||
}
|
||||
type ExhibitRow = {
|
||||
id: string; exhibit_type_id: 'folder' | 'document' | 'note' | 'event' | 'party'; xpos: number; ypos: number; width: number; hidden: boolean
|
||||
title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null
|
||||
asset_id: string | null; published_at: Date | null; occurred_at: Date | null
|
||||
original_name: string | null; mime_type: string | null; byte_size: string | null
|
||||
source_document_id: string | null; source_region_key: string | null
|
||||
party_kind: PartyKind | null; organization_kind: OrganizationKind | null
|
||||
}
|
||||
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
function requireUuid(value: string, label: string) {
|
||||
if (!uuidPattern.test(value)) throw new Error(`${label} must be a UUID`)
|
||||
return value
|
||||
}
|
||||
function timestamp(value: string | undefined) {
|
||||
if (!value) return null
|
||||
const date = new Date(value)
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null
|
||||
}
|
||||
function documentType(document: CaseDocument): SourceFileType {
|
||||
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
|
||||
return allowed.includes(document.fileType) ? document.fileType : 'file'
|
||||
}
|
||||
function documentKind(type: SourceFileType) {
|
||||
return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase()
|
||||
}
|
||||
|
||||
export function createLevelRepository(pool: Pool, editingEnabled: boolean): LevelRepository {
|
||||
async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
|
||||
const result = await client.query<LevelRow>(`SELECT id, slug, board_id, title, subtitle, status,
|
||||
viewport_x, viewport_y, viewport_zoom, updated_at, source_template_version_id
|
||||
FROM osint.levels WHERE slug = $1${lock ? ' FOR UPDATE' : ''}`, [slug])
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
|
||||
const level = await findLevel(pool, slug)
|
||||
if (!level) return null
|
||||
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
|
||||
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, timelineResult] = await Promise.all([
|
||||
pool.query<ExhibitRow>(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden,
|
||||
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title,
|
||||
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
|
||||
f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at,
|
||||
p.party_kind, op.organization_kind,
|
||||
a.original_name, a.mime_type, a.byte_size,
|
||||
s.source_document_exhibit_id AS source_document_id, sr.region_key AS source_region_key
|
||||
FROM osint.exhibits e
|
||||
LEFT JOIN osint.folder_exhibits f ON f.exhibit_id = e.id
|
||||
LEFT JOIN osint.document_exhibits d ON d.exhibit_id = e.id
|
||||
LEFT JOIN osint.note_exhibits n ON n.exhibit_id = e.id
|
||||
LEFT JOIN osint.event_exhibits ev ON ev.exhibit_id = e.id
|
||||
LEFT JOIN osint.party_exhibits p ON p.exhibit_id = e.id
|
||||
LEFT JOIN osint.organization_parties op ON op.exhibit_id = e.id
|
||||
LEFT JOIN osint.assets a ON a.id = d.asset_id
|
||||
LEFT JOIN osint.exhibit_sources s ON s.exhibit_id = e.id
|
||||
LEFT JOIN osint.document_regions sr ON sr.id = s.source_region_id
|
||||
WHERE e.board_id = $1 ORDER BY e.z_index, e.created_at, e.id`, [level.board_id]),
|
||||
pool.query<{ document_exhibit_id: string; content: string }>(
|
||||
`SELECT b.document_exhibit_id, b.content FROM osint.document_content_blocks b
|
||||
JOIN osint.exhibits e ON e.id = b.document_exhibit_id WHERE e.board_id = $1 ORDER BY b.document_exhibit_id, b.sort_order`, [level.board_id]),
|
||||
pool.query<{ document_exhibit_id: string; region_key: string; label: string; excerpt: string; occurred_at: Date | null }>(
|
||||
`SELECT r.document_exhibit_id, r.region_key, r.label, r.excerpt, r.occurred_at FROM osint.document_regions r
|
||||
JOIN osint.exhibits e ON e.id = r.document_exhibit_id WHERE e.board_id = $1 ORDER BY r.document_exhibit_id, r.sort_order`, [level.board_id]),
|
||||
pool.query<{ folder_exhibit_id: string; child_exhibit_id: string; sort_order: number; xpos: number; ypos: number }>(
|
||||
`SELECT m.folder_exhibit_id, m.child_exhibit_id, m.sort_order, child.xpos, child.ypos
|
||||
FROM osint.folder_memberships m JOIN osint.exhibits child ON child.id = m.child_exhibit_id
|
||||
WHERE m.board_id = $1 ORDER BY m.sort_order, m.child_exhibit_id`, [level.board_id]),
|
||||
pool.query<{ id: string; from_exhibit_id: string; to_exhibit_id: string; label: string | null; tightness: number; tag_style: 'luggage' | 'compact'; tag_position_percent: number; tag_lateral_offset: number }>(
|
||||
`SELECT id, from_exhibit_id, to_exhibit_id, label, tightness, tag_style, tag_position_percent, tag_lateral_offset FROM osint.exhibit_connections WHERE board_id = $1 ORDER BY created_at, id`, [level.board_id]),
|
||||
pool.query<{ exhibit_id: string; field_key: string; value: string }>(
|
||||
`SELECT v.exhibit_id, f.field_key, v.value FROM osint.exhibit_metadata_text_values v
|
||||
JOIN osint.metadata_fields f ON f.id = v.field_id WHERE f.board_id = $1 ORDER BY f.field_key`, [level.board_id]),
|
||||
pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string }>(
|
||||
`SELECT event_exhibit_id,evidence_exhibit_id FROM osint.event_evidence WHERE board_id=$1
|
||||
ORDER BY event_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
|
||||
pool.query<{ party_exhibit_id: string; alias: string }>(
|
||||
`SELECT a.party_exhibit_id,a.alias FROM osint.party_aliases a JOIN osint.exhibits e ON e.id=a.party_exhibit_id
|
||||
WHERE e.board_id=$1 ORDER BY a.party_exhibit_id,a.sort_order,a.id`, [level.board_id]),
|
||||
pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string }>(
|
||||
`SELECT party_exhibit_id,evidence_exhibit_id FROM osint.party_evidence WHERE board_id=$1
|
||||
ORDER BY party_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
|
||||
pool.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [level.board_id]),
|
||||
pool.query<{ id: string; label: string; context_text: string; expected_party_kind: PartyKind | null; resolved_party_exhibit_id: string | null }>(
|
||||
`SELECT id,label,context_text,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts
|
||||
WHERE board_id=$1 ORDER BY sort_order,id`, [level.board_id]),
|
||||
pool.query<{ range_start: string; range_end: string }>(
|
||||
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]),
|
||||
])
|
||||
|
||||
const blocks = new Map<string, string[]>()
|
||||
for (const row of blocksResult.rows) blocks.set(row.document_exhibit_id, [...(blocks.get(row.document_exhibit_id) || []), row.content])
|
||||
const regions = new Map<string, CaseDocument['regions']>()
|
||||
for (const row of regionsResult.rows) regions.set(row.document_exhibit_id, [...(regions.get(row.document_exhibit_id) || []), {
|
||||
id: row.region_key, label: row.label, excerpt: row.excerpt, date: row.occurred_at?.toISOString(),
|
||||
}])
|
||||
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 })
|
||||
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[]>()
|
||||
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[]>()
|
||||
for (const row of partyEvidenceResult.rows) partyEvidence.set(row.party_exhibit_id, [...(partyEvidence.get(row.party_exhibit_id) || []), row.evidence_exhibit_id])
|
||||
const relations: WidgetRelation[] = membershipsResult.rows.map(row => {
|
||||
contained.set(row.folder_exhibit_id, [...(contained.get(row.folder_exhibit_id) || []), row.child_exhibit_id])
|
||||
return { id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromWidgetId: row.folder_exhibit_id,
|
||||
toWidgetId: row.child_exhibit_id, type: 'contains', sortOrder: row.sort_order, config: { x: row.xpos, y: row.ypos } }
|
||||
})
|
||||
const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
|
||||
const type = row.document_type_id || 'file'
|
||||
const publishedAt = row.published_at?.toISOString()
|
||||
return { id: row.id, title: row.title, kind: documentKind(type), date: publishedAt?.slice(0, 10) || '', publishedAt,
|
||||
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
|
||||
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
|
||||
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
|
||||
})
|
||||
const evidence: Evidence[] = exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden).map(row => ({
|
||||
id: row.id, type: row.exhibit_type_id as Evidence['type'], title: row.title, content: row.content,
|
||||
sourceDocumentId: row.source_document_id || undefined, sourceRegionId: row.source_region_key || undefined,
|
||||
eventDate: row.occurred_at?.toISOString(), x: row.xpos, y: row.ypos, width: row.width,
|
||||
supportingEvidenceIds: eventEvidence.get(row.id) || [],
|
||||
partyKind: row.party_kind || undefined, organizationKind: row.organization_kind || undefined,
|
||||
aliases: aliases.get(row.id) || [], relatedEvidenceIds: partyEvidence.get(row.id) || [],
|
||||
config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.id) || [],
|
||||
}))
|
||||
const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
|
||||
...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined }))
|
||||
return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations,
|
||||
connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id,
|
||||
label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
|
||||
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
|
||||
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
|
||||
timelineRange: timelineResult.rows[0] ? { start: timelineResult.rows[0].range_start, end: timelineResult.rows[0].range_end } : undefined,
|
||||
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
|
||||
sourceTemplateVersionId: level.source_template_version_id || undefined }
|
||||
}
|
||||
|
||||
async function templateSummary(slug: string): Promise<TemplateSummary | null> {
|
||||
const result = await pool.query<{
|
||||
id: string; slug: string; name: string; current_version: number; version_count: number; updated_at: Date
|
||||
}>(`SELECT t.id,t.slug,t.name,current.version AS current_version,COUNT(v.id)::int AS version_count,t.updated_at
|
||||
FROM osint.level_templates t
|
||||
JOIN osint.level_template_versions current ON current.id=t.current_version_id
|
||||
JOIN osint.level_template_versions v ON v.template_id=t.id
|
||||
WHERE t.slug=$1 GROUP BY t.id,current.version`, [slug])
|
||||
const row = result.rows[0]
|
||||
return row ? { id: row.id, slug: row.slug, name: row.name, currentVersion: row.current_version,
|
||||
versionCount: row.version_count, updatedAt: row.updated_at.toISOString() } : null
|
||||
}
|
||||
|
||||
async function replaceBoard(client: PoolClient, level: LevelRow, state: CaseState) {
|
||||
const documentIds = new Set(state.documents.map(document => requireUuid(document.id, 'Document id')))
|
||||
const evidenceIds = new Set(state.evidence.map(exhibit => requireUuid(exhibit.id, 'Exhibit id')))
|
||||
const allIds = [...documentIds, ...evidenceIds]
|
||||
if (new Set(allIds).size !== allIds.length) throw new Error('An id cannot identify both a document and another exhibit')
|
||||
|
||||
const relationList = state.relations || state.evidence.flatMap(exhibit => (exhibit.containedDocumentIds || []).map((documentId, index) => ({
|
||||
id: `contains:${exhibit.id}:${documentId}`, fromWidgetId: exhibit.id, toWidgetId: documentId, type: 'contains', sortOrder: index,
|
||||
})))
|
||||
const positions = new Map<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 }>(
|
||||
'SELECT id,expected_party_kind FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])).rows.map(row => [row.id, row.expected_party_kind]))
|
||||
const existingTimelineResult = await client.query<{ range_start: string; range_end: string }>(
|
||||
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id])
|
||||
const existingTimeline = existingTimelineResult.rows[0]
|
||||
? { start: existingTimelineResult.rows[0].range_start, end: existingTimelineResult.rows[0].range_end }
|
||||
: null
|
||||
|
||||
await client.query(`UPDATE osint.levels SET title=$2, subtitle=$3, viewport_x=$4, viewport_y=$5, viewport_zoom=$6,
|
||||
updated_at=NOW() WHERE id=$1`, [level.id, state.title, state.subtitle, state.viewport.x, state.viewport.y, state.viewport.zoom])
|
||||
await client.query('UPDATE osint.boards SET revision=revision+1, updated_at=NOW() WHERE id=$1', [level.board_id])
|
||||
await client.query('DELETE FROM osint.exhibit_connections WHERE board_id=$1', [level.board_id])
|
||||
await client.query('DELETE FROM osint.folder_memberships 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_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.brief_concepts WHERE board_id=$1', [level.board_id])
|
||||
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [level.board_id])
|
||||
await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id])
|
||||
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [level.board_id])
|
||||
for (const table of ['folder_exhibits', 'image_documents', 'note_exhibits', 'event_exhibits', 'person_parties', 'organization_parties', 'party_exhibits', 'document_exhibits']) {
|
||||
await client.query(`DELETE FROM osint.${table} WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)`, [level.board_id])
|
||||
}
|
||||
if (allIds.length) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1 AND NOT (id = ANY($2::uuid[]))', [level.board_id, allIds])
|
||||
else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id])
|
||||
|
||||
for (const [index, document] of state.documents.entries()) {
|
||||
const position = positions.get(document.id) || { x: 100, y: 100 }
|
||||
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
|
||||
VALUES ($1,$2,'document',$3,$4,174,145,$5,FALSE)
|
||||
ON CONFLICT (id) DO UPDATE SET exhibit_type_id='document',xpos=$3,ypos=$4,width=174,height=145,z_index=$5,hidden=FALSE,updated_at=NOW()`,
|
||||
[document.id, level.board_id, position.x, position.y, index])
|
||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at)
|
||||
VALUES ($1,$2,$3,$4,$5)`, [document.id, documentType(document), document.assetId || null, document.title, timestamp(document.publishedAt || document.date)])
|
||||
if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
|
||||
for (const [sortOrder, content] of document.body.entries()) await client.query(
|
||||
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content])
|
||||
for (const [sortOrder, region] of document.regions.entries()) await client.query(
|
||||
`INSERT INTO osint.document_regions (id,document_exhibit_id,region_key,label,excerpt,occurred_at,sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [randomUUID(), document.id, region.id, region.label, region.excerpt, timestamp(region.date), sortOrder])
|
||||
}
|
||||
for (const [index, exhibit] of state.evidence.entries()) {
|
||||
const type = exhibit.type === 'evidence' ? 'folder' : exhibit.type
|
||||
const canonicalType = type === 'folder' || type === 'note' || type === 'event' || type === 'party' ? type : 'note'
|
||||
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,160,$7,FALSE)
|
||||
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=160,z_index=$7,hidden=FALSE,updated_at=NOW()`,
|
||||
[exhibit.id, level.board_id, canonicalType, exhibit.x, exhibit.y, exhibit.width, state.documents.length + index])
|
||||
if (canonicalType === 'folder') await client.query(
|
||||
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
|
||||
[exhibit.id, exhibit.title, exhibit.content, Boolean(exhibit.config?.open)])
|
||||
if (canonicalType === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content])
|
||||
if (canonicalType === 'event') await client.query(
|
||||
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
|
||||
[exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate)])
|
||||
if (canonicalType === 'party') {
|
||||
const partyKind: PartyKind = exhibit.partyKind === 'organization' ? 'organization' : 'person'
|
||||
await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)',
|
||||
[exhibit.id, partyKind, exhibit.title, exhibit.content])
|
||||
if (partyKind === 'person') await client.query('INSERT INTO osint.person_parties (exhibit_id) VALUES ($1)', [exhibit.id])
|
||||
else await client.query('INSERT INTO osint.organization_parties (exhibit_id,organization_kind) VALUES ($1,$2)',
|
||||
[exhibit.id, exhibit.organizationKind || 'business'])
|
||||
for (const [sortOrder, alias] of (exhibit.aliases || []).filter(Boolean).entries()) await client.query(
|
||||
'INSERT INTO osint.party_aliases (id,party_exhibit_id,alias,sort_order) VALUES ($1,$2,$3,$4)', [randomUUID(), exhibit.id, alias, sortOrder])
|
||||
}
|
||||
}
|
||||
|
||||
for (const relation of relationList.filter(item => item.type === 'contains')) {
|
||||
if (!evidenceIds.has(relation.fromWidgetId) || !allIds.includes(relation.toWidgetId)) throw new Error('Folder membership references an unknown exhibit')
|
||||
await client.query(`INSERT INTO osint.folder_memberships (board_id,folder_exhibit_id,child_exhibit_id,sort_order)
|
||||
VALUES ($1,$2,$3,$4)`, [level.board_id, relation.fromWidgetId, relation.toWidgetId, relation.sortOrder || 0])
|
||||
}
|
||||
for (const event of state.evidence.filter(item => item.type === 'event')) {
|
||||
for (const [sortOrder, evidenceId] of (event.supportingEvidenceIds || []).entries()) {
|
||||
if (evidenceId === event.id || !allIds.includes(evidenceId)) throw new Error('Event evidence references an unknown or identical exhibit')
|
||||
await client.query(`INSERT INTO osint.event_evidence
|
||||
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
|
||||
[level.board_id, event.id, evidenceId, sortOrder])
|
||||
}
|
||||
}
|
||||
for (const party of state.evidence.filter(item => item.type === 'party')) {
|
||||
for (const [sortOrder, evidenceId] of (party.relatedEvidenceIds || []).entries()) {
|
||||
if (evidenceId === party.id || !allIds.includes(evidenceId)) throw new Error('Party evidence references an unknown or identical exhibit')
|
||||
await client.query(`INSERT INTO osint.party_evidence
|
||||
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
|
||||
[level.board_id, party.id, evidenceId, sortOrder])
|
||||
}
|
||||
}
|
||||
for (const connection of state.connections) {
|
||||
requireUuid(connection.id, 'Connection id')
|
||||
if (!allIds.includes(connection.fromEvidenceId) || !allIds.includes(connection.toEvidenceId) || connection.fromEvidenceId === connection.toEvidenceId) throw new Error('Connection references an unknown or identical exhibit')
|
||||
const tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65))))
|
||||
const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage'
|
||||
const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50))))
|
||||
const lateralLimit = Math.round(10 + (100 - tightness) * .6)
|
||||
const tagOffset = Math.max(-lateralLimit, Math.min(lateralLimit, Math.round(Number(connection.tagOffset ?? 0))))
|
||||
await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset)
|
||||
VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset])
|
||||
}
|
||||
for (const exhibit of state.evidence.filter(item => item.sourceDocumentId)) {
|
||||
if (!documentIds.has(exhibit.sourceDocumentId!)) throw new Error('Exhibit source references an unknown document')
|
||||
let regionId: string | null = null
|
||||
if (exhibit.sourceRegionId) {
|
||||
const region = await client.query<{ id: string }>(
|
||||
'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [exhibit.sourceDocumentId, exhibit.sourceRegionId])
|
||||
regionId = region.rows[0]?.id || null
|
||||
}
|
||||
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>()
|
||||
for (const document of state.documents) for (const key of Object.keys(document.metadata || {})) {
|
||||
if (!fields.has(key)) {
|
||||
const fieldId = randomUUID(); fields.set(key, fieldId)
|
||||
await client.query(`INSERT INTO osint.metadata_fields (id,board_id,field_key,label,value_type) VALUES ($1,$2,$3,$3,'text')`, [fieldId, level.board_id, key])
|
||||
}
|
||||
await client.query('INSERT INTO osint.exhibit_metadata_text_values (exhibit_id,field_id,value) VALUES ($1,$2,$3)',
|
||||
[document.id, fields.get(key), document.metadata[key]])
|
||||
}
|
||||
const brief = state.brief || { body: '', concepts: [] }
|
||||
const savedTimeline = state.timelineRange === undefined ? existingTimeline : state.timelineRange
|
||||
if (savedTimeline) {
|
||||
const start = timestamp(savedTimeline.start)
|
||||
const end = timestamp(savedTimeline.end)
|
||||
if (!start || !end || Date.parse(end) <= Date.parse(start)) throw new Error('Timeline end must be after timeline start')
|
||||
await client.query('INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)',
|
||||
[level.board_id, savedTimeline.start, savedTimeline.end])
|
||||
}
|
||||
await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || ''])
|
||||
for (const [sortOrder, concept] of brief.concepts.entries()) {
|
||||
requireUuid(concept.id, 'Brief concept id')
|
||||
if (concept.resolvedPartyExhibitId && !evidenceIds.has(concept.resolvedPartyExhibitId)) throw new Error('Concept resolution references an unknown party')
|
||||
const expected = concept.expectedPartyKind || expectedConceptKinds.get(concept.id) || null
|
||||
await client.query(`INSERT INTO osint.brief_concepts
|
||||
(id,board_id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[concept.id, level.board_id, concept.label, concept.context, sortOrder, expected, concept.resolvedPartyExhibitId || null])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async listLevels() {
|
||||
const result = await pool.query(`SELECT slug AS id, title, subtitle, status, updated_at AS "updatedAt"
|
||||
FROM osint.levels ORDER BY updated_at DESC`)
|
||||
return result.rows
|
||||
},
|
||||
async createLevel(input) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const boardId = randomUUID(); const levelId = randomUUID()
|
||||
await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')`, [boardId])
|
||||
await client.query(`INSERT INTO osint.levels (id,slug,board_id,title,subtitle) VALUES ($1,$2,$3,$4,$5)`,
|
||||
[levelId, input.id, boardId, input.title, input.subtitle])
|
||||
await client.query('COMMIT')
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
return (await assembleLevel(input.id))!
|
||||
},
|
||||
async listTemplates() {
|
||||
const result = await pool.query<{
|
||||
id: string; slug: string; name: string; current_version: number; version_count: number; updated_at: Date
|
||||
}>(`SELECT t.id,t.slug,t.name,current.version AS current_version,COUNT(v.id)::int AS version_count,t.updated_at
|
||||
FROM osint.level_templates t
|
||||
JOIN osint.level_template_versions current ON current.id=t.current_version_id
|
||||
JOIN osint.level_template_versions v ON v.template_id=t.id
|
||||
GROUP BY t.id,current.version ORDER BY t.updated_at DESC,t.slug`)
|
||||
return result.rows.map(row => ({ id: row.id, slug: row.slug, name: row.name, currentVersion: row.current_version,
|
||||
versionCount: row.version_count, updatedAt: row.updated_at.toISOString() }))
|
||||
},
|
||||
async instantiateTemplate(templateSlug, input) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const version = await client.query<{ id: string; board_id: string; title: string; subtitle: string }>(
|
||||
`SELECT v.id,v.board_id,v.title,v.subtitle FROM osint.level_templates t
|
||||
JOIN osint.level_template_versions v ON v.template_id=t.id
|
||||
WHERE t.slug=$1 AND (($2::int IS NULL AND v.id=t.current_version_id) OR v.version=$2)
|
||||
FOR SHARE OF t,v`, [templateSlug, input.version ?? null])
|
||||
const source = version.rows[0]
|
||||
if (!source) { await client.query('ROLLBACK'); return null }
|
||||
const boardId = randomUUID(); const levelId = randomUUID()
|
||||
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,source_template_version_id,title,subtitle) VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||
[levelId, input.id, boardId, source.id, input.title || source.title, source.subtitle])
|
||||
await cloneBoard(client, source.board_id, boardId)
|
||||
await client.query('COMMIT')
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
return assembleLevel(input.id)
|
||||
},
|
||||
async saveLevelAsTemplate(levelId, input) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const level = await findLevel(client, levelId, true)
|
||||
if (!level) { await client.query('ROLLBACK'); return null }
|
||||
await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [input.slug])
|
||||
let template = await client.query<{ id: string }>('SELECT id FROM osint.level_templates WHERE slug=$1 FOR UPDATE', [input.slug])
|
||||
let templateId = template.rows[0]?.id
|
||||
if (!templateId) {
|
||||
templateId = randomUUID()
|
||||
await client.query('INSERT INTO osint.level_templates (id,slug,name) VALUES ($1,$2,$3)', [templateId, input.slug, input.name])
|
||||
template = await client.query<{ id: string }>('SELECT id FROM osint.level_templates WHERE id=$1 FOR UPDATE', [templateId])
|
||||
}
|
||||
const versionResult = await client.query<{ version: number }>(
|
||||
'SELECT COALESCE(MAX(version),0)::int+1 AS version FROM osint.level_template_versions WHERE template_id=$1', [templateId])
|
||||
const version = versionResult.rows[0].version
|
||||
const boardId = randomUUID(); const versionId = randomUUID()
|
||||
await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'template_version')`, [boardId])
|
||||
await client.query(`INSERT INTO osint.level_template_versions
|
||||
(id,template_id,version,board_id,title,subtitle,created_from_level_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[versionId, templateId, version, boardId, level.title, level.subtitle, level.id])
|
||||
await cloneBoard(client, level.board_id, boardId)
|
||||
await client.query('UPDATE osint.level_templates SET name=$2,current_version_id=$3,updated_at=NOW() WHERE id=$1',
|
||||
[templateId, input.name, versionId])
|
||||
await client.query('COMMIT')
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
return templateSummary(input.slug)
|
||||
},
|
||||
getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) },
|
||||
async saveLevel(state) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const level = await findLevel(client, state.id, true)
|
||||
if (!level) throw new Error('Level not found')
|
||||
await replaceBoard(client, level, state)
|
||||
await client.query('COMMIT')
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
async resetLevel(levelId) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const level = await findLevel(client, levelId, true)
|
||||
if (!level) { await client.query('ROLLBACK'); return null }
|
||||
if (level.source_template_version_id) {
|
||||
const version = await client.query<{ board_id: string; title: string; subtitle: string }>(
|
||||
'SELECT board_id,title,subtitle FROM osint.level_template_versions WHERE id=$1 FOR SHARE', [level.source_template_version_id])
|
||||
const source = version.rows[0]
|
||||
if (!source) throw new Error('Source template version not found')
|
||||
await clearBoard(client, level.board_id)
|
||||
await cloneBoard(client, source.board_id, level.board_id)
|
||||
await client.query(`UPDATE osint.levels SET title=$2,subtitle=$3,viewport_x=0,viewport_y=28,viewport_zoom=0.7,updated_at=NOW()
|
||||
WHERE id=$1`, [level.id, source.title, source.subtitle])
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
return assembleLevel(levelId)
|
||||
},
|
||||
async getAsset(assetId) {
|
||||
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])
|
||||
return result.rows[0] || null
|
||||
},
|
||||
async uploadDocument(levelId, file) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const level = await findLevel(client, levelId, true)
|
||||
if (!level) { await client.query('ROLLBACK'); return null }
|
||||
const candidateAssetId = randomUUID(); const exhibitId = randomUUID()
|
||||
const checksum = createHash('sha256').update(file.buffer).digest('hex')
|
||||
const asset = await client.query<{ id: string }>(`INSERT INTO osint.assets
|
||||
(id,original_name,mime_type,byte_size,content,checksum_sha256) VALUES ($1,$2,$3,$4,$5,$6)
|
||||
ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`,
|
||||
[candidateAssetId, file.originalname, file.mimetype || 'application/octet-stream', file.size, file.buffer, checksum])
|
||||
const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file'
|
||||
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
|
||||
VALUES ($1,$2,'document',100,100,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id])
|
||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
|
||||
[exhibitId, fileType, asset.rows[0].id, file.originalname])
|
||||
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
|
||||
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
|
||||
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
|
||||
await client.query('COMMIT')
|
||||
return { id: exhibitId, title: file.originalname, kind: documentKind(fileType), fileType, metadata: {}, date: '', body: [], regions: [],
|
||||
assetId: asset.rows[0].id, fileName: file.originalname, mimeType: file.mimetype, fileSize: file.size }
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
},
|
||||
}
|
||||
}
|
||||
+2
-40
@@ -1,11 +1,8 @@
|
||||
import 'dotenv/config'
|
||||
import { createHash } from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import pg from 'pg'
|
||||
import { runMigrations } from './migrations.js'
|
||||
|
||||
const { Client } = pg
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
if (!databaseUrl) {
|
||||
console.error('DATABASE_URL is required')
|
||||
@@ -13,39 +10,4 @@ if (!databaseUrl) {
|
||||
}
|
||||
|
||||
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
||||
const client = new Client({ connectionString: databaseUrl })
|
||||
await client.connect()
|
||||
|
||||
try {
|
||||
await client.query('CREATE SCHEMA IF NOT EXISTS osint')
|
||||
await client.query(`CREATE TABLE IF NOT EXISTS osint.schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`)
|
||||
|
||||
const files = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).sort()
|
||||
for (const name of files) {
|
||||
const sql = await fs.readFile(path.join(migrationsDir, name), 'utf8')
|
||||
const checksum = createHash('sha256').update(sql).digest('hex')
|
||||
const existing = await client.query<{ checksum: string }>('SELECT checksum FROM osint.schema_migrations WHERE name = $1', [name])
|
||||
if (existing.rows[0]) {
|
||||
if (existing.rows[0].checksum !== checksum) throw new Error(`Applied migration was modified: ${name}`)
|
||||
console.log(`skip ${name}`)
|
||||
continue
|
||||
}
|
||||
await client.query('BEGIN')
|
||||
try {
|
||||
await client.query(sql)
|
||||
await client.query('INSERT INTO osint.schema_migrations (name, checksum) VALUES ($1, $2)', [name, checksum])
|
||||
await client.query('COMMIT')
|
||||
console.log(`apply ${name}`)
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
console.log('OSINT migrations complete.')
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
await runMigrations(databaseUrl, migrationsDir)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import pg from 'pg'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { runMigrations } from './migrations.js'
|
||||
|
||||
const { Client } = pg
|
||||
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
|
||||
const suite = baseDatabaseUrl ? describe : describe.skip
|
||||
const databaseName = `osint_test_${process.pid}_${Date.now()}`
|
||||
let adminClient: InstanceType<typeof Client>
|
||||
let testDatabaseUrl = ''
|
||||
|
||||
suite('PostgreSQL migrations', () => {
|
||||
beforeAll(async () => {
|
||||
const adminUrl = new URL(baseDatabaseUrl!)
|
||||
adminUrl.pathname = '/postgres'
|
||||
adminClient = new Client({ connectionString: adminUrl.toString() })
|
||||
await adminClient.connect()
|
||||
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
|
||||
const testUrl = new URL(baseDatabaseUrl!)
|
||||
testUrl.pathname = `/${databaseName}`
|
||||
testDatabaseUrl = testUrl.toString()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (!adminClient) return
|
||||
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
|
||||
await adminClient.end()
|
||||
})
|
||||
|
||||
it('applies every migration transactionally and is idempotent', async () => {
|
||||
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
||||
const firstRun: string[] = []
|
||||
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
|
||||
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(12)
|
||||
|
||||
const client = new Client({ connectionString: testDatabaseUrl })
|
||||
await client.connect()
|
||||
const tables = await client.query<{ table_name: string }>(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'osint'`)
|
||||
const tableNames = tables.rows.map(row => row.table_name)
|
||||
expect(tableNames).toEqual(expect.arrayContaining([
|
||||
'boards', 'levels', 'level_templates', 'level_template_versions', 'exhibits', 'folder_exhibits',
|
||||
'document_exhibits', 'folder_memberships', 'exhibit_connections', 'metadata_fields', 'assets', 'schema_migrations',
|
||||
'party_exhibits', 'person_parties', 'organization_parties', 'brief_concepts', 'level_briefs',
|
||||
'board_timeline_settings',
|
||||
]))
|
||||
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'playthroughs']))
|
||||
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
|
||||
expect(ledger.rows[0].count).toBe('12')
|
||||
const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`)
|
||||
expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset']))
|
||||
const eventOccurrence = await client.query<{ is_nullable: string }>(`SELECT is_nullable FROM information_schema.columns WHERE table_schema='osint' AND table_name='event_exhibits' AND column_name='occurred_at'`)
|
||||
expect(eventOccurrence.rows[0].is_nullable).toBe('YES')
|
||||
await client.end()
|
||||
|
||||
const secondRun: string[] = []
|
||||
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
|
||||
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(12)
|
||||
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import pg from 'pg'
|
||||
|
||||
const { Client } = pg
|
||||
|
||||
export async function runMigrations(databaseUrl: string, migrationsDir: string, log: (message: string) => void = console.log) {
|
||||
const client = new Client({ connectionString: databaseUrl })
|
||||
await client.connect()
|
||||
try {
|
||||
await client.query('CREATE SCHEMA IF NOT EXISTS osint')
|
||||
await client.query(`CREATE TABLE IF NOT EXISTS osint.schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`)
|
||||
|
||||
const files = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).sort()
|
||||
for (const name of files) {
|
||||
const sql = await fs.readFile(path.join(migrationsDir, name), 'utf8')
|
||||
const checksum = createHash('sha256').update(sql).digest('hex')
|
||||
const existing = await client.query<{ checksum: string }>('SELECT checksum FROM osint.schema_migrations WHERE name = $1', [name])
|
||||
if (existing.rows[0]) {
|
||||
if (existing.rows[0].checksum !== checksum) throw new Error(`Applied migration was modified: ${name}`)
|
||||
log(`skip ${name}`)
|
||||
continue
|
||||
}
|
||||
await client.query('BEGIN')
|
||||
try {
|
||||
await client.query(sql)
|
||||
await client.query('INSERT INTO osint.schema_migrations (name, checksum) VALUES ($1, $2)', [name, checksum])
|
||||
await client.query('COMMIT')
|
||||
log(`apply ${name}`)
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
log('OSINT migrations complete.')
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
}
|
||||
+681
-105
@@ -1,54 +1,39 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BookOpen, CalendarClock, ChevronRight, CircleHelp, FileText, Folder, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from './types'
|
||||
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import type { BriefConcept, CaseDocument, CaseState, Connection, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types'
|
||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, relationPosition, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||
import { documentWidget, exhibitWidget } from './exhibitRegistry'
|
||||
|
||||
const BOARD_W = 2400
|
||||
const BOARD_H = 1500
|
||||
const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
|
||||
{ value: 'image', label: 'Image' }, { value: 'pdf', label: 'PDF' }, { value: 'web_capture', label: 'Web capture' },
|
||||
{ value: 'email', label: 'Email' }, { value: 'article', label: 'Article' }, { value: 'filing', label: 'Company filing' },
|
||||
{ value: 'price_list', label: 'Price list' }, { value: 'text', label: 'Text document' }, { value: 'file', label: 'Generic file' },
|
||||
]
|
||||
'image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file',
|
||||
].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label }))
|
||||
|
||||
function uid(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` }
|
||||
function uid(_prefix: string) { return crypto.randomUUID() }
|
||||
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
||||
function documentSearchText(document: CaseDocument) {
|
||||
return [document.title, document.kind, document.date, document.publishedAt, document.fileName, document.mimeType,
|
||||
...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]),
|
||||
...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase()
|
||||
}
|
||||
function connectionPoint(item: Evidence) {
|
||||
return item.type === 'note' ? { x: item.x + 54, y: item.y + 12 } : { x: item.x + item.width / 2, y: item.y + 68 }
|
||||
return exhibitWidget(item.type).connectionPoint(item)
|
||||
}
|
||||
|
||||
type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; evidenceId?: string; documentId?: string }
|
||||
|
||||
function normalizeCase(state: CaseState): CaseState {
|
||||
const relations = Array.isArray(state.relations) ? state.relations : state.evidence.flatMap(widget => (widget.containedDocumentIds || (widget.sourceDocumentId ? [widget.sourceDocumentId] : [])).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index })))
|
||||
const membership = new Map<string, string[]>()
|
||||
for (const relation of relations.filter(relation => relation.type === 'contains').sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))) membership.set(relation.fromWidgetId, [...(membership.get(relation.fromWidgetId) || []), relation.toWidgetId])
|
||||
return { ...state, relations,
|
||||
documents: state.documents.map(document => ({ ...document, fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'), metadata: document.metadata || {} })),
|
||||
evidence: state.evidence.map(widget => ({ ...widget, type: widget.type === 'evidence' ? 'folder' : widget.type, config: widget.config || {}, containedDocumentIds: membership.get(widget.id) || [] })) }
|
||||
}
|
||||
|
||||
function containedIds(state: CaseState, widgetId: string) {
|
||||
return state.relations.filter(relation => relation.type === 'contains' && relation.fromWidgetId === widgetId).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)).map(relation => relation.toWidgetId)
|
||||
}
|
||||
|
||||
function folderIsOpen(folder: Evidence) { return folder.config?.open === true }
|
||||
function relationPosition(state: CaseState, relation: WidgetRelation) {
|
||||
const folder = state.evidence.find(widget => widget.id === relation.fromWidgetId)
|
||||
const order = relation.sortOrder || 0
|
||||
const configuredX = Number(relation.config?.x), configuredY = Number(relation.config?.y)
|
||||
return {
|
||||
x: Number.isFinite(configuredX) ? configuredX : (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205,
|
||||
y: Number.isFinite(configuredY) ? configuredY : (folder?.y || 100) - 30 + Math.floor(order / 3) * 185,
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [caseState, setCaseState] = useState<CaseState | null>(null)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [noLevels, setNoLevels] = useState(false)
|
||||
const [openDoc, setOpenDoc] = useState<CaseDocument | null>(null)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [linkFrom, setLinkFrom] = useState<string | null>(null)
|
||||
const [docsOpen, setDocsOpen] = useState(true)
|
||||
const [docsOpen, setDocsOpen] = useState(false)
|
||||
const [documentQuery, setDocumentQuery] = useState('')
|
||||
const [helpOpen, setHelpOpen] = useState(false)
|
||||
const [adminMenuOpen, setAdminMenuOpen] = useState(false)
|
||||
const [status, setStatus] = useState('CONNECTING TO ARCHIVE…')
|
||||
const [clock, setClock] = useState('')
|
||||
const [draggingFiles, setDraggingFiles] = useState(false)
|
||||
@@ -56,13 +41,25 @@ export function App() {
|
||||
const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move')
|
||||
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
|
||||
const [editingFileId, setEditingFileId] = useState<string | null>(null)
|
||||
const [editingEventId, setEditingEventId] = useState<string | null>(null)
|
||||
const [newEventDraft, setNewEventDraft] = useState<Evidence | null>(null)
|
||||
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
|
||||
const [newPartyDraft, setNewPartyDraft] = useState<Evidence | null>(null)
|
||||
const [briefOpen, setBriefOpen] = useState(false)
|
||||
const [editingBrief, setEditingBrief] = useState(false)
|
||||
const [editingTimeline, setEditingTimeline] = useState(false)
|
||||
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
||||
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
|
||||
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
|
||||
const saveTimer = useRef<number | undefined>(undefined)
|
||||
const boardRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const adminMenuRef = useRef<HTMLDivElement>(null)
|
||||
const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1'
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
||||
fetch('/api/levels').then(r => {
|
||||
if (!r.ok) throw new Error('Server unavailable')
|
||||
return r.json()
|
||||
@@ -72,8 +69,10 @@ export function App() {
|
||||
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}${editQuery}`)
|
||||
if (!response.ok) throw new Error('Level unavailable')
|
||||
const data = await response.json()
|
||||
setCaseState(normalizeCase(data)); setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
||||
const data = normalizeCase(await response.json())
|
||||
setCaseState(data)
|
||||
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
|
||||
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
||||
})
|
||||
.catch(() => {
|
||||
const cached = localStorage.getItem('gupi-osint-board:last')
|
||||
@@ -85,6 +84,25 @@ export function App() {
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminMenuOpen) return
|
||||
const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) }
|
||||
document.addEventListener('pointerdown', close)
|
||||
return () => document.removeEventListener('pointerdown', close)
|
||||
}, [adminMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!recentlyCreatedExhibitId) return
|
||||
const timer = window.setTimeout(() => setRecentlyCreatedExhibitId(null), 1400)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [recentlyCreatedExhibitId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!recentlyCreatedConnectionId) return
|
||||
const timer = window.setTimeout(() => setRecentlyCreatedConnectionId(null), 1200)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [recentlyCreatedConnectionId])
|
||||
|
||||
const update = useCallback((fn: (state: CaseState) => CaseState) => {
|
||||
setCaseState(current => {
|
||||
if (!current) return current
|
||||
@@ -120,31 +138,162 @@ export function App() {
|
||||
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 }] }))
|
||||
setOpenDoc(null); setSelected(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
|
||||
setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
|
||||
}
|
||||
|
||||
const addNote = () => {
|
||||
const content = window.prompt('What do you think this evidence means?')?.trim()
|
||||
if (!content || !caseState) return
|
||||
const { viewport } = caseState
|
||||
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom), width: 108 }
|
||||
update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id)
|
||||
const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
|
||||
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...position, width: 108 }
|
||||
update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id)
|
||||
}
|
||||
|
||||
const addEvent = () => {
|
||||
if (!caseState) return
|
||||
const { viewport } = caseState
|
||||
const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 })
|
||||
const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
|
||||
supportingEvidenceIds: [], ...position, width: 270 }
|
||||
setNewEventDraft(event)
|
||||
}
|
||||
|
||||
const addParty = () => {
|
||||
if (!caseState) return
|
||||
const { viewport } = caseState
|
||||
const position = nextOpenBoardPosition(caseState.evidence, {
|
||||
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
|
||||
}, { width: 280 })
|
||||
setNewPartyDraft({ id: uid('party'), type: 'party', partyKind: 'person', title: '', content: '', aliases: [], relatedEvidenceIds: [], ...position, width: 280 })
|
||||
}
|
||||
|
||||
const classifyConcept = (conceptId: string, partyKind: PartyKind) => {
|
||||
if (!caseState) return
|
||||
const concept = caseState.brief.concepts.find(item => item.id === conceptId)
|
||||
if (!concept) return
|
||||
const existingId = concept.resolvedPartyExhibitId
|
||||
const partyId = existingId || uid('party')
|
||||
const { viewport } = caseState
|
||||
const existingParty = caseState.evidence.find(item => item.id === existingId)
|
||||
const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.evidence, {
|
||||
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
|
||||
}, { width: 280 })
|
||||
const party: Evidence = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined,
|
||||
title: concept.label, content: concept.context, aliases: [], relatedEvidenceIds: [],
|
||||
...position, width: 280 }
|
||||
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],
|
||||
brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) },
|
||||
}))
|
||||
setSelected(partyId)
|
||||
setRecentlyCreatedExhibitId(partyId)
|
||||
setStatus(`${partyKind === 'person' ? 'PERSON' : 'ORGANIZATION'} DOSSIER CREATED`)
|
||||
}
|
||||
|
||||
const closeBrief = () => {
|
||||
if (caseState) localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString())
|
||||
setBriefOpen(false)
|
||||
}
|
||||
|
||||
const handleCardClick = (id: string) => {
|
||||
if (!linkFrom) { setSelected(current => current === id ? null : id); return }
|
||||
if (linkFrom !== id && caseState && !caseState.connections.some(c => (c.fromEvidenceId === linkFrom && c.toEvidenceId === id) || (c.fromEvidenceId === id && c.toEvidenceId === linkFrom))) {
|
||||
update(s => ({ ...s, connections: [...s.connections, { id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: id }] }))
|
||||
completeThread(id)
|
||||
}
|
||||
|
||||
const completeThread = (targetId: string) => {
|
||||
if (!linkFrom || !caseState || linkFrom === targetId) return
|
||||
const existing = caseState.connections.find(connection => (connection.fromEvidenceId === linkFrom && connection.toEvidenceId === targetId) || (connection.fromEvidenceId === targetId && connection.toEvidenceId === linkFrom))
|
||||
if (existing) {
|
||||
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
||||
return
|
||||
}
|
||||
setLinkFrom(null); setSelected(id)
|
||||
setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
|
||||
setLinkFrom(null)
|
||||
if (caseState.evidence.some(item => item.id === targetId)) setSelected(targetId)
|
||||
}
|
||||
|
||||
const saveThread = (connection: Connection) => {
|
||||
if (!caseState) return
|
||||
const exists = caseState.connections.some(item => item.id === connection.id)
|
||||
update(state => ({ ...state, connections: exists ? state.connections.map(item => item.id === connection.id ? connection : item) : [...state.connections, connection] }))
|
||||
if (!exists) setRecentlyCreatedConnectionId(connection.id)
|
||||
setThreadDraft(null)
|
||||
setStatus(connection.label ? 'RED THREAD TAGGED' : 'RED THREAD TIGHTENED')
|
||||
}
|
||||
|
||||
const removeThread = (id: string) => {
|
||||
update(state => ({ ...state, connections: state.connections.filter(connection => connection.id !== id) }))
|
||||
setThreadDraft(null)
|
||||
setStatus('RED THREAD REMOVED')
|
||||
}
|
||||
|
||||
const removeExhibit = (id: string) => {
|
||||
update(state => discardExhibit(state, id))
|
||||
setSelected(current => current === id ? null : current)
|
||||
setLinkFrom(current => current === id ? null : current)
|
||||
setEditingFolderId(current => current === id ? null : current)
|
||||
setEditingEventId(current => current === id ? null : current)
|
||||
setEditingPartyId(current => current === id ? null : current)
|
||||
setRecentlyCreatedExhibitId(current => current === id ? null : current)
|
||||
setStatus('EXHIBIT DISCARDED · RELATIONS REMOVED')
|
||||
}
|
||||
|
||||
const toggleThreadTool = () => {
|
||||
if (linkFrom) { setLinkFrom(null); setStatus('RED THREAD CANCELLED') }
|
||||
else if (selected) { setLinkFrom(selected); setStatus('RED THREAD READY · SELECT TARGET') }
|
||||
setBoardTool('move')
|
||||
}
|
||||
|
||||
const reset = async () => {
|
||||
if (!window.confirm('Reset the entire investigation board?')) return
|
||||
if (!caseState?.sourceTemplateVersionId || !window.confirm('Reset this investigation to its original template version?')) return
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(caseState!.id)}/reset`, { method: 'POST' })
|
||||
if (response.ok) { const data = await response.json(); setCaseState(normalizeCase(data)); localStorage.removeItem('gupi-osint-board:last'); setSelected(null); setStatus('CASE RESET') }
|
||||
}
|
||||
|
||||
const saveAsTemplate = async () => {
|
||||
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
|
||||
const name = window.prompt('Template name:', caseState.title)?.trim()
|
||||
if (!name) return
|
||||
window.clearTimeout(saveTimer.current)
|
||||
setStatus('FREEZING TEMPLATE VERSION…')
|
||||
const saved = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}?edit=1`, {
|
||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(caseState),
|
||||
})
|
||||
if (!saved.ok) { setStatus('LEVEL SAVE FAILED'); return }
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/templates?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }),
|
||||
})
|
||||
if (!response.ok) { setStatus('TEMPLATE SAVE FAILED'); return }
|
||||
const template: { slug: string; currentVersion: number } = await response.json()
|
||||
setStatus(`TEMPLATE ${template.slug.toUpperCase()} · VERSION ${template.currentVersion}`)
|
||||
}
|
||||
|
||||
const instantiateTemplate = async () => {
|
||||
if (!requestedEditMode || !caseState?.editingAllowed) return
|
||||
const templatesResponse = await fetch('/api/templates')
|
||||
if (!templatesResponse.ok) { setStatus('TEMPLATE ARCHIVE UNAVAILABLE'); return }
|
||||
const templates: { slug: string; name: string; currentVersion: number }[] = await templatesResponse.json()
|
||||
if (!templates.length) { setStatus('NO TEMPLATES SAVED'); return }
|
||||
const templateSlug = window.prompt(`Template slug:\n${templates.map(item => `${item.slug} (v${item.currentVersion})`).join('\n')}`, templates[0].slug)?.trim()
|
||||
if (!templateSlug) return
|
||||
const title = window.prompt('Name the new investigation:', templates.find(item => item.slug === templateSlug)?.name || 'New Investigation')?.trim()
|
||||
if (!title) return
|
||||
setStatus('CLONING TEMPLATE…')
|
||||
const response = await fetch(`/api/templates/${encodeURIComponent(templateSlug)}/levels?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title }),
|
||||
})
|
||||
if (!response.ok) { setStatus('TEMPLATE CLONE FAILED'); return }
|
||||
const level: CaseState = await response.json()
|
||||
window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`)
|
||||
}
|
||||
|
||||
const enterLevelEditor = () => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
params.set('edit', '1')
|
||||
window.location.assign(`${window.location.pathname}?${params.toString()}`)
|
||||
}
|
||||
|
||||
const uploadFiles = async (files: FileList | File[]) => {
|
||||
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
|
||||
const queue = Array.from(files)
|
||||
@@ -165,10 +314,14 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
if (noLevels) return <EmptyArchive canEdit={requestedEditMode} onCreated={level => { setCaseState(level); setNoLevels(false); window.history.replaceState({}, '', `?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>
|
||||
|
||||
const documentById = new Map(caseState.documents.map(document => [document.id, document]))
|
||||
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
|
||||
const filteredDocuments = normalizedDocumentQuery ? caseState.documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : caseState.documents
|
||||
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
||||
const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId))
|
||||
const temporalItems: TemporalItem[] = [
|
||||
...caseState.evidence.flatMap(folder => folder.type !== 'folder' ? [] : containedIds(caseState, folder.id).flatMap(documentId => {
|
||||
@@ -180,54 +333,191 @@ export function App() {
|
||||
...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 (!b.eventDate) return -1
|
||||
return dateValue(a.eventDate) - dateValue(b.eventDate)
|
||||
})
|
||||
return <main className="desktop">
|
||||
<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>
|
||||
<nav>
|
||||
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => { const firstWidget = temporalItems.find(item => item.evidenceId); if (firstWidget?.evidenceId) focusEvidence(firstWidget.evidenceId) }}>TIMELINE</button><button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||
<button className={docsOpen ? 'active' : ''} onClick={() => setDocsOpen(true)}>EVIDENCE</button>
|
||||
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button>
|
||||
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
||||
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
||||
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
||||
{adminMenuOpen && <div className="admin-menu-items" role="menu">
|
||||
{!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
|
||||
<button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF & CONCEPTS</button>
|
||||
<button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button>
|
||||
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void saveAsTemplate() }}>SAVE AS TEMPLATE</button>
|
||||
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void instantiateTemplate() }}>NEW FROM TEMPLATE</button>
|
||||
</>}
|
||||
</div>}
|
||||
</div>}
|
||||
</nav>
|
||||
<div className="terminal-status"><i /> {status}<span>{clock}</span></div>
|
||||
</header>
|
||||
|
||||
<section className="workspace">
|
||||
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
|
||||
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{caseState.documents.length}</sup></h2></div><button onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
|
||||
<div className="search"><Search size={15}/><span>Search case archive…</span></div>
|
||||
{requestedEditMode && caseState.editingAllowed && <><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="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>
|
||||
<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 = '' }} /></>}
|
||||
<div className="doc-list">
|
||||
{caseState.documents.map((doc, index) => <button className="doc-row" data-temporal-id={`document:${doc.id}`} key={doc.id} onDoubleClick={() => setOpenDoc(doc)} onClick={() => setOpenDoc(doc)}>
|
||||
{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><strong>{doc.title}</strong><span>{doc.kind} · {doc.date}</span></div><ChevronRight size={16}/>
|
||||
</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>}
|
||||
</div>
|
||||
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
|
||||
</aside>
|
||||
|
||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { 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>
|
||||
<Board state={caseState} selected={selected} linkFrom={linkFrom} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} />
|
||||
<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} />
|
||||
{briefOpen && <BriefPanel
|
||||
brief={caseState.brief}
|
||||
parties={caseState.evidence.filter(item => item.type === 'party')}
|
||||
recentlyCreatedExhibitId={recentlyCreatedExhibitId}
|
||||
canEdit={canAuthor}
|
||||
onClose={closeBrief}
|
||||
onEdit={() => setEditingBrief(true)}
|
||||
onClassify={classifyConcept}
|
||||
onNewParty={addParty}
|
||||
onLocate={focusEvidence}
|
||||
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>}
|
||||
{!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{caseState.documents.length}</b></button>}
|
||||
<div className="board-actions">
|
||||
<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>
|
||||
<span />
|
||||
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
|
||||
<button className={linkFrom ? 'active' : ''} disabled={!selected} onClick={() => setLinkFrom(linkFrom ? null : selected)}><Link2 size={17}/> {linkFrom ? 'SELECT TARGET' : 'CONNECT'}</button>
|
||||
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
|
||||
<button onClick={addParty}><UserRound size={17}/> NEW PARTY</button>
|
||||
<button className={`thread-tool ${linkFrom ? 'active' : ''}`} aria-label="Red thread" title={linkFrom ? 'Cancel red thread' : selected ? 'Connect selected exhibit with red thread' : 'Select an exhibit first'} disabled={!selected} onClick={toggleThreadTool}><Link2 size={18}/></button>
|
||||
<span />
|
||||
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.max(.45, s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
|
||||
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
|
||||
<b>{Math.round(caseState.viewport.zoom * 100)}%</b>
|
||||
<button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.min(1.5, s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
|
||||
<button aria-label="Reset" onClick={reset}><RotateCcw size={17}/></button>
|
||||
<button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
|
||||
<button aria-label="Reset" title={caseState.sourceTemplateVersionId ? 'Reset to source template' : 'This level has no source template'} disabled={!caseState.sourceTemplateVersionId} onClick={reset}><RotateCcw size={17}/></button>
|
||||
</div>
|
||||
{draggingFiles && <div className="file-drop-overlay"><div><Upload size={28}/><b>ADD SOURCE DOCUMENTS</b><span>DROP FILES INTO THIS LEVEL</span></div></div>}
|
||||
</div>
|
||||
</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('|')}`} />
|
||||
<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('|')}`}/>
|
||||
<Timeline items={temporalItems} selected={selected} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/>
|
||||
<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) }}/>
|
||||
{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)} />}
|
||||
{editingFolderId && <FolderEditor key={editingFolderId} folder={caseState.evidence.find(widget => widget.id === editingFolderId)!} memberIds={containedIds(caseState, editingFolderId)} documents={caseState.documents} canManageContents={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), relations: [...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...members.map((documentId, index) => { const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId); const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }); return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } })] })); setEditingFolderId(null); setStatus('FOLDER UPDATED') }}/>}
|
||||
{editingFolderId && <FolderEditor
|
||||
key={editingFolderId}
|
||||
folder={caseState.evidence.find(widget => widget.id === editingFolderId)!}
|
||||
memberIds={containedIds(caseState, editingFolderId)}
|
||||
documents={caseState.documents}
|
||||
canManageContents={canAuthor}
|
||||
onClose={() => setEditingFolderId(null)}
|
||||
onSave={(folder, members) => {
|
||||
update(state => ({
|
||||
...state,
|
||||
evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget),
|
||||
relations: [
|
||||
...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id),
|
||||
...members.map((documentId, index) => {
|
||||
const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId)
|
||||
const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index })
|
||||
return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position }
|
||||
}),
|
||||
],
|
||||
}))
|
||||
setEditingFolderId(null)
|
||||
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') }}/>}
|
||||
{editingEventId && <EventEditor
|
||||
key={editingEventId}
|
||||
event={caseState.evidence.find(item => item.id === editingEventId)!}
|
||||
evidence={caseState.evidence}
|
||||
documents={caseState.documents}
|
||||
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') }}
|
||||
/>}
|
||||
{newEventDraft && <EventEditor
|
||||
key={newEventDraft.id}
|
||||
event={newEventDraft}
|
||||
evidence={caseState.evidence}
|
||||
documents={caseState.documents}
|
||||
onClose={() => setNewEventDraft(null)}
|
||||
onSave={event => {
|
||||
update(state => ({ ...state, evidence: [...state.evidence, event] }))
|
||||
setNewEventDraft(null)
|
||||
setSelected(event.id)
|
||||
setRecentlyCreatedExhibitId(event.id)
|
||||
setStatus(event.eventDate ? 'DATED EVENT ADDED' : 'UNDATED EVENT ADDED')
|
||||
}}
|
||||
/>}
|
||||
{editingPartyId && <PartyEditor
|
||||
key={editingPartyId}
|
||||
party={caseState.evidence.find(item => item.id === editingPartyId)!}
|
||||
evidence={caseState.evidence}
|
||||
documents={caseState.documents}
|
||||
onClose={() => setEditingPartyId(null)}
|
||||
onSave={party => {
|
||||
update(state => ({ ...state, evidence: state.evidence.map(item => item.id === party.id ? party : item) }))
|
||||
setEditingPartyId(null)
|
||||
setStatus('PARTY DOSSIER UPDATED')
|
||||
}}
|
||||
/>}
|
||||
{newPartyDraft && <PartyEditor
|
||||
key={newPartyDraft.id}
|
||||
party={newPartyDraft}
|
||||
evidence={caseState.evidence}
|
||||
documents={caseState.documents}
|
||||
creating
|
||||
onClose={() => setNewPartyDraft(null)}
|
||||
onSave={party => {
|
||||
update(state => ({ ...state, evidence: [...state.evidence, party] }))
|
||||
setNewPartyDraft(null)
|
||||
setSelected(party.id)
|
||||
setRecentlyCreatedExhibitId(party.id)
|
||||
setStatus(`${party.partyKind === 'person' ? 'PERSON' : 'ORGANIZATION'} DOSSIER CREATED`)
|
||||
}}
|
||||
/>}
|
||||
{editingBrief && <BriefEditor
|
||||
brief={caseState.brief}
|
||||
onClose={() => setEditingBrief(false)}
|
||||
onSave={brief => {
|
||||
update(state => ({ ...state, brief }))
|
||||
setEditingBrief(false)
|
||||
setStatus('LEVEL BRIEF UPDATED')
|
||||
}}
|
||||
/>}
|
||||
{editingTimeline && <TimelineRangeEditor
|
||||
range={caseState.timelineRange}
|
||||
dates={temporalItems.map(item => item.date)}
|
||||
onClose={() => setEditingTimeline(false)}
|
||||
onSave={timelineRange => {
|
||||
update(state => ({ ...state, timelineRange }))
|
||||
setEditingTimeline(false)
|
||||
setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC')
|
||||
}}
|
||||
/>}
|
||||
{threadDraft && <ThreadEditor
|
||||
key={threadDraft.id}
|
||||
connection={threadDraft}
|
||||
sourceName={caseState.evidence.find(item => item.id === threadDraft.fromEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.fromEvidenceId)?.title || 'Exhibit'}
|
||||
targetName={caseState.evidence.find(item => item.id === threadDraft.toEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.toEvidenceId)?.title || 'Exhibit'}
|
||||
isNew={!caseState.connections.some(item => item.id === threadDraft.id)}
|
||||
onClose={() => setThreadDraft(null)}
|
||||
onSave={saveThread}
|
||||
onRemove={() => removeThread(threadDraft.id)}
|
||||
/>}
|
||||
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
||||
</main>
|
||||
}
|
||||
@@ -247,75 +537,246 @@ 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>
|
||||
}
|
||||
|
||||
function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile }: { state: CaseState; selected: string | null; linkFrom: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void }) {
|
||||
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
||||
function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, 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 }) {
|
||||
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
||||
const suppressClick = useRef(false)
|
||||
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
|
||||
const pinchDistance = useRef<number | null>(null)
|
||||
const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
|
||||
const [expandedThreadTagId, setExpandedThreadTagId] = useState<string | null>(null)
|
||||
const [draggingThreadTagId, setDraggingThreadTagId] = useState<string | null>(null)
|
||||
const [draggingWidget, setDraggingWidget] = useState(false)
|
||||
const [trashActive, setTrashActive] = useState(false)
|
||||
const trashRef = useRef<HTMLDivElement>(null)
|
||||
const trashTarget = useRef(false)
|
||||
const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
|
||||
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
|
||||
const pointForId = (id: string) => {
|
||||
const evidence = byId.get(id)
|
||||
if (evidence) return connectionPoint(evidence)
|
||||
const relation = containmentRelations.find(item => item.toWidgetId === id)
|
||||
const folder = relation ? byId.get(relation.fromWidgetId) : undefined
|
||||
if (!relation || !folder) return undefined
|
||||
const position = relationPosition(state, relation)
|
||||
return folderIsOpen(folder) ? { x: position.x + 87, y: position.y + 72 } : connectionPoint(folder)
|
||||
}
|
||||
useEffect(() => {
|
||||
const board = boardRef.current
|
||||
if (!board) return
|
||||
const handlePinch = (event: WheelEvent) => {
|
||||
if (!event.ctrlKey && !event.metaKey) return
|
||||
const handleWheelZoom = (event: WheelEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.max(.45, Math.min(1.5, s.viewport.zoom - event.deltaY * .006)) } }))
|
||||
if (event.ctrlKey || event.metaKey || event.deltaY === 0) return
|
||||
const bounds = board.getBoundingClientRect()
|
||||
const anchor = { x: event.clientX - bounds.left, y: event.clientY - bounds.top }
|
||||
update(s => ({ ...s, viewport: zoomViewportAt(s.viewport, zoomFromWheel(s.viewport.zoom, event.deltaY), anchor) }))
|
||||
}
|
||||
board.addEventListener('wheel', handlePinch, { passive: false })
|
||||
return () => board.removeEventListener('wheel', handlePinch)
|
||||
board.addEventListener('wheel', handleWheelZoom, { passive: false })
|
||||
return () => board.removeEventListener('wheel', handleWheelZoom)
|
||||
}, [boardRef, update])
|
||||
const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget' | 'relation'; id: string }) => {
|
||||
if ((event.target as HTMLElement).closest('button')) return
|
||||
if (event.pointerType === 'touch') {
|
||||
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||
if (touchPoints.current.size >= 2) {
|
||||
const points = [...touchPoints.current.values()]
|
||||
pinchDistance.current = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
|
||||
drag.current = null
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
const widget = target?.kind === 'widget' ? byId.get(target.id) : undefined
|
||||
const relation = target?.kind === 'relation' ? state.relations.find(candidate => candidate.id === target.id) : undefined
|
||||
const position = relation ? relationPosition(state, relation) : undefined
|
||||
drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? position?.x ?? state.viewport.x, originY: widget?.y ?? position?.y ?? state.viewport.y }
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
setDraggingWidget(target?.kind === 'widget')
|
||||
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ }
|
||||
}
|
||||
const threadTagPointerDown = (event: React.PointerEvent, id: string) => {
|
||||
event.stopPropagation()
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
setDraggingThreadTagId(id)
|
||||
drag.current = { kind: 'thread-tag', id, startX: event.clientX, startY: event.clientY, originX: 0, originY: 0 }
|
||||
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ }
|
||||
}
|
||||
const trackThreadPointer = (event: React.PointerEvent) => {
|
||||
if (linkFrom && boardRef.current) {
|
||||
const bounds = boardRef.current.getBoundingClientRect()
|
||||
setThreadPointer({ x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom })
|
||||
}
|
||||
}
|
||||
const pointerMove = (event: React.PointerEvent) => {
|
||||
trackThreadPointer(event)
|
||||
if (event.pointerType === 'touch' && touchPoints.current.has(event.pointerId)) {
|
||||
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||
if (touchPoints.current.size >= 2) {
|
||||
const points = [...touchPoints.current.values()]
|
||||
const distance = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
|
||||
const previous = pinchDistance.current
|
||||
if (previous && boardRef.current) {
|
||||
const bounds = boardRef.current.getBoundingClientRect()
|
||||
const anchor = { x: (points[0].x + points[1].x) / 2 - bounds.left, y: (points[0].y + points[1].y) / 2 - bounds.top }
|
||||
update(s => ({ ...s, viewport: zoomViewportAt(s.viewport, zoomFromPinch(s.viewport.zoom, previous, distance), anchor) }))
|
||||
}
|
||||
pinchDistance.current = distance
|
||||
suppressClick.current = true
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!drag.current) return
|
||||
const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY
|
||||
if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true
|
||||
if (drag.current.kind === 'widget') update(s => ({ ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, x: drag.current!.originX + dx / s.viewport.zoom, y: drag.current!.originY + dy / s.viewport.zoom } : e) }))
|
||||
else if (drag.current.kind === 'relation') update(s => ({ ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), x: drag.current!.originX + dx / s.viewport.zoom, y: drag.current!.originY + dy / s.viewport.zoom } } : relation) }))
|
||||
else update(s => ({ ...s, viewport: { ...s.viewport, x: drag.current!.originX + dx, y: drag.current!.originY + dy } }))
|
||||
if (drag.current.kind === 'widget') {
|
||||
const bounds = trashRef.current?.getBoundingClientRect()
|
||||
const overTrash = Boolean(bounds && event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom)
|
||||
trashTarget.current = overTrash
|
||||
setTrashActive(overTrash)
|
||||
}
|
||||
if (drag.current.kind === 'thread-tag' && boardRef.current) {
|
||||
if (drag.current.moved) setExpandedThreadTagId(null)
|
||||
const connection = state.connections.find(item => item.id === drag.current!.id)
|
||||
const from = connection ? pointForId(connection.fromEvidenceId) : undefined
|
||||
const to = connection ? pointForId(connection.toEvidenceId) : undefined
|
||||
if (connection && from && to) {
|
||||
const bounds = boardRef.current.getBoundingClientRect()
|
||||
const pointer = { x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom }
|
||||
const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer)
|
||||
update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: placement.lateralOffset } : item) }))
|
||||
}
|
||||
} else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } })
|
||||
else if (drag.current.kind === 'relation') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), ...next } } : relation) } })
|
||||
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) }))
|
||||
}
|
||||
const finishDrag = (event: React.PointerEvent) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
touchPoints.current.delete(event.pointerId)
|
||||
if (touchPoints.current.size < 2) pinchDistance.current = null
|
||||
}
|
||||
const completedDrag = drag.current
|
||||
if (completedDrag) suppressClick.current = Boolean(completedDrag.moved)
|
||||
drag.current = null
|
||||
setDraggingThreadTagId(null)
|
||||
setDraggingWidget(false)
|
||||
setTrashActive(false)
|
||||
if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) onDiscardExhibit(completedDrag.id)
|
||||
trashTarget.current = false
|
||||
}
|
||||
const finishDrag = () => { if (drag.current) suppressClick.current = Boolean(drag.current.moved); drag.current = null }
|
||||
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
|
||||
return <div className={`board-viewport tool-${tool}`} ref={boardRef}
|
||||
onPointerDown={e => { if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } }}
|
||||
onPointerMove={pointerMove} onPointerUp={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
|
||||
const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined
|
||||
return <div className={`board-viewport tool-${tool} ${linkFrom ? 'threading' : ''}`} ref={boardRef}
|
||||
onPointerDown={e => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.thread-tag')) setExpandedThreadTagId(null)
|
||||
const emptyBoardDrag = e.button === 0 && !target.closest('.evidence-card, .source-file-widget, .thread-tag, button')
|
||||
if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1 || emptyBoardDrag) { e.preventDefault(); pointerDown(e) }
|
||||
}}
|
||||
onPointerMoveCapture={trackThreadPointer} onPointerMove={pointerMove} onPointerLeave={() => setThreadPointer(null)} onPointerUp={finishDrag} onPointerCancel={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
|
||||
<div ref={trashRef} className={`board-trash ${draggingWidget ? 'drag-ready' : ''} ${trashActive ? 'active' : ''}`} aria-hidden="true"><Trash2 size={23}/><span><b>DISCARD</b><small>DRAG EXHIBIT HERE</small></span></div>
|
||||
<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>
|
||||
<svg className="connections" width={BOARD_W} height={BOARD_H}>
|
||||
{state.connections.map(c => { const a = byId.get(c.fromEvidenceId), b = byId.get(c.toEvidenceId); if (!a || !b) return null; const p1 = connectionPoint(a), p2 = connectionPoint(b); return <g key={c.id}><path d={`M ${p1.x} ${p1.y} C ${(p1.x+p2.x)/2} ${p1.y}, ${(p1.x+p2.x)/2} ${p2.y}, ${p2.x} ${p2.y}`}/><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.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> })}
|
||||
{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>
|
||||
{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> })}
|
||||
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
|
||||
{state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => {
|
||||
const evidence = byId.get(evidenceId)
|
||||
let target = evidence ? connectionPoint(evidence) : undefined
|
||||
if (!target) {
|
||||
const relation = containmentRelations.find(item => item.toWidgetId === evidenceId)
|
||||
const folder = relation ? byId.get(relation.fromWidgetId) : undefined
|
||||
if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder)
|
||||
}
|
||||
if (!target) return []
|
||||
const origin = connectionPoint(event)
|
||||
return [<line key={`${event.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
|
||||
}))}
|
||||
</svg>
|
||||
<svg className="party-association-lines" width={BOARD_W} height={BOARD_H}>
|
||||
{state.evidence.filter(party => party.type === 'party').flatMap(party => (party.relatedEvidenceIds || []).flatMap(evidenceId => {
|
||||
const evidence = byId.get(evidenceId)
|
||||
let target = evidence ? connectionPoint(evidence) : undefined
|
||||
if (!target) {
|
||||
const relation = containmentRelations.find(item => item.toWidgetId === evidenceId)
|
||||
const folder = relation ? byId.get(relation.fromWidgetId) : undefined
|
||||
if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder)
|
||||
}
|
||||
if (!target) return []
|
||||
const origin = connectionPoint(party)
|
||||
return [<line key={`${party.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
|
||||
}))}
|
||||
</svg>
|
||||
<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}/> })}
|
||||
</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] : [] }); return <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${ev.type} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
|
||||
onPointerDown={e => { e.stopPropagation(); 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() }}
|
||||
{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` }}
|
||||
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) }}
|
||||
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>{ev.type === 'note' ? 'INVESTIGATOR / NOTE' : ev.type === 'folder' ? `EVIDENCE FOLDER / ${containedDocuments.length}` : ev.type.toUpperCase()}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
||||
<div className="card-content"><h3>{ev.title}</h3><p>{ev.content}</p>
|
||||
{ev.eventDate && <time>{ev.eventDate.replaceAll('-', ' / ')}</time>}
|
||||
{ev.type === 'folder' && !folderIsOpen(ev) && <div className="folder-documents">{containedDocuments.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>)}{containedDocuments.length > 3 && <small>+ {containedDocuments.length - 3} MORE FILES</small>}</div>}
|
||||
{ev.type === 'folder' && <div className="folder-actions"><button onClick={() => toggleFolder(ev.id)}>{folderIsOpen(ev) ? <Folder size={12}/> : <FolderOpen size={12}/>} {folderIsOpen(ev) ? 'CLOSE' : 'OPEN'}</button><button onClick={() => onEditFolder(ev.id)}><Pencil size={12}/> EDIT</button></div>}
|
||||
{ev.type !== 'folder' && ev.sourceDocumentId && <button onClick={() => onOpenSource(ev.sourceDocumentId!)}><BookOpen size={13}/> VIEW SOURCE</button>}</div>
|
||||
<header><span>{definition.heading(ev, containedDocuments)}</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}/>
|
||||
</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), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; return <article key={relation.id} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType}`} style={{ left, top }}
|
||||
onPointerDown={event => { event.stopPropagation(); if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'relation', id: relation.id }) }}
|
||||
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag() }} onDoubleClick={() => open && onOpenSource(document.id)}>
|
||||
<header><span>{document.fileType.replaceAll('_', ' ').toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||
<div className="source-file-preview">{document.fileType === 'image' && document.assetId ? <img draggable={false} src={`/api/assets/${encodeURIComponent(document.assetId)}`} alt=""/> : <div><ImageIcon size={35}/><small>{document.kind}</small></div>}</div>
|
||||
{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 }}
|
||||
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 }) }}
|
||||
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)}>
|
||||
<header><span>{definition.label.toUpperCase()}</span><i>{String((relation.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>
|
||||
<strong>{document.title}</strong><time>{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</time>
|
||||
<div className="source-file-actions"><button onClick={() => onOpenSource(document.id)}><BookOpen size={12}/> OPEN</button><button onClick={() => 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> })}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function DocumentLocatorBeam({ documentId, layoutKey }: { documentId: string | null; layoutKey: string }) {
|
||||
const [beam, setBeam] = useState<{ path: string; x: number; y: number } | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
if (!documentId) { setBeam(null); return }
|
||||
let frame = 0
|
||||
let animateUntil = Date.now() + 500
|
||||
const matchingElement = (attribute: 'documentRowId' | 'documentLocatorTarget') => Array.from(document.querySelectorAll<HTMLElement>(attribute === 'documentRowId' ? '[data-document-row-id]' : '[data-document-locator-target]')).find(element => element.dataset[attribute] === documentId)
|
||||
const measure = () => {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(function measureFrame() {
|
||||
const source = matchingElement('documentRowId')
|
||||
const target = matchingElement('documentLocatorTarget')
|
||||
const viewport = document.querySelector<HTMLElement>('.board-viewport')
|
||||
if (!source || !target || !viewport) { setBeam(null); return }
|
||||
const from = source.getBoundingClientRect(), to = target.getBoundingClientRect(), bounds = viewport.getBoundingClientRect()
|
||||
const x1 = Math.min(from.right - 3, bounds.left - 3)
|
||||
const y1 = from.top + from.height / 2
|
||||
const x2 = Math.max(bounds.left + 12, Math.min(bounds.right - 12, to.left + to.width / 2))
|
||||
const y2 = Math.max(bounds.top + 12, Math.min(bounds.bottom - 12, to.top + to.height / 2))
|
||||
const bend = Math.max(70, Math.abs(x2 - x1) * .32)
|
||||
setBeam({ path: `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`, x: x2, y: y2 })
|
||||
if (Date.now() < animateUntil) frame = requestAnimationFrame(measureFrame)
|
||||
})
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
const observed = [matchingElement('documentRowId'), matchingElement('documentLocatorTarget'), document.querySelector<HTMLElement>('.board-viewport')].filter((element): element is HTMLElement => Boolean(element))
|
||||
observed.forEach(element => observer.observe(element))
|
||||
const handleLayoutChange = () => { animateUntil = Date.now() + 500; measure() }
|
||||
window.addEventListener('resize', handleLayoutChange)
|
||||
document.querySelector('.doc-list')?.addEventListener('scroll', handleLayoutChange, { passive: true })
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
observer.disconnect()
|
||||
window.removeEventListener('resize', handleLayoutChange)
|
||||
document.querySelector('.doc-list')?.removeEventListener('scroll', handleLayoutChange)
|
||||
}
|
||||
}, [documentId, layoutKey])
|
||||
if (!beam) return null
|
||||
return <svg className="document-locator-beam" aria-hidden="true">
|
||||
<defs><filter id="document-locator-glow" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="4" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>
|
||||
<path className="document-locator-halo" d={beam.path}/><path className="document-locator-ray" d={beam.path}/><circle cx={beam.x} cy={beam.y} r="8"/><circle className="document-locator-pulse" cx={beam.x} cy={beam.y} r="15"/>
|
||||
</svg>
|
||||
}
|
||||
|
||||
function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey: string }) {
|
||||
const [lines, setLines] = useState<{ id: string; x1: number; y1: number; x2: number; y2: number }[]>([])
|
||||
const itemsKey = items.map(item => `${item.id}:${item.date}`).join('|')
|
||||
@@ -350,20 +811,13 @@ function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey:
|
||||
return <svg className="temporal-links" aria-hidden="true">{lines.map(line => <line key={line.id} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2}/>)}</svg>
|
||||
}
|
||||
|
||||
function dateValue(date: string) {
|
||||
const parsed = Date.parse(date)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function Timeline({ items, selected, onSelect }: { items: TemporalItem[]; selected: string | null; onSelect: (item: TemporalItem) => void }) {
|
||||
const itemYears = items.map(item => Number(item.date.slice(0, 4))).filter(Number.isFinite)
|
||||
let startYear = itemYears.length ? Math.min(...itemYears) : new Date().getFullYear() - 2
|
||||
let endYear = itemYears.length ? Math.max(...itemYears) : startYear + 4
|
||||
if (endYear - startYear < 4) { const missing = 4 - (endYear - startYear); startYear -= Math.floor(missing / 2); endYear += Math.ceil(missing / 2) }
|
||||
const start = Date.UTC(startYear, 0, 1), end = Date.UTC(endYear, 11, 31)
|
||||
const years = Array.from({ length: endYear - startYear + 1 }, (_, index) => startYear + index)
|
||||
const position = (date: string) => Math.max(0, Math.min(100, ((dateValue(date) - start) / (end - start)) * 100))
|
||||
return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><span>{items.length} DATED ITEMS</span></div><div className="timeline-track"><div className="axis"/>{years.map(year => <span className="year" key={year} style={{ left: `${position(`${year}-01-01`)}%` }}>{year}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.evidenceId === 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 Timeline({ items, range, selected, onSelect, onEdit }: { items: TemporalItem[]; range?: TimelineRange | null; selected: string | null; onSelect: (item: TemporalItem) => void; onEdit: () => void }) {
|
||||
const { startYear, endYear, start, end } = timelineRange(items.map(item => item.date), undefined, range || undefined)
|
||||
const position = (date: string) => timelinePositionPercent(date, { start, end })
|
||||
const ticks = range
|
||||
? Array.from({ length: 5 }, (_, index) => { const value = start + (end - start) * index / 4; return { value, label: new Date(value).toISOString().slice(5, 10) } })
|
||||
: Array.from({ length: endYear - startYear + 1 }, (_, index) => { const year = startYear + index; return { value: Date.parse(`${year}-01-01T00:00:00.000Z`), label: String(year) } })
|
||||
return <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>
|
||||
}
|
||||
|
||||
function localDateTime(value?: string) {
|
||||
@@ -375,6 +829,96 @@ function localDateTime(value?: string) {
|
||||
return local.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: TimelineRange | null; dates: string[]; onClose: () => void; onSave: (range: TimelineRange | null) => void }) {
|
||||
const dated = dates.map(date => date.slice(0, 10)).filter(Boolean).sort()
|
||||
const [start, setStart] = useState(range?.start || dated[0] || '')
|
||||
const [end, setEnd] = useState(range?.end || dated[dated.length - 1] || '')
|
||||
const valid = Boolean(start && end && end > start)
|
||||
return <div className="modal-shade"><form className="window timeline-editor" onSubmit={event => { event.preventDefault(); if (valid) onSave({ start, end }) }}>
|
||||
<header><CalendarClock size={16}/><b>Adjust timeline range</b><span/><button type="button" aria-label="Close timeline editor" onClick={onClose}><X size={14}/></button></header>
|
||||
<div><small>TEMPORAL VIEWPORT · BOARD SETTING</small><p>Choose the interval shown across the full timeline. Dated evidence outside it is pinned to the nearest edge.</p>
|
||||
<div className="timeline-range-fields"><label className="field"><span>START DATE</span><input aria-label="Timeline start date" type="date" required value={start} onChange={event => setStart(event.target.value)}/></label><label className="field"><span>END DATE</span><input aria-label="Timeline end date" type="date" required value={end} min={start} onChange={event => setEnd(event.target.value)}/></label></div>
|
||||
<div className="folder-editor-actions"><button type="button" onClick={() => onSave(null)}>USE AUTOMATIC RANGE</button><button className="primary" type="submit" disabled={!valid}>APPLY RANGE</button></div>
|
||||
</div>
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSave, onRemove }: { connection: Connection; sourceName: string; targetName: string; isNew: boolean; onClose: () => void; onSave: (connection: Connection) => void; onRemove: () => void }) {
|
||||
const [label, setLabel] = useState(connection.label || '')
|
||||
const [tightness, setTightness] = useState(connection.tightness ?? 65)
|
||||
const [tagStyle, setTagStyle] = useState<'luggage' | 'compact'>(connection.tagStyle === 'compact' ? 'compact' : 'luggage')
|
||||
const [tagPosition, setTagPosition] = useState(connection.tagPosition ?? 50)
|
||||
const save = (tag = label) => {
|
||||
const lateralLimit = threadTagLateralLimit(tightness)
|
||||
onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle, tagPosition,
|
||||
tagOffset: Math.max(-lateralLimit, Math.min(lateralLimit, connection.tagOffset ?? 0)) })
|
||||
}
|
||||
return <div className="modal-shade"><form className="window thread-editor" onSubmit={event => { event.preventDefault(); save() }}>
|
||||
<header><Link2 size={16}/><b>{isNew ? 'Add relation tag' : 'Edit red thread'}</b><span/><button type="button" aria-label="Close thread editor" onClick={onClose}><X size={14}/></button></header>
|
||||
<div><small>RED THREAD · INVESTIGATOR RELATION</small><div className="thread-endpoints"><b>{sourceName}</b><i/><b>{targetName}</b></div>
|
||||
<p>What does this connection mean? Add a short tag if the thread represents a specific claim.</p>
|
||||
<label className="field"><span>RELATION TAG · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="e.g. Proof Elias is the driver" value={label} onChange={event => setLabel(event.target.value)}/></label>
|
||||
<fieldset className="tag-style-picker"><legend>TAG PRESENTATION</legend><label className={tagStyle === 'luggage' ? 'selected' : ''}><input type="radio" name="tag-style" value="luggage" checked={tagStyle === 'luggage'} onChange={() => setTagStyle('luggage')}/><span className="tag-style-luggage"><i/>LUGGAGE</span><small>Expressive · rotates to read</small></label><label className={tagStyle === 'compact' ? 'selected' : ''}><input type="radio" name="tag-style" value="compact" checked={tagStyle === 'compact'} onChange={() => setTagStyle('compact')}/><span className="tag-style-compact"><i/>COMPACT</span><small>Quiet · less board clutter</small></label></fieldset>
|
||||
<label className="field thread-position-control"><span>TAG POSITION <output>{tagPosition}%</output></span><input aria-label="Tag position" type="range" min="5" max="95" step="1" value={tagPosition} onChange={event => setTagPosition(Number(event.target.value))}/><small>Drag the tag on the board for along-thread position and tension-limited lateral play.</small></label>
|
||||
<label className="field thread-tightness"><span>THREAD TIGHTNESS <output>{tightness}%</output></span><input aria-label="Thread tightness" type="range" min="0" max="100" step="5" value={tightness} onChange={event => setTightness(Number(event.target.value))}/><small><span>SLACK</span><span>TAUT</span></small></label>
|
||||
<div className="folder-editor-actions">{!isNew && <button className="danger" type="button" onClick={onRemove}>REMOVE THREAD</button>}<span/>{isNew && <button type="button" onClick={() => save('')}>SKIP TAG</button>}<button className="primary" type="submit">{isNew ? 'ADD TAG & TIGHTEN' : 'SAVE THREAD'}</button></div>
|
||||
</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 }) {
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
const partyById = new Map(parties.map(party => [party.id, party]))
|
||||
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||
return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {unresolved} UNRESOLVED</small><b>CONCEPT CLASSIFICATION</b></div><span/><button type="button" aria-label={minimized ? 'Restore brief' : 'Minimize brief'} title={minimized ? 'Restore' : 'Minimize'} onDoubleClick={event => event.stopPropagation()} onClick={() => setMinimized(value => !value)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close brief" title="Close" onDoubleClick={event => event.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
|
||||
<p>{brief.body || 'No brief has been authored yet.'}</p>
|
||||
<div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
|
||||
<button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button>
|
||||
{canEdit && <button className="edit-brief" onClick={onEdit}><Pencil size={13}/> EDIT BRIEF & CONCEPTS</button>}
|
||||
<button className="dismiss-brief" onClick={onClose}>{unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
|
||||
</aside>
|
||||
}
|
||||
|
||||
function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) {
|
||||
const [body, setBody] = useState(brief.body)
|
||||
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
|
||||
const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }])
|
||||
return <div className="modal-shade"><form className="window folder-editor brief-editor" onSubmit={submit => { submit.preventDefault(); onSave({ body: body.trim(), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
|
||||
<header><BookOpen size={16}/><b>Edit level brief</b><span/><button type="button" aria-label="Close brief editor" onClick={onClose}><X size={14}/></button></header>
|
||||
<div className="folder-editor-body"><small>AUTHORING · PLAYER CONCEPTS</small>
|
||||
<label className="field"><span>BRIEF</span><textarea aria-label="Level brief" rows={5} value={body} onChange={event => setBody(event.target.value)}/></label>
|
||||
<div className="metadata-heading"><div><b>CONCEPTS TO CLASSIFY</b><small>EXPECTED TYPE IS HIDDEN FROM PLAYERS</small></div><button type="button" onClick={addConcept}><Plus size={13}/> ADD CONCEPT</button></div>
|
||||
<div className="concept-editor-list">{concepts.map(concept => <div className="concept-editor-row" key={concept.id}><input aria-label="Concept name" placeholder="REAL NAME" value={concept.label} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, label: event.target.value } : item))}/><input aria-label="Concept context" placeholder="CONTEXT IN THE BRIEF" value={concept.context} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, context: event.target.value } : item))}/><select aria-label="Expected party type" value={concept.expectedPartyKind || 'person'} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, expectedPartyKind: event.target.value as PartyKind } : item))}><option value="person">Person</option><option value="organization">Organization</option></select><button type="button" aria-label="Remove concept" onClick={() => setConcepts(items => items.filter(item => item.id !== concept.id))}><Trash2 size={13}/></button></div>)}</div>
|
||||
<p className="folder-editor-note">Concepts are names in the brief, not board exhibits. A player turns each concept into a Party exhibit by classifying it.</p>
|
||||
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE BRIEF</button></div>
|
||||
</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 }) {
|
||||
const [name, setName] = useState(party.title)
|
||||
const [summary, setSummary] = useState(party.content)
|
||||
const [partyKind, setPartyKind] = useState<PartyKind>(party.partyKind || 'person')
|
||||
const [organizationKind, setOrganizationKind] = useState<OrganizationKind>(party.organizationKind || 'business')
|
||||
const [aliases, setAliases] = useState((party.aliases || []).join('\n'))
|
||||
const [related, setRelated] = useState(party.relatedEvidenceIds || [])
|
||||
const candidates = [...evidence.filter(item => item.id !== party.id && item.type !== 'party').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })), ...documents.map(item => ({ id: item.id, title: item.title, kind: item.fileType.toUpperCase() }))]
|
||||
const 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 }) }}>
|
||||
<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>
|
||||
{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>}
|
||||
<label className="field"><span>DISPLAY NAME</span><input aria-label="Party name" required value={name} onChange={event => setName(event.target.value)}/></label>
|
||||
<label className="field"><span>DOSSIER SUMMARY</span><textarea aria-label="Party summary" rows={3} value={summary} onChange={event => setSummary(event.target.value)}/></label>
|
||||
{partyKind === 'organization' && <label className="field"><span>ORGANIZATION TYPE</span><select aria-label="Organization type" value={organizationKind} onChange={event => setOrganizationKind(event.target.value as OrganizationKind)}><option value="business">Business</option><option value="public_body">Public body</option><option value="association">Association</option><option value="informal_group">Informal group</option><option value="other">Other</option></select></label>}
|
||||
<label className="field"><span>ALIASES · ONE PER LINE</span><textarea aria-label="Party aliases" rows={2} value={aliases} onChange={event => setAliases(event.target.value)}/></label>
|
||||
<div className="folder-members-heading"><div><b>ASSOCIATED EVIDENCE</b><small>{related.length} LINKED</small></div><span>PARTY DOSSIER</span></div>
|
||||
<div className="folder-members">{candidates.map(candidate => { const included = related.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'ASSOCIATED' : 'NOT LINKED'}</span></div> })}</div>
|
||||
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE DOSSIER</button></div>
|
||||
</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 }) {
|
||||
const [title, setTitle] = useState(folder.title)
|
||||
const [content, setContent] = useState(folder.content)
|
||||
@@ -403,6 +947,39 @@ function FolderEditor({ folder, memberIds, documents, canManageContents, onClose
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function EventEditor({ event, evidence, documents, onClose, onSave }: { event: Evidence; evidence: Evidence[]; documents: CaseDocument[]; onClose: () => void; onSave: (event: Evidence) => void }) {
|
||||
const [title, setTitle] = useState(event.title)
|
||||
const [narrative, setNarrative] = useState(event.content)
|
||||
const [occurredAt, setOccurredAt] = useState(localDateTime(event.eventDate))
|
||||
const [supports, setSupports] = useState(event.supportingEvidenceIds || [])
|
||||
const candidates = [
|
||||
...evidence.filter(item => item.id !== event.id && item.type !== 'event').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })),
|
||||
...documents.map(document => ({ id: document.id, title: document.title, kind: document.fileType.replaceAll('_', ' ').toUpperCase() })),
|
||||
]
|
||||
const toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
|
||||
const submit = (submitEvent: React.FormEvent) => {
|
||||
submitEvent.preventDefault()
|
||||
const eventDate = occurredAt ? new Date(occurredAt).toISOString() : undefined
|
||||
onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate, supportingEvidenceIds: supports })
|
||||
}
|
||||
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>
|
||||
<div className="folder-editor-body">
|
||||
<small>EVENT EXHIBIT · THIS HAPPENED</small>
|
||||
<label className="field"><span>TITLE</span><input aria-label="Event title" value={title} onChange={change => setTitle(change.target.value)}/></label>
|
||||
<label className="field"><span>NARRATIVE</span><textarea aria-label="Event narrative" rows={4} value={narrative} onChange={change => setNarrative(change.target.value)}/></label>
|
||||
<label className="field event-date-field"><span><CalendarClock size={13}/> OCCURRED AT · LOCAL · OPTIONAL</span><span><input aria-label="Occurred at" type="datetime-local" value={occurredAt} onChange={change => setOccurredAt(change.target.value)}/><button type="button" disabled={!occurredAt} onClick={() => setOccurredAt('')}>CLEAR DATE</button></span></label>
|
||||
<div className="folder-members-heading"><div><b>SUPPORTING EXHIBITS</b><small>{supports.length} CITED</small></div><span>NORMALIZED EVENT EVIDENCE</span></div>
|
||||
<div className="folder-members event-support-list">
|
||||
{candidates.length === 0 && <p>ADD SOURCE MATERIAL OR NOTES BEFORE CITING EVIDENCE.</p>}
|
||||
{candidates.map(candidate => { const included = supports.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'SUPPORTS EVENT' : 'NOT CITED'}</span></div> })}
|
||||
</div>
|
||||
<p className="folder-editor-note">An event is an investigator assertion. Leave occurrence time blank when it is unknown; creation time is never used for timeline placement.</p>
|
||||
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE EVENT</button></div>
|
||||
</div>
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onClose: () => void; onSave: (document: CaseDocument) => void }) {
|
||||
const [title, setTitle] = useState(document.title)
|
||||
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
||||
@@ -449,9 +1026,8 @@ function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocum
|
||||
|
||||
function DocumentAsset({ doc }: { doc: CaseDocument }) {
|
||||
const source = `/api/assets/${encodeURIComponent(doc.assetId!)}`
|
||||
if (doc.mimeType?.startsWith('image/')) return <img className="document-image" src={source} alt={doc.fileName || doc.title}/>
|
||||
if (doc.mimeType === 'application/pdf' || doc.mimeType?.startsWith('text/')) return <iframe className="document-frame" src={source} title={doc.fileName || doc.title} sandbox="allow-same-origin"/>
|
||||
return <div className="unsupported-file"><FileText size={42}/><b>{doc.fileName || doc.title}</b><span>{doc.mimeType || 'Unknown file type'} · {doc.fileSize ? `${Math.ceil(doc.fileSize / 1024)} KB` : ''}</span><a href={source} download={doc.fileName}>DOWNLOAD ORIGINAL</a></div>
|
||||
const Asset = documentWidget(doc.fileType).Asset
|
||||
return <Asset document={doc} source={source}/>
|
||||
}
|
||||
|
||||
function Help({ onClose }: { onClose: () => void }) { return <div className="modal-shade"><section className="window help"><header><CircleHelp size={16}/><b>Field Manual</b><span/><button onClick={onClose}><X size={14}/></button></header><div><small>GU-NET QUICK START</small><h2>Reconstruct what happened.</h2><ol><li>Open a case document.</li><li>Extract the highlighted clue.</li><li>Drag evidence into meaningful groups.</li><li>Select a card, choose Connect, then select its target.</li><li>Use dated markers to move through the case.</li></ol><p>The system will not announce your conclusion. Make it visible.</p><button className="primary" onClick={onClose}>BEGIN INVESTIGATION</button></div></section></div> }
|
||||
function Help({ onClose }: { onClose: () => void }) { return <div className="modal-shade"><section className="window help"><header><CircleHelp size={16}/><b>Field Manual</b><span/><button onClick={onClose}><X size={14}/></button></header><div><small>GU-NET QUICK START</small><h2>Reconstruct what happened.</h2><ol><li>Open a case document.</li><li>Extract the highlighted clue.</li><li>Drag evidence into meaningful groups.</li><li>Select an exhibit, choose the red-thread tool, then select its target.</li><li>Tag the thread with the claim it represents.</li><li>Use dated markers to move through the case.</li></ol><p>The system will not announce your conclusion. Make it visible.</p><button className="primary" onClick={onClose}>BEGIN INVESTIGATION</button></div></section></div> }
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CaseState, Evidence, WidgetRelation } from './types'
|
||||
import {
|
||||
MAX_BOARD_ZOOM,
|
||||
MIN_BOARD_ZOOM,
|
||||
clampBoardZoom,
|
||||
containedIds,
|
||||
discardExhibit,
|
||||
folderIsOpen,
|
||||
moveBoardPoint,
|
||||
nextOpenBoardPosition,
|
||||
normalizeCase,
|
||||
panViewport,
|
||||
relationPosition,
|
||||
projectThreadTag,
|
||||
threadCurve,
|
||||
threadTagLateralLimit,
|
||||
threadTagPlacement,
|
||||
timelinePositionPercent,
|
||||
timelineRange,
|
||||
zoomFromWheel,
|
||||
zoomFromPinch,
|
||||
zoomViewportAt,
|
||||
} from './boardDomain'
|
||||
|
||||
describe('red thread geometry', () => {
|
||||
it('turns tightness into a taut or visibly sagging bezier curve', () => {
|
||||
const taut = threadCurve({ x: 0, y: 0 }, { x: 120, y: 60 }, 100)
|
||||
const slack = threadCurve({ x: 0, y: 0 }, { x: 120, y: 60 }, 0)
|
||||
expect(taut.midpoint).toEqual({ x: 60, y: 30 })
|
||||
expect(slack.midpoint.y).toBeGreaterThan(taut.midpoint.y)
|
||||
expect(slack.path).toMatch(/^M 0 0 C /)
|
||||
})
|
||||
|
||||
it('clamps tightness to its normalized percentage range', () => {
|
||||
expect(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 200)).toEqual(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 100))
|
||||
})
|
||||
|
||||
it('places and projects tags by percentage along the curve', () => {
|
||||
const from = { x: 0, y: 0 }, to = { x: 200, y: 0 }
|
||||
expect(threadTagPlacement(from, to, 100, 25, 0)).toMatchObject({ x: 50, y: 0, positionPercent: 25 })
|
||||
expect(projectThreadTag(from, to, 100, { x: 150, y: 8 })).toMatchObject({ positionPercent: 75, lateralOffset: 8 })
|
||||
})
|
||||
|
||||
it('reduces lateral tag travel as the thread becomes taut', () => {
|
||||
expect(threadTagLateralLimit(0)).toBe(70)
|
||||
expect(threadTagLateralLimit(100)).toBe(10)
|
||||
expect(threadTagPlacement({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50, 100).lateralOffset).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
const folder: Evidence = {
|
||||
id: 'folder-1',
|
||||
type: 'folder',
|
||||
title: 'Folder',
|
||||
content: '',
|
||||
x: 200,
|
||||
y: 300,
|
||||
width: 260,
|
||||
config: { open: false },
|
||||
}
|
||||
|
||||
const state: CaseState = {
|
||||
brief: { body: '', concepts: [] },
|
||||
id: 'test-level',
|
||||
title: 'Test',
|
||||
subtitle: '',
|
||||
documents: [],
|
||||
evidence: [folder],
|
||||
relations: [],
|
||||
connections: [],
|
||||
viewport: { x: 10, y: 20, zoom: 0.5 },
|
||||
}
|
||||
|
||||
describe('board coordinate math', () => {
|
||||
it('moves board objects by screen distance divided by zoom', () => {
|
||||
expect(moveBoardPoint({ x: 100, y: 80 }, { x: 50, y: -25 }, 0.5)).toEqual({ x: 200, y: 30 })
|
||||
expect(moveBoardPoint({ x: 100, y: 80 }, { x: 50, y: -25 }, 1.25)).toEqual({ x: 140, y: 60 })
|
||||
})
|
||||
|
||||
it('rejects invalid zoom during coordinate conversion', () => {
|
||||
expect(() => moveBoardPoint({ x: 0, y: 0 }, { x: 10, y: 10 }, 0)).toThrow(RangeError)
|
||||
})
|
||||
|
||||
it('pans in screen coordinates without scaling the delta', () => {
|
||||
expect(panViewport(state.viewport, { x: 25, y: -10 })).toEqual({ x: 35, y: 10, zoom: 0.5 })
|
||||
})
|
||||
|
||||
it('clamps toolbar, wheel, and touch pinch zoom to the same limits', () => {
|
||||
expect(clampBoardZoom(-10)).toBe(MIN_BOARD_ZOOM)
|
||||
expect(clampBoardZoom(10)).toBe(MAX_BOARD_ZOOM)
|
||||
expect(zoomFromWheel(1, 10_000)).toBe(MIN_BOARD_ZOOM)
|
||||
expect(zoomFromWheel(1, -10_000)).toBe(MAX_BOARD_ZOOM)
|
||||
expect(zoomFromWheel(1, 100)).toBeCloseTo(0.85)
|
||||
expect(zoomFromPinch(1, 100, 125)).toBeCloseTo(1.25)
|
||||
expect(zoomFromPinch(1, 100, 50)).toBeCloseTo(0.5)
|
||||
expect(zoomFromPinch(1, 0, 120)).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the board point beneath the cursor fixed while zooming', () => {
|
||||
const anchor = { x: 110, y: 120 }
|
||||
const zoomed = zoomViewportAt(state.viewport, 1, anchor)
|
||||
expect(zoomed).toEqual({ x: -90, y: -80, zoom: 1 })
|
||||
expect(zoomed.x + 200 * zoomed.zoom).toBe(anchor.x)
|
||||
expect(zoomed.y + 200 * zoomed.zoom).toBe(anchor.y)
|
||||
expect(zoomViewportAt(zoomed, .5, anchor)).toEqual(state.viewport)
|
||||
})
|
||||
|
||||
it('places new exhibits in deterministic open slots', () => {
|
||||
const preferred = { x: 500, y: 400 }
|
||||
expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred)
|
||||
expect(nextOpenBoardPosition([{ x: 500, y: 400, width: 280 }], preferred, { width: 280 })).toEqual({ x: 826, y: 400 })
|
||||
expect(nextOpenBoardPosition([
|
||||
{ x: 500, y: 400, width: 280 },
|
||||
{ x: 826, y: 400, width: 280 },
|
||||
], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeline projection', () => {
|
||||
it('creates at least a four-year range around a single year', () => {
|
||||
const range = timelineRange(['2021-03-12'])
|
||||
expect(range.endYear - range.startYear).toBe(4)
|
||||
expect(range.startYear).toBeLessThanOrEqual(2021)
|
||||
expect(range.endYear).toBeGreaterThanOrEqual(2021)
|
||||
})
|
||||
|
||||
it('uses all supplied years when the evidence spans a wider range', () => {
|
||||
expect(timelineRange(['2012-01-01', '2024-06-01'])).toMatchObject({ startYear: 2012, endYear: 2024 })
|
||||
})
|
||||
|
||||
it('projects dates proportionally and clamps dates outside the range', () => {
|
||||
const range = timelineRange(['2020-01-01', '2024-12-31'])
|
||||
expect(timelinePositionPercent('2010-01-01', range)).toBe(0)
|
||||
expect(timelinePositionPercent('2030-01-01', range)).toBe(100)
|
||||
expect(timelinePositionPercent('2022-07-02', range)).toBeGreaterThan(49)
|
||||
expect(timelinePositionPercent('2022-07-02', range)).toBeLessThan(51)
|
||||
})
|
||||
|
||||
it('uses a valid configured date range without automatic year padding', () => {
|
||||
const range = timelineRange(['1987-01-08', '1987-10-24'], 2026, { start: '1987-10-01', end: '1987-10-31' })
|
||||
expect(new Date(range.start).toISOString()).toBe('1987-10-01T00:00:00.000Z')
|
||||
expect(new Date(range.end).toISOString()).toBe('1987-10-31T23:59:59.999Z')
|
||||
expect(timelinePositionPercent('1987-10-16', range)).toBeGreaterThan(48)
|
||||
expect(timelinePositionPercent('1987-10-16', range)).toBeLessThan(52)
|
||||
})
|
||||
})
|
||||
|
||||
describe('folder domain behavior', () => {
|
||||
const relations: WidgetRelation[] = [
|
||||
{ id: 'later', fromWidgetId: folder.id, toWidgetId: 'doc-2', type: 'contains', sortOrder: 2 },
|
||||
{ id: 'other', fromWidgetId: 'folder-2', toWidgetId: 'doc-x', type: 'contains', sortOrder: 0 },
|
||||
{ id: 'first', fromWidgetId: folder.id, toWidgetId: 'doc-1', type: 'contains', sortOrder: 0 },
|
||||
]
|
||||
|
||||
it('orders and scopes contained documents by their normalized relations', () => {
|
||||
expect(containedIds({ ...state, relations }, folder.id)).toEqual(['doc-1', 'doc-2'])
|
||||
})
|
||||
|
||||
it('only treats an explicit boolean true as open', () => {
|
||||
expect(folderIsOpen(folder)).toBe(false)
|
||||
expect(folderIsOpen({ ...folder, config: { open: true } })).toBe(true)
|
||||
expect(folderIsOpen({ ...folder, config: { open: 'true' } })).toBe(false)
|
||||
})
|
||||
|
||||
it('retains a configured expanded file position', () => {
|
||||
const relation = { ...relations[0], config: { x: 720, y: 415 } }
|
||||
expect(relationPosition({ ...state, relations }, relation)).toEqual({ x: 720, y: 415 })
|
||||
})
|
||||
|
||||
it('derives a deterministic position when a relation has not been moved', () => {
|
||||
expect(relationPosition({ ...state, relations }, relations[2])).toEqual({ x: 550, y: 270 })
|
||||
expect(relationPosition({ ...state, relations }, relations[0])).toEqual({ x: 960, y: 270 })
|
||||
})
|
||||
|
||||
it('normalizes legacy containment without changing source ownership', () => {
|
||||
const legacy = {
|
||||
...state,
|
||||
documents: [{ id: 'doc-1', title: 'Image', kind: 'IMAGE', date: '', body: [], regions: [], mimeType: 'image/png' }],
|
||||
evidence: [{ ...folder, type: 'evidence', sourceDocumentId: 'doc-1', containedDocumentIds: ['doc-1'], config: undefined }],
|
||||
relations: undefined,
|
||||
} as unknown as CaseState
|
||||
const normalized = normalizeCase(legacy)
|
||||
expect(normalized.evidence[0]).toMatchObject({ type: 'folder', config: {}, containedDocumentIds: ['doc-1'] })
|
||||
expect(normalized.documents[0]).toMatchObject({ fileType: 'image', metadata: {} })
|
||||
expect(normalized.relations).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exhibit disposal', () => {
|
||||
it('removes an exhibit and every graph reference while retaining source documents', () => {
|
||||
const note = { ...folder, id: 'note-1', type: 'note' as const, title: 'Working note' }
|
||||
const event = { ...folder, id: 'event-1', type: 'event' as const, supportingEvidenceIds: [note.id, 'doc-1'] }
|
||||
const party = { ...folder, id: 'party-1', type: 'party' as const, relatedEvidenceIds: [note.id] }
|
||||
const discarded = discardExhibit({
|
||||
...state,
|
||||
documents: [{ id: 'doc-1', title: 'Source', kind: 'TEXT', date: '', body: [], regions: [], fileType: 'text', metadata: {} }],
|
||||
evidence: [folder, note, event, party],
|
||||
relations: [{ id: 'nested', fromWidgetId: folder.id, toWidgetId: note.id, type: 'contains' }],
|
||||
connections: [{ id: 'thread', fromEvidenceId: note.id, toEvidenceId: party.id }],
|
||||
brief: { body: '', concepts: [{ id: 'concept-1', label: 'Unknown', context: '', resolvedPartyExhibitId: note.id }] },
|
||||
}, note.id)
|
||||
|
||||
expect(discarded.documents).toHaveLength(1)
|
||||
expect(discarded.evidence.map(exhibit => exhibit.id)).not.toContain(note.id)
|
||||
expect(discarded.relations).toEqual([])
|
||||
expect(discarded.connections).toEqual([])
|
||||
expect(discarded.evidence.find(exhibit => exhibit.id === event.id)?.supportingEvidenceIds).toEqual(['doc-1'])
|
||||
expect(discarded.evidence.find(exhibit => exhibit.id === party.id)?.relatedEvidenceIds).toEqual([])
|
||||
expect(discarded.brief.concepts[0].resolvedPartyExhibitId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { CaseState, Evidence, TimelineRange, Viewport, WidgetRelation } from './types'
|
||||
|
||||
export interface BoardPoint { x: number; y: number }
|
||||
|
||||
function threadControls(from: BoardPoint, to: BoardPoint, tightness = 65) {
|
||||
const tautness = Math.max(0, Math.min(100, Number(tightness) || 0)) / 100
|
||||
const distance = Math.hypot(to.x - from.x, to.y - from.y)
|
||||
const sag = (1 - tautness) * Math.min(190, Math.max(45, distance * .28))
|
||||
const c1 = { x: from.x + (to.x - from.x) / 3, y: from.y + (to.y - from.y) / 3 + sag }
|
||||
const c2 = { x: from.x + (to.x - from.x) * 2 / 3, y: from.y + (to.y - from.y) * 2 / 3 + sag }
|
||||
return { c1, c2 }
|
||||
}
|
||||
|
||||
function cubicPoint(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardPoint, t: number) {
|
||||
const inverse = 1 - t
|
||||
return {
|
||||
x: inverse ** 3 * from.x + 3 * inverse ** 2 * t * c1.x + 3 * inverse * t ** 2 * c2.x + t ** 3 * to.x,
|
||||
y: inverse ** 3 * from.y + 3 * inverse ** 2 * t * c1.y + 3 * inverse * t ** 2 * c2.y + t ** 3 * to.y,
|
||||
}
|
||||
}
|
||||
|
||||
function cubicTangent(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardPoint, t: number) {
|
||||
const inverse = 1 - t
|
||||
return {
|
||||
x: 3 * inverse ** 2 * (c1.x - from.x) + 6 * inverse * t * (c2.x - c1.x) + 3 * t ** 2 * (to.x - c2.x),
|
||||
y: 3 * inverse ** 2 * (c1.y - from.y) + 6 * inverse * t * (c2.y - c1.y) + 3 * t ** 2 * (to.y - c2.y),
|
||||
}
|
||||
}
|
||||
|
||||
export function threadTagLateralLimit(tightness = 65) {
|
||||
const normalized = Math.max(0, Math.min(100, Number(tightness) || 0))
|
||||
return Math.round(10 + (100 - normalized) * .6)
|
||||
}
|
||||
|
||||
export function threadTagPlacement(from: BoardPoint, to: BoardPoint, tightness = 65, positionPercent = 50, lateralOffset = 0) {
|
||||
const { c1, c2 } = threadControls(from, to, tightness)
|
||||
const position = Math.max(5, Math.min(95, Number(positionPercent) || 50))
|
||||
const t = position / 100
|
||||
const point = cubicPoint(from, c1, c2, to, t)
|
||||
const tangent = cubicTangent(from, c1, c2, to, t)
|
||||
const length = Math.hypot(tangent.x, tangent.y) || 1
|
||||
const normal = { x: -tangent.y / length, y: tangent.x / length }
|
||||
const maxLateralOffset = threadTagLateralLimit(tightness)
|
||||
const offset = Math.max(-maxLateralOffset, Math.min(maxLateralOffset, Number(lateralOffset) || 0))
|
||||
return { x: point.x + normal.x * offset, y: point.y + normal.y * offset, positionPercent: position, lateralOffset: offset, maxLateralOffset }
|
||||
}
|
||||
|
||||
export function projectThreadTag(from: BoardPoint, to: BoardPoint, tightness: number, pointer: BoardPoint) {
|
||||
const { c1, c2 } = threadControls(from, to, tightness)
|
||||
let bestT = .5
|
||||
let bestDistance = Number.POSITIVE_INFINITY
|
||||
for (let index = 5; index <= 95; index += 1) {
|
||||
const t = index / 100
|
||||
const point = cubicPoint(from, c1, c2, to, t)
|
||||
const distance = (pointer.x - point.x) ** 2 + (pointer.y - point.y) ** 2
|
||||
if (distance < bestDistance) { bestDistance = distance; bestT = t }
|
||||
}
|
||||
const point = cubicPoint(from, c1, c2, to, bestT)
|
||||
const tangent = cubicTangent(from, c1, c2, to, bestT)
|
||||
const length = Math.hypot(tangent.x, tangent.y) || 1
|
||||
const normal = { x: -tangent.y / length, y: tangent.x / length }
|
||||
const maxLateralOffset = threadTagLateralLimit(tightness)
|
||||
const lateralOffset = Math.max(-maxLateralOffset, Math.min(maxLateralOffset, (pointer.x - point.x) * normal.x + (pointer.y - point.y) * normal.y))
|
||||
return { positionPercent: Math.round(bestT * 100), lateralOffset: Math.round(lateralOffset), maxLateralOffset }
|
||||
}
|
||||
|
||||
export function threadCurve(from: BoardPoint, to: BoardPoint, tightness = 65) {
|
||||
const { c1, c2 } = threadControls(from, to, tightness)
|
||||
const midpoint = cubicPoint(from, c1, c2, to, .5)
|
||||
return { path: `M ${from.x} ${from.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${to.x} ${to.y}`, midpoint }
|
||||
}
|
||||
|
||||
export const MIN_BOARD_ZOOM = 0.45
|
||||
export const MAX_BOARD_ZOOM = 1.5
|
||||
|
||||
export function clampBoardZoom(zoom: number) {
|
||||
return Math.max(MIN_BOARD_ZOOM, Math.min(MAX_BOARD_ZOOM, zoom))
|
||||
}
|
||||
|
||||
export function zoomFromWheel(currentZoom: number, deltaY: number) {
|
||||
return clampBoardZoom(currentZoom - deltaY * 0.0015)
|
||||
}
|
||||
|
||||
export function zoomFromPinch(currentZoom: number, previousDistance: number, nextDistance: number) {
|
||||
if (!Number.isFinite(previousDistance) || !Number.isFinite(nextDistance) || previousDistance <= 0 || nextDistance <= 0) return currentZoom
|
||||
return clampBoardZoom(currentZoom * nextDistance / previousDistance)
|
||||
}
|
||||
|
||||
export function zoomViewportAt(viewport: Viewport, nextZoom: number, screenAnchor: BoardPoint): Viewport {
|
||||
if (!Number.isFinite(viewport.zoom) || viewport.zoom <= 0) throw new RangeError('Board zoom must be positive')
|
||||
const zoom = clampBoardZoom(nextZoom)
|
||||
const boardPoint = { x: (screenAnchor.x - viewport.x) / viewport.zoom, y: (screenAnchor.y - viewport.y) / viewport.zoom }
|
||||
return { x: screenAnchor.x - boardPoint.x * zoom, y: screenAnchor.y - boardPoint.y * zoom, zoom }
|
||||
}
|
||||
|
||||
export function moveBoardPoint(origin: { x: number; y: number }, screenDelta: { x: number; y: number }, zoom: number) {
|
||||
if (!Number.isFinite(zoom) || zoom <= 0) throw new RangeError('Board zoom must be positive')
|
||||
return { x: origin.x + screenDelta.x / zoom, y: origin.y + screenDelta.y / zoom }
|
||||
}
|
||||
|
||||
export function nextOpenBoardPosition(
|
||||
evidence: Pick<Evidence, 'x' | 'y' | 'width'>[],
|
||||
preferred: { x: number; y: number },
|
||||
size: { width: number; height?: number },
|
||||
bounds = { width: 2400, height: 1500 },
|
||||
) {
|
||||
const height = size.height || 160
|
||||
const gap = 28
|
||||
const overlaps = (x: number, y: number) => evidence.some(item =>
|
||||
x < item.x + item.width + gap && x + size.width + gap > item.x &&
|
||||
y < item.y + 160 + gap && y + height + gap > item.y)
|
||||
const xStep = size.width + 46
|
||||
const yStep = height + 46
|
||||
for (let row = 0; row < 7; row += 1) {
|
||||
for (const column of [0, 1, -1, 2, -2, 3, -3]) {
|
||||
const x = Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x + column * xStep))
|
||||
const y = Math.max(100, Math.min(bounds.height - height - 80, preferred.y + row * yStep))
|
||||
if (!overlaps(x, y)) return { x, y }
|
||||
}
|
||||
}
|
||||
return { x: Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x)), y: Math.min(bounds.height - height - 80, preferred.y + evidence.length * 24) }
|
||||
}
|
||||
|
||||
export function panViewport(origin: Viewport, screenDelta: { x: number; y: number }): Viewport {
|
||||
return { ...origin, x: origin.x + screenDelta.x, y: origin.y + screenDelta.y }
|
||||
}
|
||||
|
||||
export function dateValue(date: string) {
|
||||
const parsed = Date.parse(date)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
export function timelineRange(dates: string[], fallbackYear = new Date().getFullYear(), configured?: TimelineRange) {
|
||||
if (configured) {
|
||||
const start = Date.parse(`${configured.start}T00:00:00.000Z`)
|
||||
const end = Date.parse(`${configured.end}T23:59:59.999Z`)
|
||||
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
|
||||
return { startYear: Number(configured.start.slice(0, 4)), endYear: Number(configured.end.slice(0, 4)), start, end }
|
||||
}
|
||||
}
|
||||
const years = dates
|
||||
.map(date => Number(date.slice(0, 4)))
|
||||
.filter(year => Number.isFinite(year) && year >= 1 && year <= 9999)
|
||||
let startYear = years.length ? Math.min(...years) : fallbackYear - 2
|
||||
let endYear = years.length ? Math.max(...years) : startYear + 4
|
||||
if (endYear - startYear < 4) {
|
||||
const missing = 4 - (endYear - startYear)
|
||||
startYear -= Math.floor(missing / 2)
|
||||
endYear += Math.ceil(missing / 2)
|
||||
}
|
||||
return { startYear, endYear, start: Date.UTC(startYear, 0, 1), end: Date.UTC(endYear, 11, 31) }
|
||||
}
|
||||
|
||||
export function timelinePositionPercent(date: string, range: Pick<ReturnType<typeof timelineRange>, 'start' | 'end'>) {
|
||||
if (range.end <= range.start) throw new RangeError('Timeline range must have positive duration')
|
||||
return Math.max(0, Math.min(100, ((dateValue(date) - range.start) / (range.end - range.start)) * 100))
|
||||
}
|
||||
|
||||
export function containedIds(state: CaseState, widgetId: string) {
|
||||
return state.relations
|
||||
.filter(relation => relation.type === 'contains' && relation.fromWidgetId === widgetId)
|
||||
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
||||
.map(relation => relation.toWidgetId)
|
||||
}
|
||||
|
||||
export function folderIsOpen(folder: Evidence) {
|
||||
return folder.config?.open === true
|
||||
}
|
||||
|
||||
export function relationPosition(state: CaseState, relation: WidgetRelation) {
|
||||
const folder = state.evidence.find(widget => widget.id === relation.fromWidgetId)
|
||||
const order = relation.sortOrder || 0
|
||||
const configuredX = Number(relation.config?.x)
|
||||
const configuredY = Number(relation.config?.y)
|
||||
return {
|
||||
x: Number.isFinite(configuredX) ? configuredX : (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205,
|
||||
y: Number.isFinite(configuredY) ? configuredY : (folder?.y || 100) - 30 + Math.floor(order / 3) * 185,
|
||||
}
|
||||
}
|
||||
|
||||
export function discardExhibit(state: CaseState, exhibitId: string): CaseState {
|
||||
if (!state.evidence.some(exhibit => exhibit.id === exhibitId)) return state
|
||||
return {
|
||||
...state,
|
||||
evidence: state.evidence
|
||||
.filter(exhibit => exhibit.id !== exhibitId)
|
||||
.map(exhibit => ({
|
||||
...exhibit,
|
||||
containedDocumentIds: exhibit.containedDocumentIds?.filter(id => id !== exhibitId),
|
||||
supportingEvidenceIds: exhibit.supportingEvidenceIds?.filter(id => id !== exhibitId),
|
||||
relatedEvidenceIds: exhibit.relatedEvidenceIds?.filter(id => id !== exhibitId),
|
||||
})),
|
||||
relations: state.relations.filter(relation => relation.fromWidgetId !== exhibitId && relation.toWidgetId !== exhibitId),
|
||||
connections: state.connections.filter(connection => connection.fromEvidenceId !== exhibitId && connection.toEvidenceId !== exhibitId),
|
||||
brief: {
|
||||
...state.brief,
|
||||
concepts: state.brief.concepts.map(concept => concept.resolvedPartyExhibitId === exhibitId
|
||||
? { ...concept, resolvedPartyExhibitId: undefined }
|
||||
: concept),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCase(state: CaseState): CaseState {
|
||||
const relations = Array.isArray(state.relations)
|
||||
? state.relations
|
||||
: state.evidence.flatMap(widget => (widget.containedDocumentIds || (widget.sourceDocumentId ? [widget.sourceDocumentId] : [])).map((documentId, index) => ({
|
||||
id: `contains:${widget.id}:${documentId}`,
|
||||
fromWidgetId: widget.id,
|
||||
toWidgetId: documentId,
|
||||
type: 'contains',
|
||||
sortOrder: index,
|
||||
})))
|
||||
const normalized = { ...state, relations }
|
||||
return {
|
||||
...normalized,
|
||||
brief: state.brief || { body: '', concepts: [] },
|
||||
documents: state.documents.map(document => ({
|
||||
...document,
|
||||
fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'),
|
||||
metadata: document.metadata || {},
|
||||
})),
|
||||
evidence: state.evidence.map(widget => ({
|
||||
...widget,
|
||||
type: widget.type === 'evidence' ? 'folder' : widget.type,
|
||||
config: widget.config || {},
|
||||
containedDocumentIds: containedIds(normalized, widget.id),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { EvidenceType, SourceFileType } from './types'
|
||||
import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry'
|
||||
|
||||
describe('frontend exhibit registry', () => {
|
||||
it('registers every API exhibit type and keeps legacy evidence on the folder renderer', () => {
|
||||
const types: EvidenceType[] = ['folder', 'evidence', 'note', 'event', 'party']
|
||||
expect(Object.keys(exhibitWidgetRegistry).sort()).toEqual(types.sort())
|
||||
expect(exhibitWidget('evidence')).toBe(exhibitWidget('folder'))
|
||||
expect(exhibitWidget('event').heading({} as never, [])).toContain('THIS HAPPENED')
|
||||
})
|
||||
|
||||
it('registers every normalized document type with an explicit renderer', () => {
|
||||
const types: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
|
||||
expect(Object.keys(documentWidgetRegistry).sort()).toEqual(types.sort())
|
||||
for (const type of types) {
|
||||
expect(documentWidget(type).label).toBeTruthy()
|
||||
expect(documentWidget(type).Preview).toBeTypeOf('function')
|
||||
expect(documentWidget(type).Asset).toBeTypeOf('function')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { BookOpen, Building2, CalendarClock, FileText, Folder, FolderOpen, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
|
||||
import type { CaseDocument, Evidence, EvidenceType, SourceFileType } from './types'
|
||||
import { folderIsOpen } from './boardDomain'
|
||||
|
||||
export type ExhibitWidgetProps = {
|
||||
exhibit: Evidence
|
||||
documents: CaseDocument[]
|
||||
onOpenSource: (id: string) => void
|
||||
onToggleFolder: (id: string) => void
|
||||
onEditFolder: (id: string) => void
|
||||
onEditEvent: (id: string) => void
|
||||
onEditParty: (id: string) => void
|
||||
}
|
||||
|
||||
export type ExhibitWidgetDefinition = {
|
||||
visualType: Exclude<EvidenceType, 'evidence'>
|
||||
heading: (exhibit: Evidence, documents: CaseDocument[]) => string
|
||||
connectionPoint: (exhibit: Evidence) => { x: number; y: number }
|
||||
Component: ComponentType<ExhibitWidgetProps>
|
||||
}
|
||||
|
||||
function FolderWidget({ exhibit, documents, onOpenSource, onToggleFolder, onEditFolder }: ExhibitWidgetProps) {
|
||||
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>}
|
||||
<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>
|
||||
}
|
||||
|
||||
function StandardWidget({ exhibit, onOpenSource }: ExhibitWidgetProps) {
|
||||
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
|
||||
{exhibit.eventDate && <time>{exhibit.eventDate.replaceAll('-', ' / ')}</time>}
|
||||
{exhibit.sourceDocumentId && <button onClick={() => onOpenSource(exhibit.sourceDocumentId!)}><BookOpen size={13}/> VIEW SOURCE</button>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function EventWidget({ exhibit, onEditEvent }: ExhibitWidgetProps) {
|
||||
const supportCount = exhibit.supportingEvidenceIds?.length || 0
|
||||
return <div className="card-content"><h3>{exhibit.title}</h3><p>{exhibit.content}</p>
|
||||
<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>
|
||||
}
|
||||
|
||||
function PartyWidget({ exhibit, onEditParty }: ExhibitWidgetProps) {
|
||||
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>
|
||||
<p>{exhibit.content || 'No dossier summary yet.'}</p>
|
||||
{(exhibit.aliases?.length || 0) > 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>
|
||||
}
|
||||
|
||||
const standardPoint = (exhibit: Evidence) => ({ x: exhibit.x + exhibit.width / 2, y: exhibit.y + 68 })
|
||||
const folderDefinition: ExhibitWidgetDefinition = {
|
||||
visualType: 'folder', heading: (_exhibit, documents) => `EVIDENCE FOLDER / ${documents.length}`,
|
||||
connectionPoint: standardPoint, Component: FolderWidget,
|
||||
}
|
||||
|
||||
export const exhibitWidgetRegistry: Record<EvidenceType, ExhibitWidgetDefinition> = {
|
||||
folder: folderDefinition,
|
||||
evidence: folderDefinition,
|
||||
note: { visualType: 'note', heading: () => 'INVESTIGATOR / NOTE', connectionPoint: exhibit => ({ x: exhibit.x + 54, y: exhibit.y + 12 }), Component: StandardWidget },
|
||||
event: { visualType: 'event', heading: () => 'EVENT / THIS HAPPENED', connectionPoint: standardPoint, Component: EventWidget },
|
||||
party: { visualType: 'party', heading: exhibit => exhibit.partyKind === 'person' ? 'PARTY / PERSON DOSSIER' : 'PARTY / ORGANIZATION DOSSIER', connectionPoint: standardPoint, Component: PartyWidget },
|
||||
}
|
||||
|
||||
export function exhibitWidget(type: EvidenceType) {
|
||||
return exhibitWidgetRegistry[type] || exhibitWidgetRegistry.note
|
||||
}
|
||||
|
||||
type DocumentWidgetProps = { document: CaseDocument; source: string; onMemoryCue?: (cue: string) => void }
|
||||
export type DocumentWidgetDefinition = {
|
||||
label: string
|
||||
Preview: ComponentType<DocumentWidgetProps>
|
||||
Asset: ComponentType<DocumentWidgetProps>
|
||||
}
|
||||
|
||||
function ImagePreview({ document, source }: DocumentWidgetProps) {
|
||||
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.kind}</small></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>
|
||||
}
|
||||
|
||||
const genericDocument = (label: string): DocumentWidgetDefinition => ({ label, Preview: GenericPreview, Asset: GenericAsset })
|
||||
export const documentWidgetRegistry: Record<SourceFileType, DocumentWidgetDefinition> = {
|
||||
image: { label: 'Image', Preview: ImagePreview, Asset: ImageAsset },
|
||||
pdf: { label: 'PDF', Preview: GenericPreview, Asset: FrameAsset },
|
||||
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
|
||||
}
|
||||
+118
-8
@@ -12,7 +12,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.brand-mark { width: 35px; height: 35px; display: grid; place-items: center; border: 1px solid #d88938; color: #e99a44; font: 600 11px IBM Plex Mono; transform: rotate(-3deg); }
|
||||
.menubar nav { display: flex; height: 100%; align-items: stretch; }
|
||||
.menubar nav button { background: none; border: 0; padding: 0 18px; font: 500 11px IBM Plex Mono; letter-spacing: .1em; color: #94aaa4; cursor: pointer; }
|
||||
.menubar nav button:hover { background: #112f2a; color: white; }
|
||||
.menubar nav button:hover, .menubar nav button.active { background: #112f2a; color: white; }
|
||||
.admin-menu { position: relative; display: flex; align-items: stretch; }.admin-menu > button { height: 100%; }.admin-menu-items { position: absolute; z-index: 30; top: calc(100% - 1px); right: 0; width: 235px; padding: 5px; display: grid; background: #c5c9c2; border: 2px outset #e5e8e2; box-shadow: 6px 8px 0 #020907aa; }.menubar nav .admin-menu-items button { min-height: 37px; padding: 0 12px; color: #263d36; border: 1px solid transparent; text-align: left; font-size: 9px; }.menubar nav .admin-menu-items button:hover, .menubar nav .admin-menu-items button:focus-visible { background: #173d34; border-color: #526c64; color: #f1f3ef; }
|
||||
.brief-count { display: inline-grid; min-width: 17px; height: 17px; margin-left: 7px; padding: 0 4px; place-items: center; border-radius: 9px; background: #b56d30; color: #fff3df; font: 600 9px IBM Plex Mono; }
|
||||
.terminal-status { font: 10px IBM Plex Mono; color: #769087; letter-spacing: .06em; display: flex; gap: 8px; align-items: center; white-space: nowrap; }
|
||||
.terminal-status i { width: 7px; height: 7px; background: #71c888; border-radius: 50%; box-shadow: 0 0 8px #71c888; }
|
||||
.terminal-status span { color: #d9a05d; margin-left: 20px; font-size: 13px; }
|
||||
@@ -24,11 +26,19 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.panel-heading h2 { font: 600 17px IBM Plex Mono; letter-spacing: .04em; margin: 6px 0 0; }
|
||||
.panel-heading sup { color: #d89043; font-size: 10px; }
|
||||
.panel-heading button { background: none; border: 0; color: #6f8b83; cursor: pointer; }
|
||||
.search { margin: 0 15px 12px; height: 36px; border: 1px solid #314a44; display: flex; align-items: center; gap: 9px; padding: 0 12px; color: #607d75; font-size: 11px; }
|
||||
.search { margin: 0 15px 12px; height: 36px; border: 1px solid #314a44; display: flex; align-items: center; gap: 9px; padding: 0 10px 0 12px; color: #607d75; font-size: 11px; }
|
||||
.search:focus-within { border-color: #9a683d; box-shadow: inset 0 0 0 1px #6f4b2f; color: #c18b55; }
|
||||
.search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: #d6ded9; font: 10px IBM Plex Mono; }
|
||||
.search input::placeholder { color: #607d75; }.search input::-webkit-search-cancel-button { display: none; }
|
||||
.search button { display: grid; place-items: center; padding: 2px; border: 0; background: transparent; color: #8a9d97; cursor: pointer; }
|
||||
.import-document { margin: 0 15px 12px; height: 34px; border: 1px dashed #a26d3d; background: #19322c; color: #d79754; display: flex; justify-content: center; align-items: center; gap: 8px; font: 600 9px IBM Plex Mono; letter-spacing: .08em; cursor: pointer; }.import-document:hover { background: #244039; border-style: solid; }.file-input { display: none; }
|
||||
.doc-list { overflow: auto; border-top: 1px solid #243d36; }
|
||||
.no-document-results { min-height: 150px; padding: 32px 20px; display: grid; place-items: center; align-content: center; gap: 9px; text-align: center; color: #617d75; }.no-document-results b { color: #91a59f; font: 600 9px IBM Plex Mono; letter-spacing: .08em; }.no-document-results span { font: 8px/1.5 IBM Plex Mono; }
|
||||
.doc-row { width: 100%; min-height: 76px; border: 0; border-bottom: 1px solid #243d36; background: transparent; padding: 11px 13px; display: grid; grid-template-columns: 45px 1fr 18px; text-align: left; align-items: center; gap: 10px; cursor: pointer; }
|
||||
.doc-row:hover { background: #18342e; }
|
||||
.doc-row.selected { position: relative; background: #304534; box-shadow: inset 4px 0 #f1cf55, inset 0 0 20px #d8b94722; }
|
||||
.doc-row.selected::after { content: 'LOCATED'; position: absolute; top: 7px; right: 9px; color: #f0ce57; font: 600 6px IBM Plex Mono; letter-spacing: .12em; }
|
||||
.doc-row.selected .doc-icon { border-color: #e7c750; box-shadow: 0 0 12px #efd44f66, 3px 3px #081a16; }
|
||||
.doc-row strong { display: block; color: #d8dfda; font-size: 12px; margin-bottom: 6px; }
|
||||
.doc-row span { display: block; font: 9px IBM Plex Mono; color: #708b83; }
|
||||
.doc-row > svg { color: #527068; }
|
||||
@@ -44,21 +54,64 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.case-number { border-left: 1px solid #415750; margin-left: 26px; padding-left: 17px; font: 8px IBM Plex Mono; color: #6d867f; line-height: 1.5; }
|
||||
.case-number b { color: #bdc9c3; font-size: 13px; }
|
||||
.board-viewport { position: absolute; inset: 0; overflow: hidden; touch-action: none; overscroll-behavior: contain; cursor: default; background-image: radial-gradient(#49615a55 1px, transparent 1px), linear-gradient(90deg, #18302a33 1px, transparent 1px), linear-gradient(#18302a33 1px, transparent 1px); background-size: 20px 20px, 100px 100px, 100px 100px; }
|
||||
.board-viewport.tool-move { cursor: grab; }
|
||||
.board-viewport.tool-move:active { cursor: grabbing; }
|
||||
.board-viewport.tool-hand, .board-viewport.tool-hand .evidence-card, .board-viewport.tool-hand .source-file-widget { cursor: grab; }
|
||||
.board-viewport.tool-hand:active, .board-viewport.tool-hand .evidence-card:active, .board-viewport.tool-hand .source-file-widget:active { cursor: grabbing; }
|
||||
.board-viewport.threading, .board-viewport.threading .evidence-card, .board-viewport.threading .source-file-widget { cursor: crosshair; }
|
||||
.board-trash { position: absolute; z-index: 18; left: 18px; bottom: 18px; width: 136px; min-height: 64px; padding: 10px 12px; display: flex; align-items: center; gap: 10px; border: 1px dashed #6f5d50; background: #10231fdd; color: #866f61; box-shadow: 4px 5px 0 #02090788; opacity: .48; pointer-events: none; transition: opacity .16s ease, color .16s ease, border-color .16s ease, background .16s ease, transform .16s ease, box-shadow .16s ease; }
|
||||
.board-trash span { display: grid; gap: 3px; }.board-trash b { font: 600 9px IBM Plex Mono; letter-spacing: .1em; }.board-trash small { font: 6px IBM Plex Mono; letter-spacing: .05em; white-space: nowrap; }
|
||||
.board-trash.drag-ready { opacity: 1; color: #d69568; border-color: #b06e4f; background: #1c2b26ee; transform: translateY(-2px); }
|
||||
.board-trash.active { color: #ffe2cf; border-color: #df5c48; background: #6d2823ed; box-shadow: 0 0 0 4px #c4473738, 6px 8px 0 #02090799; transform: scale(1.05); }
|
||||
.board { transform-origin: 0 0; position: absolute; background: linear-gradient(110deg, #0c211d33, #122b251f); }
|
||||
.board-stamp { position: absolute; left: 1480px; top: 240px; color: #6e807a2e; border: 2px solid #6e807a20; padding: 9px 13px; transform: rotate(-4deg); font: 600 11px IBM Plex Mono; }
|
||||
.board-stamp span { display: block; text-align: center; font-size: 8px; margin-top: 4px; }
|
||||
.connections { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||
.connections path { stroke: #8f2828; stroke-width: 3; fill: none; filter: drop-shadow(1px 2px 0 #020907aa); }
|
||||
.connections circle { fill: #b33a32; stroke: #581916; stroke-width: 2; }
|
||||
.connections .thread-preview path { stroke: #bc3b35; stroke-width: 2.5; stroke-dasharray: 8 6; opacity: .82; }
|
||||
.connections .thread-preview circle:last-child { fill: #0d211c; stroke: #c84a43; }
|
||||
.connections g.tightening path { stroke-dasharray: 1400; animation: thread-tighten .9s cubic-bezier(.15,.75,.22,1) both; }
|
||||
@keyframes thread-tighten { 0% { stroke-dashoffset: 1400; filter: drop-shadow(0 0 7px #d85a50); } 70% { stroke-dashoffset: 0; stroke-width: 4; } 100% { stroke-dashoffset: 0; stroke-width: 3; } }
|
||||
.luggage-tag.luggage-tag { background: linear-gradient(100deg, #aa8755, #c7a773 52%, #a9834f); border: 1px solid #d3b681; color: #33291c; clip-path: polygon(13px 0, calc(100% - 13px) 0, 100% 14px, 100% 100%, 0 100%, 0 14px); transform-origin: 50% 8px; transition: transform .22s ease, filter .22s ease; box-shadow: 5px 7px 0 #020b0980, inset 0 0 18px #60452233; }
|
||||
.luggage-tag.luggage-tag::before { content: ''; position: absolute; z-index: 3; top: 6px; left: 50%; width: 10px; height: 10px; translate: -50% 0; border-radius: 50%; background: #12211d; border: 2px solid #70583b; box-shadow: 0 0 0 2px #c5a66f, inset 1px 1px 2px #000; }
|
||||
.luggage-tag.luggage-tag::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -1px; height: 4px; background: #745a37; clip-path: polygon(0 0, 5% 50%, 12% 0, 20% 70%, 28% 0, 40% 60%, 49% 0, 61% 70%, 73% 0, 83% 60%, 91% 0, 100% 50%, 100% 100%, 0 100%); }
|
||||
.thread-tag { position: absolute; z-index: 7; border: 0; cursor: grab; touch-action: none; transition: left .1s ease-out, top .1s ease-out, transform .22s ease, filter .22s ease, opacity .15s ease; }
|
||||
.board-viewport.threading .thread-tag { pointer-events: none; opacity: .72; }
|
||||
.thread-tag.dragging { z-index: 15; cursor: grabbing; transition: transform .12s ease, filter .12s ease; filter: drop-shadow(0 0 7px #d99a58aa); }
|
||||
.thread-tag-position { position: absolute; z-index: 8; left: 50%; top: -22px; translate: -50% 0; min-width: 34px; padding: 3px 5px; border: 1px solid #9a683d; background: #102a24; color: #e6a45e; font: 7px IBM Plex Mono; box-shadow: 2px 3px #02090799; }
|
||||
.thread-tag.untagged { width: 13px; height: 13px; padding: 0; transform: translate(-50%, -50%); border: 2px solid #611d1c; border-radius: 50%; background: #a63531; box-shadow: 1px 2px #020907aa; }
|
||||
.thread-tag.untagged i { display: none; }
|
||||
.thread-tag.untagged:hover, .thread-tag.untagged:focus-visible { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; }
|
||||
.thread-tag.compact { transform: translate(-50%, -50%); display: flex; align-items: center; gap: 5px; max-width: 190px; padding: 0; background: transparent; color: #241d17; }
|
||||
.thread-tag.compact i { flex: 0 0 auto; width: 9px; height: 9px; border: 2px solid #611d1c; border-radius: 50%; background: #a63531; box-shadow: 1px 2px #020907aa; }
|
||||
.thread-tag-compact-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 5px 7px 4px; border: 1px solid #9c917b; background: #d7c9a9; box-shadow: 2px 3px #02090799; font: 8px Special Elite; }
|
||||
.thread-tag.compact:hover i, .thread-tag.compact:focus-visible i { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; }
|
||||
.thread-tag.labelled { width: 108px; height: 154px; padding: 27px 10px 11px; transform: translate(-50%, 4px) rotate(-2deg); }
|
||||
.thread-tag.labelled i { display: none; }
|
||||
.thread-tag-content { display: grid; align-content: start; gap: 7px; height: 108px; padding-top: 10px; overflow: hidden; text-align: left; transition: transform .22s ease; }
|
||||
.thread-tag-content small { padding-bottom: 4px; border-bottom: 1px solid #7e6542; color: #5b472d; font: 600 6px IBM Plex Mono; letter-spacing: .1em; }
|
||||
.thread-tag-content b { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 5; -webkit-box-orient: vertical; color: #33291c; font: 12px/1.25 Special Elite; }
|
||||
.thread-tag-content em { color: #68472c; font: 600 6px IBM Plex Mono; letter-spacing: .06em; }
|
||||
.thread-tag.labelled.expanded { z-index: 14; transform: translate(-50%, 4px) rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
|
||||
.thread-tag.labelled.expanded .thread-tag-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
|
||||
.thread-tag.labelled.expanded .thread-tag-content b { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
|
||||
.event-support-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||
.event-support-lines line { stroke: #d3a05c; stroke-width: 2; stroke-dasharray: 7 6; opacity: .58; filter: drop-shadow(1px 1px 0 #020907); }
|
||||
.party-association-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||
.party-association-lines line { stroke: #6fa694; stroke-width: 2; stroke-dasharray: 3 5; opacity: .52; filter: drop-shadow(1px 1px 0 #020907); }
|
||||
.folder-bands { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||
.folder-bands line { stroke: #b63232; stroke-width: 7; opacity: .2; filter: drop-shadow(1px 1px 0 #050a08); transition: x2 .42s cubic-bezier(.2,.75,.2,1), y2 .42s cubic-bezier(.2,.75,.2,1), opacity .24s ease; }
|
||||
.folder-bands line.closed { opacity: 0; }
|
||||
.evidence-card { position: absolute; min-height: 138px; color: #1b2925; background: #d8d8ca; border: 1px solid #edece0; padding: 13px 15px 12px; box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; cursor: move; user-select: none; }
|
||||
.evidence-card::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -6px; height: 5px; background: #80847b; clip-path: polygon(0 0, 5% 50%, 12% 0, 20% 70%, 28% 0, 40% 60%, 49% 0, 61% 70%, 73% 0, 83% 60%, 91% 0, 100% 50%, 100% 100%, 0 100%); }
|
||||
.evidence-card.selected { outline: 2px solid #e49a4a; outline-offset: 5px; }
|
||||
.evidence-card.document-located, .source-file-widget.document-located { z-index: 6; outline: 3px solid #f2d254; outline-offset: 6px; filter: drop-shadow(0 0 10px #f3d75d99) brightness(1.08); animation: document-locator-target 1.35s ease-in-out infinite alternate; }
|
||||
@keyframes document-locator-target { from { outline-color: #cda936; filter: drop-shadow(0 0 5px #f3d75d66) brightness(1.03); } to { outline-color: #fff19a; filter: drop-shadow(0 0 14px #ffe66cbb) brightness(1.12); } }
|
||||
.evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
|
||||
.evidence-card.thread-target:hover, .source-file-widget.thread-target:hover { outline: 2px dashed #b8443e; outline-offset: 6px; }
|
||||
.evidence-card.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; }
|
||||
@keyframes exhibit-arrival { 0% { opacity: 0; scale: .72; translate: 0 -24px; filter: brightness(1.7); box-shadow: 0 0 0 0 #eda85b00; } 45% { opacity: 1; scale: 1.035; translate: 0 2px; box-shadow: 0 0 0 12px #eda85b55, 7px 9px 0 #020b0980; } 100% { opacity: 1; scale: 1; translate: 0 0; filter: brightness(1); box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; } }
|
||||
.evidence-card header { border-bottom: 1px solid #989e94; display: flex; justify-content: space-between; padding-bottom: 6px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #5d6763; }
|
||||
.evidence-card h3 { font: 600 10px IBM Plex Mono; letter-spacing: .09em; margin: 12px 0 6px; color: #9a5d2e; }
|
||||
.evidence-card p { font: 500 16px Special Elite, serif; line-height: 1.35; margin: 0 0 12px; }
|
||||
@@ -69,6 +122,19 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.evidence-card.folder::after { background: #80643e; }
|
||||
.evidence-card.folder header { border-color: #846e49; color: #594a33; }
|
||||
.evidence-card.folder h3 { color: #66401f; }
|
||||
.evidence-card.event { background: linear-gradient(112deg, #d6d1bd, #c8c5b6); border-left: 5px solid #9a6332; min-height: 174px; }
|
||||
.evidence-card.event h3 { color: #70401e; }
|
||||
.evidence-card.event p { font-size: 15px; }
|
||||
.event-actions { clear: both; margin-top: 10px; padding-top: 7px; border-top: 1px dashed #858c84; display: flex; align-items: center; justify-content: space-between; }
|
||||
.event-actions span { color: #6a736d; font: 7px IBM Plex Mono; }
|
||||
.event-actions button { float: none; }
|
||||
.evidence-card.party { min-height: 190px; background: linear-gradient(110deg, #d9d7c9, #c8c8bd); border-top: 5px solid #315a50; }
|
||||
.evidence-card.party h3 { margin: 0 0 3px; color: #254d43; }
|
||||
.party-identity { display: grid; grid-template-columns: 34px 1fr; gap: 8px; align-items: center; padding: 11px 0 8px; border-bottom: 1px solid #929a92; }
|
||||
.party-identity > svg { color: #315a50; }
|
||||
.party-identity small { color: #6a746e; font: 7px IBM Plex Mono; }
|
||||
.evidence-card.party p { margin-top: 9px; font-size: 13px; }
|
||||
.party-aliases { padding: 5px 0; color: #76552f; font: 7px IBM Plex Mono; }
|
||||
.folder-documents { clear: both; margin-top: 9px; border-top: 1px dashed #826c49; padding-top: 6px; }
|
||||
.folder-documents button { float: none; width: 100%; height: 23px; padding: 2px 0; display: grid; grid-template-columns: 14px 1fr auto; text-align: left; color: #493d2c; }
|
||||
.folder-documents button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -79,18 +145,24 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.source-file-widget { position: absolute; z-index: 4; width: 174px; min-height: 145px; padding: 8px; color: #1a2421; background: #d9d8cc; border: 1px solid #f1efe2; box-shadow: 5px 7px 0 #020b0980, 0 0 0 1px #53615c; cursor: move; user-select: none; transition: left .42s cubic-bezier(.2,.75,.2,1), top .42s cubic-bezier(.2,.75,.2,1), opacity .28s ease, transform .42s cubic-bezier(.2,.75,.2,1); }
|
||||
.source-file-widget.closed { opacity: 0; transform: scale(.18) rotate(-8deg); pointer-events: none; }
|
||||
.source-file-widget.open { opacity: 1; transform: scale(1) rotate(.6deg); }
|
||||
.source-file-widget.selected { outline: 2px solid #e49a4a; outline-offset: 5px; }
|
||||
.source-file-widget.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
|
||||
.source-file-widget header { height: 18px; display: flex; justify-content: space-between; border-bottom: 1px solid #8b938d; color: #59645f; font: 600 7px IBM Plex Mono; letter-spacing: .1em; }
|
||||
.source-file-preview { height: 79px; margin: 7px 0; display: grid; place-items: center; overflow: hidden; background: #263a35; border: 1px solid #707a74; }
|
||||
.source-file-preview img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.source-file-preview > div { display: grid; justify-items: center; gap: 5px; color: #b8c2bd; }
|
||||
.source-file-preview small { font: 7px IBM Plex Mono; }
|
||||
.source-file-widget.file-type-text .source-file-preview { height: 108px; display: block; background: #e8e4d7; border-color: #a19b8b; }
|
||||
.text-document-preview { width: 100%; height: 100%; display: grid !important; grid-template-rows: 42px 1fr; justify-items: stretch !important; gap: 0 !important; color: #202722 !important; }
|
||||
.text-source-excerpt { margin: 0; padding: 6px 7px; overflow: hidden; border-bottom: 1px solid #a29d8e; background: #d5d1c4; font: 6px/1.35 IBM Plex Mono; text-transform: uppercase; }
|
||||
.text-document-preview textarea { width: 100%; min-height: 0; resize: none; overflow: hidden; border: 0; outline: 0; padding: 8px 7px 5px; background: repeating-linear-gradient(#ede9dc 0 20px, #c9c5b855 21px); color: #151b18; font: 900 15px/1.05 "Marker Felt", "Comic Sans MS", cursive; letter-spacing: .015em; text-align: center; transform: rotate(-1deg); }
|
||||
.text-document-preview textarea::placeholder { color: #76786f; font-size: 9px; letter-spacing: .04em; }
|
||||
.board-viewport.threading .text-document-preview textarea { pointer-events: none; }
|
||||
.source-file-widget > strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }
|
||||
.source-file-widget > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; }
|
||||
.source-file-actions { display: flex; justify-content: space-between; margin-top: 6px; border-top: 1px dashed #969b94; padding-top: 4px; }
|
||||
.source-file-actions button { display: flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 2px; color: #4e5d57; cursor: pointer; font: 600 6px IBM Plex Mono; }
|
||||
.evidence-card.note { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; background: linear-gradient(100deg, #aa8755, #c7a773 52%, #a9834f); border: 1px solid #d3b681; color: #33291c; rotate: -2deg !important; clip-path: polygon(13px 0, calc(100% - 13px) 0, 100% 14px, 100% 100%, 0 100%, 0 14px); transform-origin: 50% 8px; transition: transform .22s ease, filter .22s ease; box-shadow: 5px 7px 0 #020b0980, inset 0 0 18px #60452233; z-index: 2; }
|
||||
.evidence-card.note::before { content: ''; position: absolute; z-index: 3; top: 6px; left: 50%; width: 10px; height: 10px; translate: -50% 0; border-radius: 50%; background: #12211d; border: 2px solid #70583b; box-shadow: 0 0 0 2px #c5a66f, inset 1px 1px 2px #000; }
|
||||
.evidence-card.note::after { height: 4px; bottom: -1px; background: #745a37; }
|
||||
.evidence-card.note { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; rotate: -2deg !important; z-index: 2; }
|
||||
.evidence-card.note header { position: absolute; left: 9px; right: 9px; top: 21px; padding-bottom: 3px; font-size: 6px; color: #5b472d; border-color: #7e6542; }
|
||||
.evidence-card.note header i { display: none; }
|
||||
.evidence-card.note .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; }
|
||||
@@ -103,19 +175,45 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; }
|
||||
.board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; }
|
||||
.board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; }
|
||||
.board-actions button.thread-tool:not(:disabled) { color: #d35b52; }
|
||||
.board-actions button.thread-tool.active { background: #4a211f; color: #ff8a78; box-shadow: inset 0 0 0 1px #8f3934; }
|
||||
.board-actions button:disabled { opacity: .35; cursor: default; }
|
||||
.board-actions span { width: 1px; height: 24px; background: #385149; margin: 0 5px; }
|
||||
.board-actions b { width: 44px; text-align: center; color: #91a69f; font: 9px IBM Plex Mono; }
|
||||
.open-files { position: absolute; top: 25px; left: 25px; z-index: 4; border: 1px solid #4c675f; background: #122e28; padding: 11px 13px; display: flex; gap: 8px; font: 9px IBM Plex Mono; cursor: pointer; }
|
||||
.open-files b { color: #e19a4d; }
|
||||
.story-strip { position: absolute; z-index: 4; right: 18px; top: 18px; width: 255px; max-height: 235px; overflow: auto; padding: 10px; background: #0d2721e8; border: 1px solid #466159; box-shadow: 5px 7px 18px #0008; }
|
||||
.story-strip > small { display: block; padding: 2px 4px 8px; color: #b47a41; font: 600 8px IBM Plex Mono; letter-spacing: .12em; }
|
||||
.story-strip button { width: 100%; display: grid; grid-template-columns: 64px 1fr; gap: 2px 7px; padding: 7px 5px; text-align: left; border: 0; border-top: 1px solid #304a43; background: transparent; cursor: pointer; }
|
||||
.story-strip button:hover, .story-strip button.selected { background: #193a32; }
|
||||
.story-strip time { grid-row: 1 / 3; color: #7b938b; font: 7px IBM Plex Mono; }
|
||||
.story-strip b { color: #d9ddd8; font: 9px IBM Plex Mono; }
|
||||
.story-strip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #849b93; font: 8px Special Elite; }
|
||||
.brief-panel { position: absolute; z-index: 12; right: 18px; top: 18px; width: min(410px, 42vw); max-height: calc(100% - 36px); overflow: auto; background: #c6c9c1; color: #17231f; border: 2px solid #d8dbd4; box-shadow: 7px 9px 0 #020a08, 0 0 0 1px #46554f; }
|
||||
.brief-panel > header { height: 44px; padding: 0 7px 0 13px; display: flex; align-items: center; background: #173d34; color: #e0e8e4; cursor: default; }
|
||||
.brief-panel > header div { display: grid; gap: 2px; }.brief-panel > header > span { flex: 1; }.brief-panel > header small { color: #9bb0a9; font: 7px IBM Plex Mono; letter-spacing: .14em; }.brief-panel > header b { font: 10px IBM Plex Mono; }.brief-panel > header button { flex: 0 0 auto; width: 25px; height: 24px; margin-left: 4px; display: grid; place-items: center; padding: 0; border: 1px outset #e8ece8; background: #c9cec8; color: #17312b; cursor: pointer; }.brief-panel > header button:hover { background: #eef0eb; color: #070d0b; }
|
||||
.brief-panel.minimized { width: min(330px, 42vw); overflow: hidden; }.brief-panel.minimized > :not(header) { display: none; }
|
||||
.brief-panel > p { margin: 16px; padding: 13px; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
|
||||
.brief-concepts { border-top: 1px solid #8c958e; }.brief-concepts section { padding: 12px 15px; border-bottom: 1px solid #959c95; }.brief-concepts section.resolved { background: #d5ddcf; }.brief-concepts section.just-resolved { animation: concept-resolved 1.15s ease-out; }.brief-concepts section > div:first-child { display: grid; gap: 4px; }.brief-concepts b { font: 600 10px IBM Plex Mono; }.brief-concepts span { color: #616d67; font: 9px Special Elite; }
|
||||
.classify-actions, .resolved-actions { display: flex; align-items: center; gap: 7px; margin-top: 9px; }.classify-actions button, .resolved-actions button, .edit-brief, .new-party-from-brief { display: inline-flex; align-items: center; gap: 5px; border: 1px outset #89948e; background: #e3e1d6; color: #29463e; padding: 7px 8px; cursor: pointer; font: 8px IBM Plex Mono; }.resolved-actions > span { margin-right: auto; display: inline-flex; align-items: center; gap: 5px; color: #31584d; font: 600 8px IBM Plex Mono; }.edit-brief, .new-party-from-brief { margin: 12px 15px 0; }.edit-brief { background: #234c41; color: white; }.new-party-from-brief { width: calc(100% - 30px); justify-content: center; border-style: dashed; background: #d8d8cf; }.dismiss-brief { width: calc(100% - 30px); margin: 12px 15px 14px; padding: 9px; border: 1px outset #73877f; background: #1e473d; color: white; cursor: pointer; font: 600 9px IBM Plex Mono; letter-spacing: .08em; }
|
||||
@keyframes concept-resolved { 0% { background: #e8b76c; box-shadow: inset 4px 0 #b56127; } 100% { background: #d5ddcf; box-shadow: inset 0 0 transparent; } }
|
||||
.concept-editor-list { max-height: 280px; overflow: auto; border: 1px solid #8d968f; background: #d4d5cd; }.concept-editor-row { display: grid; grid-template-columns: 150px 1fr 125px 30px; gap: 6px; padding: 7px; border-bottom: 1px solid #9fa59e; }.concept-editor-row input, .concept-editor-row select { min-width: 0; border: 1px solid #7d8780; background: #e8e5d8; padding: 7px; font: 9px IBM Plex Mono; }.concept-editor-row button { border: 0; color: #713c2d; }
|
||||
.file-drop-overlay { position: absolute; z-index: 20; inset: 14px; border: 2px dashed #e19a4d; background: #0a211de8; display: grid; place-items: center; pointer-events: none; }.file-drop-overlay > div { width: 290px; height: 150px; border: 1px solid #5e786f; background: #102c25; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; box-shadow: 9px 10px #020b09; color: #d99a58; }.file-drop-overlay b { font: 600 12px IBM Plex Mono; letter-spacing: .08em; }.file-drop-overlay span { font: 9px IBM Plex Mono; color: #78928a; letter-spacing: .12em; }
|
||||
.timeline { background: #0d231e; border-top: 1px solid #3c544d; display: grid; grid-template-columns: 180px 1fr 165px; align-items: center; padding: 0 25px; z-index: 8; }
|
||||
.temporal-links { position: fixed; z-index: 7; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; }
|
||||
.temporal-links { position: fixed; z-index: 9; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; }
|
||||
.document-locator-beam { position: fixed; z-index: 9; inset: 0; width: 100vw; height: 100vh; overflow: visible; pointer-events: none; }
|
||||
.document-locator-beam path { fill: none; vector-effect: non-scaling-stroke; }
|
||||
.document-locator-halo { stroke: #ffe276; stroke-width: 12; opacity: .13; filter: url(#document-locator-glow); }
|
||||
.document-locator-ray { stroke: #ffe066; stroke-width: 2; stroke-dasharray: 10 8; opacity: .9; filter: url(#document-locator-glow); animation: document-locator-flow .7s linear infinite; }
|
||||
.document-locator-beam circle { fill: #ffe36d; stroke: #fff3b0; stroke-width: 2; filter: url(#document-locator-glow); }
|
||||
.document-locator-beam .document-locator-pulse { fill: none; opacity: .65; animation: document-locator-pulse 1.1s ease-out infinite; }
|
||||
@keyframes document-locator-flow { to { stroke-dashoffset: -18; } }
|
||||
@keyframes document-locator-pulse { from { r: 7; opacity: .8; } to { r: 24; opacity: 0; } }
|
||||
.timeline-label { border-right: 1px solid #314a43; height: 67px; display: flex; flex-direction: column; justify-content: center; }
|
||||
.timeline-label b { font: 600 14px IBM Plex Mono; margin: 4px 0; }.timeline-label span { font: 8px IBM Plex Mono; color: #a07142; }
|
||||
.timeline-label b { font: 600 15px IBM Plex Mono; margin: 4px 0; }.timeline-label button { width: max-content; max-width: 170px; padding: 1px 0; overflow: hidden; color: #d29a61; background: transparent; border: 0; text-overflow: ellipsis; white-space: nowrap; text-align: left; cursor: pointer; font: 500 11px IBM Plex Mono; }
|
||||
.timeline-track { height: 75px; margin: 0 43px; position: relative; }
|
||||
.axis { position: absolute; left: 0; right: 0; top: 45px; height: 1px; background: #61736d; }
|
||||
.year { position: absolute; top: 53px; transform: translateX(-50%); font: 9px IBM Plex Mono; color: #6f8981; }
|
||||
.year { position: absolute; top: 53px; transform: translateX(-50%); color: #b7c7c1; font: 600 12px IBM Plex Mono; letter-spacing: .02em; text-shadow: 0 1px 1px #020907; }
|
||||
.year::before { content: ''; position: absolute; left: 50%; top: -9px; height: 5px; border-left: 1px solid #6a7e78; }
|
||||
.marker { position: absolute; width: 19px; height: 29px; transform: translateX(-50%); border: 0; background: transparent; cursor: pointer; padding: 0; }
|
||||
.marker i { display: block; width: 10px; height: 10px; border: 2px solid #8eb3a7; background: #16352e; rotate: 45deg; box-shadow: 0 0 0 3px #0d231e; }
|
||||
@@ -123,6 +221,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.marker.selected i { border-color: #eea458; background: #eea458; }
|
||||
.timeline-key { border-left: 1px solid #314a43; padding-left: 24px; font: 8px IBM Plex Mono; color: #759087; display: flex; gap: 18px; }.timeline-key span { display: flex; gap: 5px; }.timeline-key i { width: 7px; height: 7px; background: #8eb3a7; rotate: 45deg; }.timeline-key .amber i { background: #eea458; }
|
||||
.window { position: fixed; z-index: 30; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; }
|
||||
.timeline-editor { width: min(520px, 88vw); }.timeline-editor > div { padding: 24px 27px; }.timeline-editor p { margin: 12px 0 18px; font: 12px/1.5 Special Elite; }.timeline-range-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; }
|
||||
.thread-editor { width: min(520px, 88vw); }.thread-editor > div { padding: 24px 27px; }.thread-editor > div > small { color: #8a4b32; font: 600 8px IBM Plex Mono; letter-spacing: .14em; }.thread-editor p { margin: 14px 0; font: 12px/1.5 Special Elite; }.thread-endpoints { margin-top: 15px; display: grid; grid-template-columns: minmax(0,1fr) 70px minmax(0,1fr); align-items: center; gap: 9px; }.thread-endpoints b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }.thread-endpoints b:last-child { text-align: right; }.thread-endpoints i { height: 3px; background: #982e2b; box-shadow: 0 1px #5b1d1b; }.thread-tightness { margin: 20px 0; }.thread-tightness output { margin-left: auto; color: #9a332e; }.thread-tightness input { accent-color: #9b302d; padding: 0; }.thread-tightness > small { display: flex; justify-content: space-between; color: #68736d; font: 7px IBM Plex Mono; }.thread-position-control { margin-top: 17px; }.thread-position-control output { margin-left: auto; color: #9a5c2f; }.thread-position-control input { accent-color: #9a5c2f; padding: 0; }.thread-position-control > small { color: #68736d; font: 7px/1.4 IBM Plex Mono; }.thread-editor .folder-editor-actions > span { flex: 1; }.folder-editor-actions button.danger { color: #7c2925; border-color: #a25b55; }
|
||||
.tag-style-picker { margin: 16px 0 4px; padding: 0; border: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }.tag-style-picker legend { margin-bottom: 6px; color: #44504c; font: 600 8px IBM Plex Mono; letter-spacing: .1em; }.tag-style-picker label { position: relative; display: grid; gap: 5px; padding: 10px; border: 1px solid #909991; background: #d6d7cf; cursor: pointer; }.tag-style-picker label.selected { border-color: #8f3833; background: #e1d4bd; box-shadow: inset 3px 0 #9c3631; }.tag-style-picker input { position: absolute; opacity: 0; }.tag-style-picker label > span { display: flex; align-items: center; gap: 7px; color: #344b44; font: 600 8px IBM Plex Mono; }.tag-style-picker label > small { color: #69746e; font: 7px IBM Plex Mono; }.tag-style-luggage i { width: 15px; height: 21px; background: #b99562; border: 1px solid #7b6040; clip-path: polygon(3px 0,12px 0,15px 3px,15px 21px,0 21px,0 3px); }.tag-style-compact i { width: 25px; height: 8px; border-left: 7px solid #a63531; background: #d7c9a9; box-shadow: 1px 1px #6e6250; }
|
||||
.window > header { height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; }
|
||||
.window > header span { flex: 1; }.window > header button { width: 22px; height: 21px; display: grid; place-items: center; padding: 0; background: #b7bcb4; border: 1px outset white; color: #17221f; cursor: pointer; }
|
||||
.document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); }.document-window > nav { height: 28px; padding: 7px 10px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; }
|
||||
@@ -146,6 +247,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.folder-members-heading small { color: #8c5c31; font: 7px IBM Plex Mono; }
|
||||
.folder-members-heading > span { display: flex; gap: 6px; align-items: center; color: #66716b; font: 7px IBM Plex Mono; }
|
||||
.folder-members { max-height: 260px; overflow: auto; border: 1px solid #8a918b; border-top: 0; background: #d4d5cd; }
|
||||
.event-support-list > p { margin: 20px; text-align: center; color: #68746e; font: 8px IBM Plex Mono; }
|
||||
.event-date-field > span:last-child { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }.event-date-field > span:last-child button { border: 1px outset #8c948e; background: #c8cbc4; color: #4d5853; padding: 0 10px; cursor: pointer; font: 8px IBM Plex Mono; }.event-date-field > span:last-child button:disabled { opacity: .45; cursor: default; }
|
||||
.folder-member { min-height: 56px; padding: 8px 10px; display: grid; grid-template-columns: minmax(0, 1fr) 190px; gap: 14px; align-items: center; border-bottom: 1px solid #a1a69f; opacity: .67; }
|
||||
.folder-member.included { background: #e2d7bd; opacity: 1; }
|
||||
.folder-member label { min-width: 0; display: grid; grid-template-columns: 18px 18px minmax(0, 1fr); align-items: center; gap: 7px; cursor: pointer; }
|
||||
@@ -175,3 +278,10 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
||||
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
||||
@media (max-width: 900px) { .menubar { grid-template-columns: 1fr auto; }.menubar nav { display: none; }.terminal-status { font-size: 0; }.documents-panel { width: 275px; }.documents-panel.closed { margin-left: -275px; }.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }.timeline-key { display: none; }.timeline-track { margin: 0 23px; }.document-window { width: 80vw; }.case-heading { left: 18px; }.case-number { display: none; }.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; } }
|
||||
@media (max-width: 900px) and (orientation: portrait) {
|
||||
.board-actions { top: 50%; right: 8px; bottom: auto; left: auto; width: 96px; height: auto; max-height: calc(100% - 20px); padding: 5px; transform: translateY(-50%); flex-direction: column; align-items: stretch; overflow-y: auto; }
|
||||
.board-actions button { flex: 0 0 36px; width: 100%; padding: 0 7px; justify-content: flex-start; }
|
||||
.board-actions > span { flex: 0 0 1px; width: 100%; height: 1px; margin: 3px 0; }
|
||||
.board-actions > b { padding: 2px 0; text-align: center; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
||||
|
||||
+30
-1
@@ -1,4 +1,6 @@
|
||||
export type EvidenceType = 'folder' | 'evidence' | 'note' | 'event'
|
||||
export type EvidenceType = 'folder' | 'evidence' | 'note' | 'event' | 'party'
|
||||
export type PartyKind = 'person' | 'organization'
|
||||
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 interface DocumentRegion {
|
||||
@@ -32,6 +34,11 @@ export interface Evidence {
|
||||
sourceDocumentId?: string
|
||||
sourceRegionId?: string
|
||||
eventDate?: string
|
||||
supportingEvidenceIds?: string[]
|
||||
partyKind?: PartyKind
|
||||
organizationKind?: OrganizationKind
|
||||
aliases?: string[]
|
||||
relatedEvidenceIds?: string[]
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
@@ -53,9 +60,28 @@ export interface Connection {
|
||||
id: string
|
||||
fromEvidenceId: string
|
||||
toEvidenceId: string
|
||||
label?: string
|
||||
/** Percentage from slack (0) to taut (100). */
|
||||
tightness?: number
|
||||
tagStyle?: 'luggage' | 'compact'
|
||||
/** Percentage along the thread from its source exhibit. */
|
||||
tagPosition?: number
|
||||
/** Signed perpendicular distance from the thread, constrained by tightness. */
|
||||
tagOffset?: number
|
||||
}
|
||||
|
||||
export interface Viewport { x: number; y: number; zoom: number }
|
||||
export interface TimelineRange { start: string; end: string }
|
||||
|
||||
export interface BriefConcept {
|
||||
id: string
|
||||
label: string
|
||||
context: string
|
||||
expectedPartyKind?: PartyKind
|
||||
resolvedPartyExhibitId?: string
|
||||
}
|
||||
|
||||
export interface LevelBrief { body: string; concepts: BriefConcept[] }
|
||||
|
||||
export interface CaseState {
|
||||
id: string
|
||||
@@ -66,7 +92,10 @@ export interface CaseState {
|
||||
relations: WidgetRelation[]
|
||||
connections: Connection[]
|
||||
viewport: Viewport
|
||||
timelineRange?: TimelineRange | null
|
||||
brief: LevelBrief
|
||||
updatedAt?: string
|
||||
levelStatus?: string
|
||||
sourceTemplateVersionId?: string
|
||||
editingAllowed?: boolean
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,5 +9,5 @@
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts", "server/**/*.ts", "src/types.ts"]
|
||||
"include": ["vite.config.ts", "playwright.config.ts", "server/**/*.ts", "scripts/**/*.ts", "e2e/**/*.ts", "src/types.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user