feat: add document content search
This commit is contained in:
@@ -27,6 +27,7 @@ async function waitForSave(page: Page, action: () => Promise<void>) {
|
|||||||
test('move, folder expansion, hand pan, desktop wheel zoom, mobile pinch, and reload persistence', async ({ page }) => {
|
test('move, folder expansion, hand pan, desktop wheel zoom, mobile pinch, and reload persistence', async ({ page }) => {
|
||||||
await page.goto('/?level=e2e-level&edit=1')
|
await page.goto('/?level=e2e-level&edit=1')
|
||||||
await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible()
|
||||||
|
await expect(page.locator('.documents-panel')).toHaveClass(/\bclosed\b/)
|
||||||
await expect(page.locator('.brief-panel')).toBeVisible()
|
await expect(page.locator('.brief-panel')).toBeVisible()
|
||||||
await expect(page.getByRole('button', { name: 'Case brief', exact: true })).toContainText('2')
|
await expect(page.getByRole('button', { name: 'Case brief', exact: true })).toContainText('2')
|
||||||
await page.getByRole('button', { name: 'BEGIN INVESTIGATION', exact: true }).click()
|
await page.getByRole('button', { name: 'BEGIN INVESTIGATION', exact: true }).click()
|
||||||
|
|||||||
@@ -29,11 +29,20 @@ async function createEvent(page: Page, title: string, narrative: string, occurre
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('a cloned Glass Harbor level can be solved without modifying its template', async ({ page, request }) => {
|
test('a cloned Glass Harbor level can be solved without modifying its template', async ({ page, request }) => {
|
||||||
|
test.setTimeout(90_000)
|
||||||
const levels = await (await request.get('/api/levels')).json() as { id: string }[]
|
const levels = await (await request.get('/api/levels')).json() as { id: string }[]
|
||||||
const playable = levels.find(level => level.id.startsWith('glass-harbor-case-'))
|
const playable = levels.find(level => level.id.startsWith('glass-harbor-case-'))
|
||||||
if (!playable) throw new Error('Glass Harbor playable clone was not seeded')
|
if (!playable) throw new Error('Glass Harbor playable clone was not seeded')
|
||||||
await page.goto(`/?level=${playable.id}`)
|
await page.goto(`/?level=${playable.id}`)
|
||||||
await expect(page.getByRole('heading', { name: 'The Glass Harbor Diversion' })).toBeVisible()
|
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('.evidence-card.folder')).toHaveCount(3)
|
||||||
await expect(page.locator('.timeline .marker')).toHaveCount(8)
|
await expect(page.locator('.timeline .marker')).toHaveCount(8)
|
||||||
await expect(page.locator('.timeline-label button')).toHaveText('1987-10-01 — 1987-10-31')
|
await expect(page.locator('.timeline-label button')).toHaveText('1987-10-01 — 1987-10-31')
|
||||||
|
|||||||
+13
-4
@@ -12,6 +12,11 @@ const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
|
|||||||
|
|
||||||
function uid(_prefix: string) { return crypto.randomUUID() }
|
function uid(_prefix: string) { return crypto.randomUUID() }
|
||||||
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
||||||
|
function documentSearchText(document: CaseDocument) {
|
||||||
|
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) {
|
function connectionPoint(item: Evidence) {
|
||||||
return exhibitWidget(item.type).connectionPoint(item)
|
return exhibitWidget(item.type).connectionPoint(item)
|
||||||
}
|
}
|
||||||
@@ -24,7 +29,8 @@ export function App() {
|
|||||||
const [openDoc, setOpenDoc] = useState<CaseDocument | null>(null)
|
const [openDoc, setOpenDoc] = useState<CaseDocument | null>(null)
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
const [linkFrom, setLinkFrom] = 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 [helpOpen, setHelpOpen] = useState(false)
|
||||||
const [status, setStatus] = useState('CONNECTING TO ARCHIVE…')
|
const [status, setStatus] = useState('CONNECTING TO ARCHIVE…')
|
||||||
const [clock, setClock] = useState('')
|
const [clock, setClock] = useState('')
|
||||||
@@ -274,6 +280,8 @@ export function App() {
|
|||||||
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
|
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
|
||||||
|
|
||||||
const documentById = new Map(caseState.documents.map(document => [document.id, document]))
|
const 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 unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||||
const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId))
|
const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId))
|
||||||
const temporalItems: TemporalItem[] = [
|
const temporalItems: TemporalItem[] = [
|
||||||
@@ -298,14 +306,15 @@ export function App() {
|
|||||||
|
|
||||||
<section className="workspace">
|
<section className="workspace">
|
||||||
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
|
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
|
||||||
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{caseState.documents.length}</sup></h2></div><button onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
|
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${caseState.documents.length}` : caseState.documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
|
||||||
<div className="search"><Search size={15}/><span>Search case archive…</span></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>
|
||||||
{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 = '' }} /></>}
|
{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="doc-list">
|
<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" data-temporal-id={`document:${doc.id}`} key={doc.id} onDoubleClick={() => setOpenDoc(doc)} onClick={() => setOpenDoc(doc)}>
|
||||||
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.kind.slice(0, 3)}</b></div>
|
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.kind.slice(0, 3)}</b></div>
|
||||||
<div><strong>{doc.title}</strong><span>{doc.kind} · {doc.date}</span></div><ChevronRight size={16}/>
|
<div><strong>{doc.title}</strong><span>{doc.kind} · {doc.date}</span></div><ChevronRight size={16}/>
|
||||||
</button>)}
|
</button>)}
|
||||||
|
{normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
|
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
+6
-1
@@ -25,9 +25,14 @@ 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 h2 { font: 600 17px IBM Plex Mono; letter-spacing: .04em; margin: 6px 0 0; }
|
||||||
.panel-heading sup { color: #d89043; font-size: 10px; }
|
.panel-heading sup { color: #d89043; font-size: 10px; }
|
||||||
.panel-heading button { background: none; border: 0; color: #6f8b83; cursor: pointer; }
|
.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; }
|
.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; }
|
.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 { 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:hover { background: #18342e; }
|
||||||
.doc-row strong { display: block; color: #d8dfda; font-size: 12px; margin-bottom: 6px; }
|
.doc-row strong { display: block; color: #d8dfda; font-size: 12px; margin-bottom: 6px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user