Merge branch 'main' of ssh://ramanujan.glitch.university:2222/glitch-university/gupi-osint-board
This commit is contained in:
@@ -8,6 +8,7 @@ Status: accepted design foundation; the core schema, template lifecycle, fronten
|
||||
- An **exhibit type** describes its domain behavior: folder, document, clipping, note, event, party, or conclusion.
|
||||
- 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 **capture kind** describes how imported image evidence is understood and physically presented: photo, scene, clipping, full page, or not yet classified. It is independent of file format and document type.
|
||||
- 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.
|
||||
@@ -121,9 +122,17 @@ CREATE TABLE osint.document_types (
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE osint.document_capture_kinds (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
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),
|
||||
capture_kind_id TEXT NOT NULL DEFAULT 'unclassified'
|
||||
REFERENCES osint.document_capture_kinds(id),
|
||||
asset_id UUID REFERENCES osint.assets(id),
|
||||
title TEXT NOT NULL,
|
||||
published_at TIMESTAMPTZ,
|
||||
@@ -296,7 +305,7 @@ Red investigative thread is derived from `exhibit_connections`. Extraction prove
|
||||
|
||||
## Document metadata
|
||||
|
||||
Known, semantically important values remain real columns: `published_at`, `captured_at`, and `source_uri`. Truly author-defined fields use typed metadata definitions and values rather than JSONB or one untyped EAV table.
|
||||
Known, semantically important values remain real columns: `published_at`, `captured_at`, `source_uri`, and `capture_kind_id`. Capture kind is player-selected source interpretation used for physical board presentation and contextual connection copy; it never changes the immutable asset, OCR text, provenance, MIME type, or document type. Truly author-defined fields use typed metadata definitions and values rather than JSONB or one untyped EAV table.
|
||||
|
||||
```sql
|
||||
CREATE TABLE osint.metadata_fields (
|
||||
@@ -323,7 +332,7 @@ The following are computed and must not become duplicate source-of-truth tables:
|
||||
- Grey timeline projection: document exhibit position to `published_at` on the timeline.
|
||||
- Pale red containment band: folder position to contained exhibit position.
|
||||
- Folder flight animation: closed folder position to the exhibit's stored `xpos` and `ypos`.
|
||||
- Widget selection: `exhibit_type` plus optional `document_type` mapped through the frontend registry.
|
||||
- Widget selection: `exhibit_type` plus optional `document_type` mapped through the frontend registry. For imported image documents, `capture_kind` selects a physical presentation variant within that document widget.
|
||||
|
||||
## Transactional operations
|
||||
|
||||
|
||||
@@ -42,11 +42,27 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene
|
||||
expect(uploaded.analysis).toMatchObject({ extractionStatus:'succeeded',matchedFlags:['scene7.nils_inventor_proved'],
|
||||
goals:[expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:true })] })
|
||||
|
||||
await expect(page.getByRole('dialog',{ name:'What kind of evidence is this?' })).toBeVisible()
|
||||
await page.getByRole('button',{ name:'Classify as Clip' }).click()
|
||||
await expect(page.locator('.source-file-widget')).toHaveCount(1)
|
||||
await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','clipping')
|
||||
await expect(page.locator('.source-file-widget')).toHaveClass(/\barriving\b/)
|
||||
await expect(page.locator('.goal-complete-card')).toHaveCount(0)
|
||||
await expect(page.locator('.evidence-card.claim')).toContainText('Nils Aall Barricelli was an inventor')
|
||||
|
||||
await page.locator('.source-file-widget').dblclick()
|
||||
await page.getByRole('button',{ name:'TYPE',exact:true }).click()
|
||||
await page.getByRole('menuitemradio',{ name:/Mugshot/ }).click()
|
||||
await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','photo')
|
||||
await page.getByRole('button',{ name:'TYPE',exact:true }).click()
|
||||
await page.getByRole('menuitemradio',{ name:/Clip/ }).click()
|
||||
await expect(page.locator('.source-file-widget')).toHaveAttribute('data-capture-kind','clipping')
|
||||
await page.getByRole('button',{ name:'FILE',exact:true }).click()
|
||||
await page.getByRole('menuitem',{ name:/INFO/ }).click()
|
||||
await expect(page.getByLabel('Board presentation')).toHaveValue('clipping')
|
||||
await page.getByRole('button',{ name:'Close file editor' }).click()
|
||||
await page.getByRole('button',{ name:'Close document' }).click()
|
||||
|
||||
await page.locator('.evidence-card.claim').click()
|
||||
await page.getByRole('button',{ name:'Red thread' }).click()
|
||||
await page.locator('.source-file-widget').click()
|
||||
@@ -71,6 +87,8 @@ test('pasting, connecting, and citing one patent screenshot completes the Scene
|
||||
|
||||
const level = await (await request.get(`/api/levels/${sceneSeven.id}`)).json()
|
||||
expect(level.goals).toEqual([expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:false })])
|
||||
expect(level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document')).toHaveLength(1)
|
||||
expect(level.exhibits.filter((exhibit: { type:string }) => exhibit.type === 'document')).toEqual([
|
||||
expect.objectContaining({ captureKind:'clipping',width:210,height:194 }),
|
||||
])
|
||||
expect(level.report).toMatchObject({ status:'accepted',investigatorName:'Player' })
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- How an imported document is presented on the physical investigation board.
|
||||
-- This is deliberately separate from document_type_id/MIME type: the same PNG
|
||||
-- may be a photograph, a scene, a clipping, or a complete page.
|
||||
|
||||
CREATE TABLE osint.document_capture_kinds (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO osint.document_capture_kinds (id,name,description) VALUES
|
||||
('unclassified','Unclassified','Imported evidence that has not yet been classified.'),
|
||||
('photo','Photo','A person or object is the subject of the image.'),
|
||||
('scene','Scene','A place, situation, or event is shown.'),
|
||||
('clipping','Clipping','An extract captured from a larger source.'),
|
||||
('full_page','Full page','A complete page or document view.');
|
||||
|
||||
ALTER TABLE osint.document_exhibits
|
||||
ADD COLUMN capture_kind_id TEXT NOT NULL DEFAULT 'unclassified'
|
||||
REFERENCES osint.document_capture_kinds(id);
|
||||
|
||||
COMMENT ON COLUMN osint.document_exhibits.capture_kind_id IS
|
||||
'Player-selected evidentiary form used by board presentation and contextual connection copy; independent of the asset MIME type.';
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"slug": "barricelli-inventor-proof",
|
||||
"name": "Scene 7 · Prove Barricelli was an inventor",
|
||||
"title": "The Barricelli Files",
|
||||
"subtitle": "Scene 7 · Demonstrate OSINT skill",
|
||||
"brief": {
|
||||
"body": "DEMONSTRATE OSINT SKILL\n\nProve that Nils Aall Barricelli was an inventor. Find a reliable source online, take a screenshot, and paste it directly onto this board. Connect the source to the authored claim with red thread, explain what the evidence proves, and submit the Case Report.",
|
||||
"concepts": []
|
||||
},
|
||||
"narrative": {
|
||||
"cast": [],
|
||||
"graph": {
|
||||
"entry": "prove-inventor",
|
||||
"nodes": [
|
||||
{
|
||||
"key": "prove-inventor",
|
||||
"type": "level",
|
||||
"label": "Demonstrate OSINT skill",
|
||||
"templateSlug": "barricelli-inventor-proof",
|
||||
"x": 200,
|
||||
"y": 80,
|
||||
"terminals": [
|
||||
{ "key": "report_back", "label": "Submit finding", "to": "barricelli-luggage" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "barricelli-luggage",
|
||||
"type": "merit",
|
||||
"label": "The Barricelli Luggage",
|
||||
"awardsFlag": "barricelli_luggage",
|
||||
"x": 200,
|
||||
"y": 300,
|
||||
"terminals": [
|
||||
{ "key": "continue", "label": "Accept", "to": null }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"documents": [],
|
||||
"folders": [],
|
||||
"claims": [
|
||||
{
|
||||
"key": "nils-inventor",
|
||||
"statement": "Nils Aall Barricelli was an inventor.",
|
||||
"x": 940,
|
||||
"y": 360,
|
||||
"width": 330,
|
||||
"height": 190
|
||||
}
|
||||
],
|
||||
"report": {
|
||||
"title": "Barricelli Inventor Finding",
|
||||
"requiredForCompletion": true
|
||||
},
|
||||
"goals": [
|
||||
{
|
||||
"key": "barricelli.inventor-proof",
|
||||
"title": "Prove Nils Aall Barricelli was an inventor",
|
||||
"instructions": "Paste a reliable screenshot, connect it to the claim, and submit a properly cited Case Report.",
|
||||
"completionMessage": "CASE REPORT ACCEPTED — NILS AALL BARRICELLI: INVENTOR",
|
||||
"requiredFlags": ["scene7.nils_inventor_proved"]
|
||||
}
|
||||
],
|
||||
"evidenceMatchRules": [
|
||||
{
|
||||
"name": "Google Patents · GB695913A",
|
||||
"sourceLabel": "Google Patents · GB695913A · Improved chest of drawers",
|
||||
"sourceUri": "https://patents.google.com/patent/GB695913A/en",
|
||||
"flagKey": "scene7.nils_inventor_proved",
|
||||
"minimumAnchorMatches": 2,
|
||||
"anchors": [
|
||||
{ "phrase": "GB695913A Improved chest of drawers", "minimumSimilarity": 0.7 },
|
||||
{ "phrase": "Nils Aall Barricelli", "minimumSimilarity": 0.72 },
|
||||
{ "phrase": "695913 Chests of drawers BARRICELLI N A May 31 1951", "minimumSimilarity": 0.68 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Nasjonalbiblioteket · Patent 93585",
|
||||
"sourceLabel": "Nasjonalbiblioteket · Patent 93585 · Koffert-kommode",
|
||||
"sourceUri": "https://www.nb.no/items/921545b51acef0b05054cfc1f4666975?page=9&searchText=baricelli",
|
||||
"flagKey": "scene7.nils_inventor_proved",
|
||||
"minimumAnchorMatches": 2,
|
||||
"anchors": [
|
||||
{ "phrase": "Nr 75 348 Kl 33 b-9 Fra 31 mai 1948 93585 Koffert-kommode", "minimumSimilarity": 0.68 },
|
||||
{ "phrase": "Niels Aall Baricelli Oslo", "minimumSimilarity": 0.72 },
|
||||
{ "phrase": "Patentpaastand Kommode som er satt sammen av flere enkeltdeler som hver er utfort som koffert", "minimumSimilarity": 0.62 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceSemanticRules": [
|
||||
{
|
||||
"goalKey": "barricelli.inventor-proof",
|
||||
"name": "Barricelli inventor claim",
|
||||
"targetSubject": "Nils Aall Barricelli",
|
||||
"relatedSubject": "Nils Aall Barricelli's father",
|
||||
"assertion": "The source states or directly demonstrates that Nils Aall Barricelli was an inventor or a named patent applicant for an invention.",
|
||||
"successFlagKey": "scene7.nils_inventor_proved",
|
||||
"relatedFlagKey": "scene7.father_inventor_discovered",
|
||||
"minimumConfidence": 0.88,
|
||||
"evaluatorVersion": "barricelli_inventor_v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,12 +2,13 @@ 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, ClaimExhibit, PartyKind, SourceFileType } from '../src/types.js'
|
||||
import type { CaseDocument, CaseState, ClaimExhibit, DocumentCaptureKind, PartyKind, SourceFileType } from '../src/types.js'
|
||||
|
||||
type MysteryDocument = {
|
||||
key: string
|
||||
title: string
|
||||
fileType: SourceFileType
|
||||
captureKind?: DocumentCaptureKind
|
||||
publishedAt: string
|
||||
body?: string[]
|
||||
metadata?: Record<string, string>
|
||||
@@ -98,7 +99,7 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
|
||||
width: uploaded?.width || 174, height: uploaded?.height || 145, rotation: 0, zIndex: uploaded?.zIndex || 1, hidden: false,
|
||||
body: source.body || [], regions: [], assetId: uploaded?.assetId,
|
||||
fileName: uploaded?.fileName, mimeType: uploaded?.mimeType, fileSize: uploaded?.fileSize,
|
||||
fileType: source.fileType, metadata: source.metadata || {}, requiredFlags: source.requiredFlags || [],
|
||||
fileType: source.fileType, captureKind:source.captureKind || 'unclassified', metadata: source.metadata || {}, requiredFlags: source.requiredFlags || [],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -101,8 +101,8 @@ suite('normalized level persistence API', () => {
|
||||
timeline.range = { start: '2021-04-01', end: '2021-04-30' }
|
||||
state.viewport = { x: 91, y: -42, zoom: 0.85 }
|
||||
|
||||
const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {}, ...placed(1051, 417, 174, 145, 2) }
|
||||
const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 174, 145, 3) }
|
||||
const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', captureKind:'full_page',metadata: {}, ...placed(1051, 417, 205, 282, 2) }
|
||||
const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', captureKind:'clipping',metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 210, 178, 3) }
|
||||
const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
|
||||
const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) }
|
||||
const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
|
||||
@@ -123,6 +123,7 @@ suite('normalized level persistence API', () => {
|
||||
const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
|
||||
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
|
||||
expect(loaded.exhibits.find(item => item.id === document.id)).toMatchObject({ captureKind:'full_page',width:205,height:282 })
|
||||
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
|
||||
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
|
||||
expect(loaded.exhibits.find(item => item.id === gatedDocument.id)).toMatchObject({ requiredFlags: ['tip.received'] })
|
||||
@@ -221,7 +222,7 @@ suite('normalized level persistence API', () => {
|
||||
screenshot.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), 'Screenshot 2026-08-22.png')
|
||||
const screenshotResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: screenshot })
|
||||
expect(screenshotResponse.status).toBe(201)
|
||||
expect(await screenshotResponse.json()).toMatchObject({ type: 'document', fileType: 'image', fileName: 'Screenshot 2026-08-22.png' })
|
||||
expect(await screenshotResponse.json()).toMatchObject({ type: 'document', fileType: 'image',captureKind:'unclassified',fileName: 'Screenshot 2026-08-22.png' })
|
||||
|
||||
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)
|
||||
@@ -235,7 +236,7 @@ suite('normalized level persistence API', () => {
|
||||
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
|
||||
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
|
||||
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
||||
expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ requiredFlags: ['tip.received'] })
|
||||
expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ captureKind:'clipping',requiredFlags: ['tip.received'] })
|
||||
expect(await (await adminFetch(`${baseUrl}/api/levels/${clone.id}/evidence-match-rules`)).json()).toEqual([
|
||||
expect.objectContaining({ name: 'Smoke source passage', sourceLabel: 'Archive smoke test', sourceUri: 'https://example.test/archive/smoke',
|
||||
flagKey: 'tip.received', anchors: [expect.objectContaining({ phrase: 'OSINT smoke evidence from the archive' })] }),
|
||||
|
||||
@@ -66,11 +66,11 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
|
||||
|
||||
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; citation_text:string
|
||||
capture_kind_id:string; published_at: Date | null; captured_at: Date | null; source_uri: string | null; citation_text:string
|
||||
}>(`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,citation_text) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
||||
[mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri,row.citation_text])
|
||||
(exhibit_id,document_type_id,capture_kind_id,asset_id,title,published_at,captured_at,source_uri,citation_text) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[mapped(exhibitIds, row.exhibit_id, 'document'), row.document_type_id, row.capture_kind_id, row.asset_id, row.title, row.published_at, row.captured_at, row.source_uri,row.citation_text])
|
||||
|
||||
const citations = await client.query<{ exhibit_id:string;display_number:number }>(
|
||||
'SELECT exhibit_id,display_number FROM osint.exhibit_citations WHERE board_id=$1 ORDER BY display_number',[sourceBoardId])
|
||||
|
||||
@@ -53,7 +53,7 @@ state.brief = { body: 'Classify the named people and organizations in this inves
|
||||
] }
|
||||
state.exhibits = [{
|
||||
id: documentId, type: 'document', title: 'Dated source image', publishedAt: '2021-04-17T12:00:00.000Z',
|
||||
body: [], regions: [], fileType: 'image', metadata: {}, x: 980, y: 360, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false,
|
||||
body: [], regions: [], fileType: 'image', captureKind:'scene',metadata: {}, x: 980, y: 360, width: 244, height: 200, rotation: 0, zIndex: 2, hidden: false,
|
||||
}, {
|
||||
id: folderId, type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence',
|
||||
x: 600, y: 360, width: 260, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { Readable } from 'node:stream'
|
||||
import type { Pool, PoolClient } from 'pg'
|
||||
import type { BoardView, BriefConcept, CaseDocument, CaseState, DocumentSemanticAnalysis, Evidence, EvidenceMatchRuleDefinition, EvidenceSemanticRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, LevelGoal, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js'
|
||||
import type { BoardView, BriefConcept, CaseDocument, CaseState, DocumentCaptureKind, DocumentSemanticAnalysis, Evidence, EvidenceMatchRuleDefinition, EvidenceSemanticRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, LevelGoal, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js'
|
||||
import { isClaimExhibit, isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
|
||||
import { clearBoard, cloneBoard } from './boardClone.js'
|
||||
import { loadCaseReport } from './caseReports.js'
|
||||
@@ -82,6 +82,7 @@ type LevelRow = {
|
||||
type ExhibitRow = {
|
||||
id: string; exhibit_type_id: Exhibit['type']; xpos: number; ypos: number; width: number; height: number; rotation: number; z_index: number; hidden: boolean
|
||||
title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null
|
||||
capture_kind_id: DocumentCaptureKind | null
|
||||
asset_id: string | null; published_at: Date | null; occurred_at: Date | null
|
||||
captured_at: Date | null; source_uri: string | null; citation_text: string | null; display_number: number | null; statement: string | null
|
||||
original_name: string | null; mime_type: string | null; byte_size: string | null
|
||||
@@ -161,6 +162,10 @@ 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 documentCaptureKind(value: unknown): DocumentCaptureKind {
|
||||
const allowed: DocumentCaptureKind[] = ['unclassified','photo','scene','clipping','full_page']
|
||||
return allowed.includes(value as DocumentCaptureKind) ? value as DocumentCaptureKind : 'unclassified'
|
||||
}
|
||||
export function createLevelRepository(pool: Pool, editingEnabled: boolean, objectStorage: ObjectStorage, evidenceJudge: EvidenceJudge): LevelRepository {
|
||||
async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
|
||||
const result = await client.query<LevelRow>(`SELECT l.id,l.slug,l.board_id,l.title,l.subtitle,l.status,
|
||||
@@ -313,7 +318,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
|
||||
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') 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, d.captured_at, d.source_uri,d.citation_text,citation.display_number,
|
||||
f.is_open, d.document_type_id, d.capture_kind_id, d.asset_id, d.published_at, d.captured_at, d.source_uri,d.citation_text,citation.display_number,
|
||||
ev.occurred_at,claim.statement,
|
||||
p.party_kind, op.organization_kind,
|
||||
a.original_name, a.mime_type, a.byte_size,
|
||||
@@ -400,7 +405,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
sourceCitation:row.citation_text || undefined,displayNumber:row.display_number || undefined,requiredFlags: requirements.get(row.id) || [],
|
||||
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) || {} }
|
||||
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type,
|
||||
captureKind: documentCaptureKind(row.capture_kind_id), metadata: metadata.get(row.id) || {} }
|
||||
})
|
||||
const evidence: Evidence[] = []
|
||||
for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document')) {
|
||||
@@ -493,10 +499,11 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
}
|
||||
let nextCitation = Number((await client.query<{ maximum:number }>('SELECT COALESCE(MAX(display_number),0)::int AS maximum FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])).rows[0].maximum)
|
||||
for (const document of documents) {
|
||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri,citation_text)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (exhibit_id) DO UPDATE SET
|
||||
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,capture_kind_id,asset_id,title,published_at,captured_at,source_uri,citation_text)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (exhibit_id) DO UPDATE SET
|
||||
document_type_id=EXCLUDED.document_type_id,asset_id=EXCLUDED.asset_id,title=EXCLUDED.title,published_at=EXCLUDED.published_at,
|
||||
captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`, [document.id, documentType(document), document.assetId || null, document.title,
|
||||
capture_kind_id=EXCLUDED.capture_kind_id,captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`,
|
||||
[document.id, documentType(document), documentCaptureKind(document.captureKind), document.assetId || null, document.title,
|
||||
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null,document.sourceCitation || ''])
|
||||
const existingCitation = await client.query<{ display_number:number }>('SELECT display_number FROM osint.exhibit_citations WHERE board_id=$1 AND exhibit_id=$2',[level.board_id,document.id])
|
||||
if (!existingCitation.rows[0]) {
|
||||
@@ -818,7 +825,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean, objec
|
||||
await client.query('COMMIT')
|
||||
return { id: exhibitId,type:'document',title:file.originalname,x:xpos,y:ypos,width:174,height:145,rotation:0,zIndex:0,hidden:false,
|
||||
displayNumber:citation.rows[0].display_number,
|
||||
fileType,metadata:{},body:extraction.status === 'succeeded' && extraction.text.trim() ? [extraction.text.trim()] : [],regions:[],assetId,
|
||||
fileType,captureKind:'unclassified',metadata:{},body:extraction.status === 'succeeded' && extraction.text.trim() ? [extraction.text.trim()] : [],regions:[],assetId,
|
||||
fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size,
|
||||
analysis:{ extractionStatus:extraction.status, matchedFlags:[...new Set(matchedFlags)], awardedFlags:[...new Set(awardedFlags)], goals } }
|
||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { CaseState, DocumentExhibit, NoteExhibit } from '../src/types.js'
|
||||
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js'
|
||||
|
||||
const placed = { x: 10, y: 20, width: 174, height: 145, rotation: 0, zIndex: 1, hidden: false }
|
||||
const open: DocumentExhibit = { id: 'open', type: 'document', title: 'Open', body: [], regions: [], fileType: 'image', metadata: {}, ...placed }
|
||||
const gated: DocumentExhibit = { id: 'gated', type: 'document', title: 'Gated', body: [], regions: [], fileType: 'image', metadata: {}, requiredFlags: ['tip.received'], ...placed }
|
||||
const open: DocumentExhibit = { id: 'open', type: 'document', title: 'Open', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, ...placed }
|
||||
const gated: DocumentExhibit = { id: 'gated', type: 'document', title: 'Gated', body: [], regions: [], fileType: 'image', captureKind:'unclassified',metadata: {}, requiredFlags: ['tip.received'], ...placed }
|
||||
const note: NoteExhibit = { id: 'note', type: 'note', title: 'Note', content: '', ...placed }
|
||||
const state: CaseState = {
|
||||
id: 'demo', title: 'Demo', subtitle: '', exhibits: [open, gated, note], viewport: { x: 0, y: 0, zoom: 1 },
|
||||
|
||||
@@ -54,6 +54,7 @@ suite('PostgreSQL migrations', () => {
|
||||
'level_goals', 'level_goal_flag_requirements',
|
||||
'evidence_semantic_rules', 'evidence_semantic_evaluations',
|
||||
'claim_exhibits', 'exhibit_citations', 'case_reports', 'case_report_submissions', 'case_report_submission_issues',
|
||||
'document_capture_kinds',
|
||||
]))
|
||||
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'cutscenes', 'dialogue_steps', 'mystery_chapters', 'seen_dialogue']))
|
||||
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
|
||||
|
||||
+91
-14
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, ClipboardCheck, 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, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||
import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||
import { AdminPanel } from './admin'
|
||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||
import type { PlaythroughState } from './narrative'
|
||||
|
||||
const BOARD_W = 2400
|
||||
@@ -20,7 +20,7 @@ function screenshotFile(file: File, index: number) {
|
||||
}
|
||||
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
||||
function documentSearchText(document: CaseDocument) {
|
||||
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType,
|
||||
return [document.title, document.fileType, document.captureKind,document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,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()
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export function App() {
|
||||
const [clock, setClock] = useState('')
|
||||
const [draggingFiles, setDraggingFiles] = useState(false)
|
||||
const [uploading, setUploading] = useState(0)
|
||||
const [documentClassificationQueue, setDocumentClassificationQueue] = useState<string[]>([])
|
||||
const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move')
|
||||
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
|
||||
const [editingFileId, setEditingFileId] = useState<string | null>(null)
|
||||
@@ -275,7 +276,10 @@ export function App() {
|
||||
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
||||
return
|
||||
}
|
||||
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, label:'Proof that…',tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
|
||||
const source = caseState.exhibits.find(exhibit => exhibit.id === linkFrom)
|
||||
const target = caseState.exhibits.find(exhibit => exhibit.id === targetId)
|
||||
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId,
|
||||
label:source && target ? defaultConnectionLabel(source,target) : 'Proof that…',tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
|
||||
setLinkFrom(null)
|
||||
if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId)
|
||||
}
|
||||
@@ -421,12 +425,24 @@ export function App() {
|
||||
} else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
|
||||
else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE')
|
||||
else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`)
|
||||
if (document.fileType === 'image') setDocumentClassificationQueue(current => [...new Set([...current,document.id])])
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
|
||||
} finally { setUploading(count => count - 1) }
|
||||
}
|
||||
}, [caseState, loadLevelBySlug, requestedEditMode, update])
|
||||
|
||||
const classifyDocument = (documentId:string,captureKind:DocumentCaptureKind) => {
|
||||
const presentation=documentCapture(captureKind)
|
||||
const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height })
|
||||
update(state => ({ ...state,exhibits:state.exhibits.map(exhibit => exhibit.id === documentId && exhibit.type === 'document'
|
||||
? classify(exhibit)
|
||||
: exhibit) }))
|
||||
setOpenDoc(current => current?.id === documentId ? classify(current) : current)
|
||||
setDocumentClassificationQueue(current => current.filter(id => id !== documentId))
|
||||
setStatus(captureKind === 'unclassified' ? 'EVIDENCE SAVED · CLASSIFY IT LATER IN METADATA' : `${presentation.label.toUpperCase()} CLASSIFICATION SAVED`)
|
||||
}
|
||||
|
||||
const continueAfterGoal = async () => {
|
||||
if (!activePlaythroughId) { setCompletedGoal(null); return }
|
||||
setAdvancing(true)
|
||||
@@ -501,6 +517,7 @@ export function App() {
|
||||
const documentById = new Map(documents.map(document => [document.id, document]))
|
||||
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
|
||||
const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents
|
||||
const classificationDocument = documentClassificationQueue.length ? documents.find(document => document.id === documentClassificationQueue[0]) || null : null
|
||||
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
|
||||
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
|
||||
@@ -553,7 +570,7 @@ export function App() {
|
||||
<div className="doc-list">
|
||||
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} 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.fileType.slice(0, 3)}</b></div>
|
||||
<div><strong>{doc.title}</strong><span>EXHIBIT {doc.displayNumber || index + 1} · {documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
|
||||
<div><strong>{doc.title}</strong><span>EXHIBIT {doc.displayNumber || index + 1} · {doc.captureKind === 'unclassified' ? documentWidget(doc.fileType).label : documentCapture(doc.captureKind).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</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>
|
||||
@@ -600,7 +617,11 @@ export function App() {
|
||||
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.exhibits.map(e => `${e.id}:${e.x}:${e.y}:${e.type === 'folder' ? e.isOpen : ''}`).join('|')}`}/>
|
||||
{timelineView?.visible !== false && <Timeline items={temporalItems} range={timelineView?.rangeMode === 'fixed' ? timelineView.range : undefined} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { const exhibit = caseState.exhibits.find(candidate => candidate.id === item.exhibitId); if (exhibit?.type === 'document') setOpenDoc(exhibit); else focusEvidence(item.exhibitId) }}/>
|
||||
}
|
||||
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.relations.flatMap(relation => relation.type === 'source' && relation.toExhibitId === openDoc.id ? [relation.sourceRegionId] : [])} />}
|
||||
{classificationDocument && <DocumentClassificationPanel document={classificationDocument}
|
||||
onChoose={captureKind => classifyDocument(classificationDocument.id,captureKind)}/>}
|
||||
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onInfo={() => setEditingFileId(openDoc.id)}
|
||||
onDelete={() => { if (window.confirm(`Delete “${openDoc.title}” from this board? Its connections and folder membership will also be removed.`)) { removeExhibit(openDoc.id); setOpenDoc(null) } }}
|
||||
onType={captureKind => classifyDocument(openDoc.id,captureKind)} onExtract={id => extract(openDoc, id)} extracted={caseState.relations.flatMap(relation => relation.type === 'source' && relation.toExhibitId === openDoc.id ? [relation.sourceRegionId] : [])} />}
|
||||
{editingFolderId && <FolderEditor
|
||||
key={editingFolderId}
|
||||
folder={caseState.exhibits.find((widget): widget is FolderExhibit => widget.id === editingFolderId && widget.type === 'folder')!}
|
||||
@@ -621,7 +642,7 @@ export function App() {
|
||||
setStatus('FOLDER UPDATED')
|
||||
}}
|
||||
/>}
|
||||
{editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>
|
||||
{editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setOpenDoc(current => current?.id === document.id ? document : current); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>
|
||||
}
|
||||
{editingEventId && <EventEditor
|
||||
key={editingEventId}
|
||||
@@ -925,10 +946,10 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
|
||||
<header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
||||
<Widget exhibit={ev} context={widgetContext}/>
|
||||
</article>})}
|
||||
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
||||
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const presentation=documentCapture(document.captureKind); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} data-capture-kind={document.captureKind} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
||||
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
||||
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
||||
<header><span>{definition.label.toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||
<header><span>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
|
||||
<strong>{document.title}</strong><time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
|
||||
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
|
||||
@@ -1242,16 +1263,42 @@ function EventEditor({ event, exhibits, relations, onClose, onSave }: { event: E
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function CaptureKindIcon({ kind }:{ kind:Exclude<DocumentCaptureKind,'unclassified'> }) {
|
||||
if (kind === 'photo') return <Camera size={24}/>
|
||||
if (kind === 'scene') return <Images size={24}/>
|
||||
if (kind === 'clipping') return <Newspaper size={24}/>
|
||||
return <FileText size={24}/>
|
||||
}
|
||||
|
||||
function DocumentClassificationPanel({ document,onChoose }:{ document:CaseDocument;onChoose:(kind:DocumentCaptureKind)=>void }) {
|
||||
const choices=(Object.keys(documentCaptureRegistry) as DocumentCaptureKind[]).filter((kind):kind is Exclude<DocumentCaptureKind,'unclassified'> => kind !== 'unclassified')
|
||||
const source=document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''
|
||||
return <div className="modal-shade evidence-classification-shade"><section className="evidence-classification" role="dialog" aria-modal="true" aria-labelledby="evidence-classification-title">
|
||||
<header><ClipboardCheck size={16}/><b>Classify new evidence</b><span/><button type="button" aria-label="Classify later" onClick={() => onChoose('unclassified')}><X size={14}/></button></header>
|
||||
<div className="evidence-classification-body">
|
||||
<div className="classification-source">{source ? <img src={source} alt=""/> : <ImageIcon size={36}/>}<div><small>EXHIBIT {document.displayNumber || '—'}</small><strong>{document.title}</strong></div></div>
|
||||
<div className="classification-question"><small>NEW SOURCE DOCUMENT</small><h2 id="evidence-classification-title">What kind of evidence is this?</h2><p>This changes how it appears on the board. It does not alter the original file, OCR, or provenance.</p></div>
|
||||
<div className="classification-options">{choices.map(kind => { const definition=documentCapture(kind); return <button type="button" key={kind} aria-label={`Classify as ${definition.label}`} onClick={() => onChoose(kind)}>
|
||||
<CaptureKindIcon kind={kind}/><span><b>{definition.label}</b><small>{definition.description}</small></span>
|
||||
</button> })}</div>
|
||||
<button type="button" className="classify-later" onClick={() => onChoose('unclassified')}>NOT SURE · CLASSIFY LATER</button>
|
||||
</div>
|
||||
</section></div>
|
||||
}
|
||||
|
||||
function FileEditor({ document, canEditGates, onClose, onSave }: { document: CaseDocument; canEditGates: boolean; onClose: () => void; onSave: (document: CaseDocument) => void }) {
|
||||
const [title, setTitle] = useState(document.title)
|
||||
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
|
||||
const [captureKind, setCaptureKind] = useState<DocumentCaptureKind>(document.captureKind)
|
||||
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt))
|
||||
const [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', '))
|
||||
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined
|
||||
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt,
|
||||
const presentation=documentCapture(captureKind)
|
||||
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType,captureKind,publishedAt,
|
||||
...(captureKind !== document.captureKind ? presentation.defaultSize : {}),
|
||||
requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags,
|
||||
metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
|
||||
}
|
||||
@@ -1263,6 +1310,7 @@ function FileEditor({ document, canEditGates, onClose, onSave }: { document: Cas
|
||||
<label className="field"><span>TITLE</span><input value={title} onChange={event => setTitle(event.target.value)}/></label>
|
||||
<label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
|
||||
</div>
|
||||
<label className="field"><span>BOARD PRESENTATION</span><select aria-label="Board presentation" value={captureKind} onChange={event => setCaptureKind(event.target.value as DocumentCaptureKind)}>{Object.entries(documentCaptureRegistry).map(([kind,definition]) => <option key={kind} value={kind}>{definition.label} — {definition.description}</option>)}</select></label>
|
||||
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
|
||||
{canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
|
||||
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
|
||||
@@ -1385,10 +1433,28 @@ function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClo
|
||||
</section></div>
|
||||
}
|
||||
|
||||
function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => void; onExtract: (id: string) => void; extracted: (string | undefined)[] }) {
|
||||
const DOCUMENT_TYPE_MENU:Exclude<DocumentCaptureKind,'unclassified'>[] = ['full_page','scene','photo','clipping']
|
||||
|
||||
function DocumentWindow({ doc, onClose, onInfo, onDelete, onType, onExtract, extracted }: {
|
||||
doc: CaseDocument
|
||||
onClose: () => void
|
||||
onInfo: () => void
|
||||
onDelete: () => void
|
||||
onType: (captureKind:Exclude<DocumentCaptureKind,'unclassified'>) => void
|
||||
onExtract: (id: string) => void
|
||||
extracted: (string | undefined)[]
|
||||
}) {
|
||||
const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 })
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
const [menu, setMenu] = useState<'file'|'type'|null>(null)
|
||||
const drag = useRef<{ x: number; y: number; px: number; py: number } | null>(null)
|
||||
const menuRef = useRef<HTMLElement>(null)
|
||||
useEffect(() => {
|
||||
if (!menu) return
|
||||
const closeMenu = (event:PointerEvent) => { if (!menuRef.current?.contains(event.target as Node)) setMenu(null) }
|
||||
document.addEventListener('pointerdown',closeMenu)
|
||||
return () => document.removeEventListener('pointerdown',closeMenu)
|
||||
},[menu])
|
||||
const startDrag = (e: React.PointerEvent<HTMLElement>) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return
|
||||
drag.current = { x: e.clientX, y: e.clientY, px: pos.x, py: pos.y }
|
||||
@@ -1396,8 +1462,19 @@ function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocum
|
||||
}
|
||||
return <section className={`window document-window ${minimized ? 'minimized' : ''}`} style={{ left: pos.x, top: pos.y }}>
|
||||
<header onPointerDown={startDrag} onPointerMove={e => drag.current && setPos({ x: drag.current.px + e.clientX - drag.current.x, y: drag.current.py + e.clientY - drag.current.y })} onPointerUp={() => { drag.current = null }} onDoubleClick={() => setMinimized(v => !v)}><FileText size={15}/><b>{doc.title}</b><span/><button type="button" aria-label={minimized ? 'Restore document' : 'Minimize document'} title={minimized ? 'Restore' : 'Minimize'} onPointerDown={e => e.stopPropagation()} onClick={() => setMinimized(v => !v)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close document" title="Close" onPointerDown={e => e.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
|
||||
{!minimized && <><nav>FILE EDIT EVIDENCE VIEW</nav>
|
||||
<div className={`paper ${doc.assetId ? 'asset-paper' : ''}`}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{documentWidget(doc.fileType).label}</b></div>{doc.assetId ? <DocumentAsset doc={doc}/> : doc.body.map((line, i) => <p key={i}>{line}</p>)}{doc.regions.length > 0 && <div className="extracts">{doc.regions.map(r => <button key={r.id} className={extracted.includes(r.id) ? 'done' : ''} onClick={() => onExtract(r.id)}><Network size={15}/>{extracted.includes(r.id) ? 'LOCATE ON BOARD' : r.label}</button>)}</div>}</div>
|
||||
{!minimized && <><nav ref={menuRef} className="document-menu-bar" aria-label="Document actions">
|
||||
<div className="document-menu"><button type="button" aria-haspopup="menu" aria-expanded={menu === 'file'} onClick={() => setMenu(current => current === 'file' ? null : 'file')}>FILE</button>
|
||||
{menu === 'file' && <div className="document-menu-items" role="menu">
|
||||
<button type="button" role="menuitem" onClick={() => { setMenu(null);onInfo() }}><Info size={13}/><span><b>INFO</b><small>Metadata and provenance</small></span></button>
|
||||
<button type="button" role="menuitem" className="danger" onClick={() => { setMenu(null);onDelete() }}><Trash2 size={13}/><span><b>DELETE</b><small>Remove from this board</small></span></button>
|
||||
</div>}
|
||||
</div>
|
||||
<div className="document-menu"><button type="button" aria-haspopup="menu" aria-expanded={menu === 'type'} onClick={() => setMenu(current => current === 'type' ? null : 'type')}>TYPE</button>
|
||||
{menu === 'type' && <div className="document-menu-items type-menu" role="menu">{DOCUMENT_TYPE_MENU.map(kind => { const definition=documentCapture(kind);return <button type="button" role="menuitemradio" aria-checked={doc.captureKind === kind} key={kind} onClick={() => { onType(kind);setMenu(null) }}>
|
||||
<CaptureKindIcon kind={kind}/><span><b>{definition.label}</b><small>{definition.description}</small></span>{doc.captureKind === kind && <Check className="menu-check" size={13}/>}</button> })}</div>}
|
||||
</div>
|
||||
</nav>
|
||||
<div className={`paper ${doc.assetId ? 'asset-paper' : ''}`} onPointerDown={() => setMenu(null)}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{doc.captureKind === 'unclassified' ? documentWidget(doc.fileType).label : documentCapture(doc.captureKind).label}</b></div>{doc.assetId ? <DocumentAsset doc={doc}/> : doc.body.map((line, i) => <p key={i}>{line}</p>)}{doc.regions.length > 0 && <div className="extracts">{doc.regions.map(r => <button key={r.id} className={extracted.includes(r.id) ? 'done' : ''} onClick={() => onExtract(r.id)}><Network size={15}/>{extracted.includes(r.id) ? 'LOCATE ON BOARD' : r.label}</button>)}</div>}</div>
|
||||
<footer><span>ARCHIVE ITEM · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span><span>PROVENANCE LOCKED</span></footer></>}
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('folder domain behavior', () => {
|
||||
})
|
||||
|
||||
it('retains a configured expanded file position', () => {
|
||||
const document = { id: 'doc-2', type: 'document' as const, title: 'Source', x: 720, y: 415, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, metadata: {} }
|
||||
const document = { id: 'doc-2', type: 'document' as const, title: 'Source', x: 720, y: 415, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, captureKind:'unclassified' as const, metadata: {} }
|
||||
expect(relationPosition({ ...state, exhibits: [...state.exhibits, document], relations }, relations[0])).toEqual({ x: 720, y: 415 })
|
||||
})
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('folder domain behavior', () => {
|
||||
}
|
||||
const normalized = normalizeCase(legacy)
|
||||
expect(normalized.exhibits.find(exhibit => exhibit.type === 'folder')).toMatchObject({ type: 'folder', isOpen: false })
|
||||
expect(normalized.exhibits.find(exhibit => exhibit.type === 'document')).toMatchObject({ fileType: 'image', metadata: {} })
|
||||
expect(normalized.exhibits.find(exhibit => exhibit.type === 'document')).toMatchObject({ fileType: 'image', captureKind:'unclassified',metadata: {} })
|
||||
expect(normalized.relations).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -197,7 +197,7 @@ describe('exhibit disposal', () => {
|
||||
const note = { ...folder, id: 'note-1', type: 'note' as const, title: 'Working note' }
|
||||
const event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined }
|
||||
const party = { ...folder, id: 'party-1', type: 'party' as const, partyKind: 'person' as const, aliases: [] }
|
||||
const document = { id: 'doc-1', type: 'document' as const, title: 'Source', x: 20, y: 20, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, metadata: {} }
|
||||
const document = { id: 'doc-1', type: 'document' as const, title: 'Source', x: 20, y: 20, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, captureKind:'unclassified' as const, metadata: {} }
|
||||
const discarded = discardExhibit({
|
||||
...state,
|
||||
exhibits: [folder, document, note, event, party],
|
||||
|
||||
+10
-2
@@ -1,4 +1,4 @@
|
||||
import type { BoardView, CaseState, Connection, Exhibit, ExhibitRelation, FolderExhibit, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types'
|
||||
import type { BoardView, CaseState, Connection, DocumentCaptureKind, Exhibit, ExhibitRelation, FolderExhibit, OrganizationKind, SourceFileType, TimelineRange, Viewport } from './types'
|
||||
|
||||
export interface BoardPoint { x: number; y: number }
|
||||
|
||||
@@ -233,6 +233,11 @@ function sourceFileType(value: unknown, mimeType: unknown): SourceFileType {
|
||||
return String(mimeType || '').startsWith('image/') ? 'image' : mimeType === 'application/pdf' ? 'pdf' : 'file'
|
||||
}
|
||||
|
||||
function documentCaptureKind(value: unknown): DocumentCaptureKind {
|
||||
const allowed: DocumentCaptureKind[] = ['unclassified', 'photo', 'scene', 'clipping', 'full_page']
|
||||
return allowed.includes(value as DocumentCaptureKind) ? value as DocumentCaptureKind : 'unclassified'
|
||||
}
|
||||
|
||||
/** Normalizes current API state and upgrades disposable pre-registry browser caches. */
|
||||
export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
||||
const state = input as LegacyCaseState
|
||||
@@ -243,7 +248,9 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
||||
goals: state.goals || [],
|
||||
report: state.report,
|
||||
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
||||
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
|
||||
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit,
|
||||
...(exhibit.type === 'document' ? { captureKind: documentCaptureKind(exhibit.captureKind) } : {}),
|
||||
...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
|
||||
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
||||
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [],
|
||||
}
|
||||
@@ -264,6 +271,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
||||
fileName: String(document.fileName || '') || undefined, mimeType: String(document.mimeType || '') || undefined,
|
||||
fileSize: document.fileSize === undefined ? undefined : Number(document.fileSize),
|
||||
fileType: sourceFileType(document.fileType, document.mimeType),
|
||||
captureKind: documentCaptureKind(document.captureKind),
|
||||
metadata: document.metadata && typeof document.metadata === 'object' ? document.metadata as Record<string, string> : {},
|
||||
} as Exhibit))
|
||||
const evidence: Exhibit[] = legacyEvidence.map((item, index) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SourceFileType } from './types'
|
||||
import { documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry'
|
||||
import type { DocumentCaptureKind, DocumentExhibit, SourceFileType } from './types'
|
||||
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentWidget, documentWidgetRegistry, exhibitWidget, exhibitWidgetRegistry } from './exhibitRegistry'
|
||||
|
||||
describe('frontend exhibit registry', () => {
|
||||
it('registers every normalized exhibit type', () => {
|
||||
@@ -18,4 +18,21 @@ describe('frontend exhibit registry', () => {
|
||||
expect(documentWidget(type).Asset).toBeTypeOf('function')
|
||||
}
|
||||
})
|
||||
|
||||
it('registers every capture kind with a physical board size', () => {
|
||||
const kinds:DocumentCaptureKind[]=['unclassified','photo','scene','clipping','full_page']
|
||||
expect(Object.keys(documentCaptureRegistry).sort()).toEqual(kinds.sort())
|
||||
for (const kind of kinds) expect(documentCapture(kind).defaultSize).toMatchObject({ width:expect.any(Number),height:expect.any(Number) })
|
||||
expect(['full_page','scene','photo','clipping'].map(kind => documentCapture(kind as DocumentCaptureKind).label)).toEqual(['Document','Image','Mugshot','Clip'])
|
||||
})
|
||||
|
||||
it('uses structural and image-aware starter copy instead of calling a Party proof', () => {
|
||||
const placed={ x:0,y:0,width:100,height:100,rotation:0,zIndex:1,hidden:false }
|
||||
const party={ id:'party',type:'party' as const,title:'Nils',content:'',partyKind:'person' as const,aliases:[],...placed }
|
||||
const claim={ id:'claim',type:'claim' as const,title:'Inventor',statement:'Nils was an inventor.',...placed }
|
||||
const photo:DocumentExhibit={ id:'photo',type:'document',title:'Portrait',body:[],regions:[],fileType:'image',captureKind:'photo',metadata:{},...placed }
|
||||
expect(defaultConnectionLabel(party,claim)).toBe('Subject of claim')
|
||||
expect(defaultConnectionLabel(photo,party)).toBe('Depicts…')
|
||||
expect(defaultConnectionLabel(photo,claim)).toBe('Proof that…')
|
||||
})
|
||||
})
|
||||
|
||||
+28
-1
@@ -1,6 +1,6 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { BadgeCheck, BookOpen, Building2, CalendarClock, FileText, Image as ImageIcon, Pencil, UserRound } from 'lucide-react'
|
||||
import type { CaseDocument, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
|
||||
import type { CaseDocument, DocumentCaptureKind, DocumentExhibit, Evidence, Exhibit, ExhibitRelation, ExhibitType, SourceFileType, TemporalFact } from './types'
|
||||
|
||||
export type WidgetCommand =
|
||||
| { type: 'open-document'; documentId: string }
|
||||
@@ -135,5 +135,32 @@ export const documentWidgetRegistry:Record<SourceFileType,DocumentWidgetDefiniti
|
||||
}
|
||||
export function documentWidget(type:SourceFileType) { return documentWidgetRegistry[type] || documentWidgetRegistry.file }
|
||||
|
||||
export type DocumentCaptureDefinition = {
|
||||
label:string
|
||||
description:string
|
||||
defaultSize:{ width:number;height:number }
|
||||
}
|
||||
export const documentCaptureRegistry:Record<DocumentCaptureKind,DocumentCaptureDefinition> = {
|
||||
unclassified:{ label:'Not sure',description:'Keep the standard evidence card for now.',defaultSize:{width:174,height:145} },
|
||||
photo:{ label:'Mugshot',description:'A portrait or identifying photograph.',defaultSize:{width:188,height:240} },
|
||||
scene:{ label:'Image',description:'A place, situation, object, or event is shown.',defaultSize:{width:244,height:200} },
|
||||
clipping:{ label:'Clip',description:'An extract captured from a larger source.',defaultSize:{width:210,height:194} },
|
||||
full_page:{ label:'Document',description:'A complete page or formal document view.',defaultSize:{width:205,height:294} },
|
||||
}
|
||||
export function documentCapture(kind:DocumentCaptureKind) { return documentCaptureRegistry[kind] || documentCaptureRegistry.unclassified }
|
||||
|
||||
/** Context-sensitive starter copy. A Party is never treated as proof by default. */
|
||||
export function defaultConnectionLabel(first:Exhibit,second:Exhibit) {
|
||||
const types = new Set([first.type,second.type])
|
||||
if (types.has('party') && types.has('claim')) return 'Subject of claim'
|
||||
const document = first.type === 'document' ? first : second.type === 'document' ? second : null
|
||||
if (document && types.has('party')) {
|
||||
if (document.captureKind === 'photo') return 'Depicts…'
|
||||
if (document.captureKind === 'scene') return 'Shows…'
|
||||
return 'Concerns…'
|
||||
}
|
||||
return 'Proof that…'
|
||||
}
|
||||
|
||||
export function documentExhibits(exhibits:Exhibit[]):DocumentExhibit[] { return exhibits.filter((exhibit):exhibit is DocumentExhibit => exhibit.type === 'document') }
|
||||
export function evidenceExhibits(exhibits:Exhibit[]):Evidence[] { return exhibits.filter((exhibit):exhibit is Evidence => exhibit.type !== 'document') }
|
||||
|
||||
+74
-1
@@ -169,6 +169,63 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.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; }
|
||||
|
||||
/* Classified image evidence is rendered like a physical object on the corkboard.
|
||||
The unclassified shell above remains the neutral Win-95 archive card. */
|
||||
.source-file-widget.capture-kind-photo,
|
||||
.source-file-widget.capture-kind-scene,
|
||||
.source-file-widget.capture-kind-clipping,
|
||||
.source-file-widget.capture-kind-full_page { box-sizing: border-box; min-height: 0; background: #eee8d8; border: 0; box-shadow: 7px 9px 4px #0209078c, 0 0 0 1px #fff9; }
|
||||
.source-file-widget.capture-kind-photo header,
|
||||
.source-file-widget.capture-kind-scene header,
|
||||
.source-file-widget.capture-kind-clipping header,
|
||||
.source-file-widget.capture-kind-full_page header { position: absolute; z-index: 2; top: 10px; right: 10px; width: auto; height: auto; border: 0; color: #f1ead5; text-shadow: 0 1px 2px #000; pointer-events: none; }
|
||||
.source-file-widget.capture-kind-photo header span,
|
||||
.source-file-widget.capture-kind-scene header span,
|
||||
.source-file-widget.capture-kind-clipping header span,
|
||||
.source-file-widget.capture-kind-full_page header span { display: none; }
|
||||
.source-file-widget.capture-kind-photo header i,
|
||||
.source-file-widget.capture-kind-scene header i,
|
||||
.source-file-widget.capture-kind-clipping header i,
|
||||
.source-file-widget.capture-kind-full_page header i { padding: 3px 4px; background: #17231da8; border: 1px solid #efe8d066; font-style: normal; }
|
||||
.source-file-widget.capture-kind-photo .source-file-preview,
|
||||
.source-file-widget.capture-kind-scene .source-file-preview,
|
||||
.source-file-widget.capture-kind-clipping .source-file-preview,
|
||||
.source-file-widget.capture-kind-full_page .source-file-preview { margin: 0 0 8px; background: #d8d3c4; border: 1px solid #f9f6eb; box-shadow: inset 0 0 0 1px #5f625d; }
|
||||
.source-file-widget.capture-kind-photo > strong,
|
||||
.source-file-widget.capture-kind-scene > strong,
|
||||
.source-file-widget.capture-kind-clipping > strong,
|
||||
.source-file-widget.capture-kind-full_page > strong { color: #252720; font: 12px/1.15 Special Elite; }
|
||||
.source-file-widget.capture-kind-photo .source-file-actions,
|
||||
.source-file-widget.capture-kind-scene .source-file-actions,
|
||||
.source-file-widget.capture-kind-clipping .source-file-actions,
|
||||
.source-file-widget.capture-kind-full_page .source-file-actions { margin-top: 5px; border-color: #a49d8d; opacity: .42; transition: opacity .16s ease; }
|
||||
.source-file-widget.capture-kind-photo:hover .source-file-actions,
|
||||
.source-file-widget.capture-kind-scene:hover .source-file-actions,
|
||||
.source-file-widget.capture-kind-clipping:hover .source-file-actions,
|
||||
.source-file-widget.capture-kind-full_page:hover .source-file-actions,
|
||||
.source-file-widget.selected .source-file-actions { opacity: 1; }
|
||||
.source-file-widget.capture-kind-photo { padding: 10px 10px 12px; transform-origin: 50% 15%; }
|
||||
.source-file-widget.capture-kind-photo.open { transform: scale(1) rotate(-1.25deg); }
|
||||
.source-file-widget.capture-kind-photo .source-file-preview { height: 151px; }
|
||||
.source-file-widget.capture-kind-photo .source-file-preview img { object-fit: cover; filter: saturate(.82) contrast(1.04) sepia(.08); }
|
||||
.source-file-widget.capture-kind-photo > strong { padding: 1px 4px 0; text-align: center; font: 15px/1.1 "Marker Felt", "Comic Sans MS", cursive; transform: rotate(-.5deg); }
|
||||
.source-file-widget.capture-kind-photo > time { padding-right: 3px; text-align: right; }
|
||||
.source-file-widget.capture-kind-scene { padding: 9px 9px 10px; background: #e5e0d3; }
|
||||
.source-file-widget.capture-kind-scene.open { transform: scale(1) rotate(.35deg); }
|
||||
.source-file-widget.capture-kind-scene .source-file-preview { height: 120px; }
|
||||
.source-file-widget.capture-kind-scene .source-file-preview img { object-fit: cover; filter: saturate(.86) contrast(1.05); }
|
||||
.source-file-widget.capture-kind-scene > strong { font-size: 11px; }
|
||||
.source-file-widget.capture-kind-clipping { padding: 9px 12px 11px; background: linear-gradient(103deg,#e7e2d3,#d9d2bf); clip-path: polygon(1% 2%,99% 0,98% 13%,100% 28%,98% 43%,100% 61%,98% 78%,99% 98%,84% 99%,68% 97%,52% 100%,35% 98%,19% 100%,1% 98%,2% 79%,0 62%,2% 45%,0 27%); }
|
||||
.source-file-widget.capture-kind-clipping.open { transform: scale(1) rotate(.8deg); }
|
||||
.source-file-widget.capture-kind-clipping .source-file-preview { height: 105px; border-color: #b2a996; box-shadow: none; }
|
||||
.source-file-widget.capture-kind-clipping .source-file-preview img { object-fit: cover; filter: grayscale(.12) contrast(1.08); }
|
||||
.source-file-widget.capture-kind-clipping > strong { font-size: 11px; }
|
||||
.source-file-widget.capture-kind-full_page { padding: 9px 10px 11px; background: #ebe7da; box-shadow: 5px 7px 3px #02090780, inset 0 0 22px #8e887244; }
|
||||
.source-file-widget.capture-kind-full_page.open { transform: scale(1) rotate(-.25deg); }
|
||||
.source-file-widget.capture-kind-full_page .source-file-preview { height: 211px; background: #f5f2e9; border-color: #b3ae9f; box-shadow: none; }
|
||||
.source-file-widget.capture-kind-full_page .source-file-preview img { object-fit: contain; }
|
||||
.source-file-widget.capture-kind-full_page > strong { font-size: 10px; }
|
||||
.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; }
|
||||
@@ -237,7 +294,10 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.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 { position: sticky; z-index: 3; top: 0; height: 31px; min-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; }
|
||||
.document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); }
|
||||
.document-window > nav { position: sticky; z-index: 2; top: 31px; height: 28px; display: flex; align-items: stretch; gap: 2px; padding: 0 7px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; }
|
||||
.document-menu { position: relative; display: flex; }.document-menu > button { min-width: 48px; padding: 0 8px; border: 0; background: transparent; color: #24312d; cursor: pointer; font: 9px IBM Plex Mono; letter-spacing: .05em; }.document-menu > button:hover,.document-menu > button[aria-expanded=true] { background: #173e35; color: #f0f3ee; }
|
||||
.document-menu-items { position: absolute; z-index: 6; top: 27px; left: 0; width: 250px; display: grid; gap: 2px; padding: 4px; background: #c6cac3; border: 2px outset #edf0e9; box-shadow: 5px 7px 0 #07100dcc; }.document-menu-items > button { position: relative; min-height: 43px; display: grid; grid-template-columns: 25px minmax(0,1fr) 16px; align-items: center; gap: 8px; padding: 7px 8px; border: 1px solid transparent; background: transparent; color: #26342f; text-align: left; cursor: pointer; }.document-menu-items > button:hover,.document-menu-items > button:focus-visible { outline: 0; border-color: #557067; background: #173e35; color: #f4f6f1; }.document-menu-items > button > span { min-width: 0; display: grid; gap: 2px; }.document-menu-items b { font: 600 9px IBM Plex Mono; letter-spacing: .05em; }.document-menu-items small { color: #65716c; font: 7px IBM Plex Mono; }.document-menu-items > button:hover small,.document-menu-items > button:focus-visible small { color: #b9c9c2; }.document-menu-items > button.danger { color: #782f2b; }.document-menu-items > button.danger:hover,.document-menu-items > button.danger:focus-visible { background: #702e2b; color: white; }.document-menu-items .menu-check { color: #955b2f; }.document-menu-items > button:hover .menu-check { color: #efb06c; }.type-menu { width: 286px; }
|
||||
.paper { margin: 17px; padding: 34px 43px; height: min(500px, 58vh); overflow: auto; background: #e8e5d8; box-shadow: inset 0 0 24px #9a968566; font-family: IBM Plex Mono; }
|
||||
.paper.asset-paper { padding: 20px; display: flex; flex-direction: column; }.asset-paper .paper-meta { flex: 0 0 auto; margin-bottom: 14px; }.document-image { display: block; max-width: 100%; margin: auto; box-shadow: 0 2px 12px #0005; }.document-frame { width: 100%; flex: 1; min-height: 350px; border: 1px solid #81877f; background: white; }.unsupported-file { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 11px; color: #4a5651; }.unsupported-file b { font-size: 12px; }.unsupported-file span { font-size: 9px; color: #758079; }.unsupported-file a { margin-top: 9px; padding: 8px 11px; background: #244b40; color: white; text-decoration: none; font: 9px IBM Plex Mono; }
|
||||
.paper-meta { display: flex; justify-content: space-between; font-size: 8px; letter-spacing: .1em; border-bottom: 2px solid #252d29; padding-bottom: 9px; margin-bottom: 28px; }.paper-meta b { color: #945b32; }
|
||||
@@ -246,6 +306,18 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.extracts button { border: 1px solid #9b602f; background: #f0dbc0; color: #66391a; padding: 8px 10px; display: flex; align-items: center; gap: 6px; cursor: pointer; font: 600 9px IBM Plex Mono; text-transform: uppercase; }.extracts button:hover { background: #e8bd87; }.extracts button.done { border-color: #667a71; background: #d1d7ce; color: #42554d; }
|
||||
.document-window > footer { height: 25px; border-top: 1px solid #737f78; display: flex; justify-content: space-between; padding: 6px 8px; font: 8px IBM Plex Mono; }
|
||||
.modal-shade { position: fixed; z-index: 40; inset: 0; background: #020b09aa; display: grid; place-items: center; }
|
||||
.evidence-classification-shade { z-index: 80; padding: 18px; backdrop-filter: blur(2px); }
|
||||
.evidence-classification { width: min(680px,calc(100vw - 36px)); max-height: calc(100dvh - 36px); overflow: auto; color: #1d2824; background: #c8ccc4; border: 2px solid #dfe2dc; box-shadow: 8px 10px 0 #020907,0 0 0 1px #46544f; animation: evidence-classification-in .25s cubic-bezier(.2,.8,.2,1) both; }
|
||||
.evidence-classification > header { position: sticky; z-index: 2; top: 0; height: 33px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 10px; color: #e5ece8; background: #183f36; font: 500 10px IBM Plex Mono; text-transform: uppercase; }
|
||||
.evidence-classification > header span { flex: 1; }.evidence-classification > header button { width: 23px;height: 22px;display:grid;place-items:center;padding:0;color:#17221f;background:#bcc1b9;border:1px outset white;cursor:pointer; }
|
||||
.evidence-classification-body { padding: 24px 27px 25px; }
|
||||
.classification-source { min-height: 92px; display: grid; grid-template-columns: 122px minmax(0,1fr); gap: 15px; align-items: center; padding: 9px; border: 1px solid #8a928c; background: #b7bbb3; }
|
||||
.classification-source > img { width: 122px;height:78px;object-fit:cover;border:5px solid #eee9db;box-shadow:3px 4px #0004;transform:rotate(-1deg); }.classification-source > svg { margin:auto;color:#5e6d67; }
|
||||
.classification-source > div { min-width:0;display:grid;gap:6px; }.classification-source small { color:#7d502d;font:600 7px IBM Plex Mono;letter-spacing:.14em; }.classification-source strong { overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:16px Special Elite; }
|
||||
.classification-question { margin: 22px 0 14px; }.classification-question > small { color:#8a542d;font:600 7px IBM Plex Mono;letter-spacing:.17em; }.classification-question h2 { margin:6px 0 7px;font:25px Special Elite; }.classification-question p { margin:0;color:#59645f;font:9px/1.5 IBM Plex Mono; }
|
||||
.classification-options { display:grid;grid-template-columns:1fr 1fr;gap:9px; }.classification-options > button { min-height:78px;display:grid;grid-template-columns:34px 1fr;gap:9px;align-items:center;padding:12px;text-align:left;color:#253730;background:#d9dbd3;border:1px solid #8e9690;cursor:pointer; }.classification-options > button:hover,.classification-options > button:focus-visible { outline:0;border-color:#9b6035;background:#e5d6bd;box-shadow:inset 4px 0 #9b6035; }.classification-options svg { color:#8b572f; }.classification-options span { display:grid;gap:5px; }.classification-options b { font:600 10px IBM Plex Mono;text-transform:uppercase; }.classification-options small { color:#64706a;font:8px/1.4 IBM Plex Mono; }
|
||||
.classify-later { width:100%;margin-top:11px;padding:9px;border:1px dashed #8a928c;color:#65706b;background:transparent;cursor:pointer;font:7px IBM Plex Mono;letter-spacing:.11em; }.classify-later:hover { color:#384b44;background:#d4d6ce; }
|
||||
@keyframes evidence-classification-in { from { opacity:0;transform:translateY(18px) scale(.96); } }
|
||||
.help { width: 440px; }.help > div { padding: 30px 34px 34px; }.help h2 { font: 23px Special Elite; margin: 8px 0 22px; }.help ol { padding-left: 22px; font-size: 12px; line-height: 2; }.help p { font: 13px Special Elite; border-left: 3px solid #a56330; padding-left: 12px; }.primary { float: right; background: #163f35; color: white; border: 2px outset #608177; font: 9px IBM Plex Mono; padding: 10px 13px; cursor: pointer; }
|
||||
.folder-editor { width: min(680px, 88vw); }
|
||||
.folder-editor-body { padding: 24px 27px 22px; }
|
||||
@@ -336,6 +408,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; }
|
||||
.match-rules-editor { width: 100vw; height: 100dvh; max-height: none; border: 0; }.match-rules-body { padding: 16px; }.match-rule-layout { grid-template-columns: 1fr; }.match-rule-list { max-height: 180px; }.match-rule-fields { grid-template-columns: 1fr 100px; }
|
||||
.case-report-shade { padding:0; }.case-report { width:100vw;height:100dvh;border:0; }.case-report-paper { padding:28px 17px 24px; }.report-fields { grid-template-columns:1fr;gap:0; }.report-evidence-heading { grid-template-columns:auto 1fr; }.report-evidence-heading em { grid-column:1/-1;width:max-content; }.case-report-actions { bottom:-24px;margin-left:-7px;margin-right:-7px; }
|
||||
.evidence-classification-shade { padding:0; }.evidence-classification { width:100vw;max-width:none;max-height:100dvh;border-width:0;box-shadow:none; }.evidence-classification-body { padding:18px 16px 22px; }.classification-options { grid-template-columns:1fr; }.classification-source { grid-template-columns:92px minmax(0,1fr); }.classification-source > img { width:92px;height:66px; }.classification-question h2 { font-size:22px; }
|
||||
.board-actions button { width: 38px; padding: 0; justify-content: center; gap: 0; font-size: 0; }
|
||||
.board-actions > b { display: none; }
|
||||
.board-actions > span { margin: 0 2px; }
|
||||
|
||||
@@ -2,6 +2,7 @@ export type ExhibitType = 'folder' | 'document' | '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 type DocumentCaptureKind = 'unclassified' | 'photo' | 'scene' | 'clipping' | 'full_page'
|
||||
|
||||
export interface CanvasPlacement {
|
||||
x: number
|
||||
@@ -49,6 +50,8 @@ export interface DocumentExhibit extends ExhibitBase {
|
||||
mimeType?: string
|
||||
fileSize?: number
|
||||
fileType: SourceFileType
|
||||
/** Evidentiary form on the board; independent of MIME/file type. */
|
||||
captureKind: DocumentCaptureKind
|
||||
metadata: Record<string, string>
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user