StepPageSplit now: - Auto-calls POST /page-split on step entry - Shows oriented image + detection result - If double page: creates sub-sessions named "Title — S. 1/2" - If single page: green badge "keine Trennung noetig" - Manual "Weiter" button (no auto-advance) Also: - StepOrientation wrapper simplified (no page-split in orientation) - StepUpload passes name back via onUploaded(sid, name) - page.tsx: after page-split "Weiter" switches to first sub-session - useKombiPipeline exposes setSessionName Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useState, useRef } from 'react'
|
|
import { useSearchParams } from 'next/navigation'
|
|
import type { PipelineStep, DocumentCategory } from './types'
|
|
import { KOMBI_V2_STEPS, dbStepToKombiV2Ui } from './types'
|
|
import type { SubSession, SessionListItem } from '../ocr-pipeline/types'
|
|
|
|
export type { SessionListItem }
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
/** Groups sessions by document_group_id for the session list */
|
|
export interface DocumentGroupView {
|
|
group_id: string
|
|
title: string
|
|
sessions: SessionListItem[]
|
|
page_count: number
|
|
}
|
|
|
|
function initSteps(): PipelineStep[] {
|
|
return KOMBI_V2_STEPS.map((s, i) => ({
|
|
...s,
|
|
status: i === 0 ? 'active' : 'pending',
|
|
}))
|
|
}
|
|
|
|
export function useKombiPipeline() {
|
|
const [currentStep, setCurrentStep] = useState(0)
|
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
|
const [sessionName, setSessionName] = useState('')
|
|
const [sessions, setSessions] = useState<SessionListItem[]>([])
|
|
const [loadingSessions, setLoadingSessions] = useState(true)
|
|
const [activeCategory, setActiveCategory] = useState<DocumentCategory | undefined>(undefined)
|
|
const [isGroundTruth, setIsGroundTruth] = useState(false)
|
|
const [subSessions, setSubSessions] = useState<SubSession[]>([])
|
|
const [parentSessionId, setParentSessionId] = useState<string | null>(null)
|
|
const [steps, setSteps] = useState<PipelineStep[]>(initSteps())
|
|
|
|
const searchParams = useSearchParams()
|
|
const deepLinkHandled = useRef(false)
|
|
const gridSaveRef = useRef<(() => Promise<void>) | null>(null)
|
|
|
|
// ---- Session loading ----
|
|
|
|
const loadSessions = useCallback(async () => {
|
|
setLoadingSessions(true)
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setSessions((data.sessions || []).filter((s: SessionListItem) => !s.parent_session_id))
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load sessions:', e)
|
|
} finally {
|
|
setLoadingSessions(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => { loadSessions() }, [loadSessions])
|
|
|
|
// ---- Group sessions by document_group_id ----
|
|
|
|
const groupedSessions = useCallback((): (SessionListItem | DocumentGroupView)[] => {
|
|
const groups = new Map<string, SessionListItem[]>()
|
|
const ungrouped: SessionListItem[] = []
|
|
|
|
for (const s of sessions) {
|
|
if (s.document_group_id) {
|
|
const existing = groups.get(s.document_group_id) || []
|
|
existing.push(s)
|
|
groups.set(s.document_group_id, existing)
|
|
} else {
|
|
ungrouped.push(s)
|
|
}
|
|
}
|
|
|
|
const result: (SessionListItem | DocumentGroupView)[] = []
|
|
|
|
// Sort groups by earliest created_at
|
|
const sortedGroups = Array.from(groups.entries()).sort((a, b) => {
|
|
const aTime = Math.min(...a[1].map(s => new Date(s.created_at).getTime()))
|
|
const bTime = Math.min(...b[1].map(s => new Date(s.created_at).getTime()))
|
|
return bTime - aTime
|
|
})
|
|
|
|
for (const [groupId, groupSessions] of sortedGroups) {
|
|
groupSessions.sort((a, b) => (a.page_number || 0) - (b.page_number || 0))
|
|
// Extract base title (remove " — S. X" suffix)
|
|
const baseName = groupSessions[0]?.name?.replace(/ — S\. \d+$/, '') || 'Dokument'
|
|
result.push({
|
|
group_id: groupId,
|
|
title: baseName,
|
|
sessions: groupSessions,
|
|
page_count: groupSessions.length,
|
|
})
|
|
}
|
|
|
|
for (const s of ungrouped) {
|
|
result.push(s)
|
|
}
|
|
|
|
// Sort by creation time (most recent first)
|
|
const getTime = (item: SessionListItem | DocumentGroupView): number => {
|
|
if ('group_id' in item) {
|
|
return Math.min(...item.sessions.map((s: SessionListItem) => new Date(s.created_at).getTime()))
|
|
}
|
|
return new Date(item.created_at).getTime()
|
|
}
|
|
result.sort((a, b) => getTime(b) - getTime(a))
|
|
|
|
return result
|
|
}, [sessions])
|
|
|
|
// ---- Open session ----
|
|
|
|
const openSession = useCallback(async (sid: string, keepSubSessions?: boolean) => {
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`)
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
|
|
setSessionId(sid)
|
|
setSessionName(data.name || data.filename || '')
|
|
setActiveCategory(data.document_category || undefined)
|
|
setIsGroundTruth(!!data.ground_truth?.build_grid_reference)
|
|
|
|
// Sub-session handling
|
|
if (data.sub_sessions?.length > 0) {
|
|
setSubSessions(data.sub_sessions)
|
|
setParentSessionId(sid)
|
|
} else if (data.parent_session_id) {
|
|
setParentSessionId(data.parent_session_id)
|
|
} else if (!keepSubSessions) {
|
|
setSubSessions([])
|
|
setParentSessionId(null)
|
|
}
|
|
|
|
// Determine UI step from DB state
|
|
const dbStep = data.current_step || 1
|
|
const hasGrid = !!data.grid_editor_result
|
|
const hasStructure = !!data.structure_result
|
|
const hasWords = !!data.word_result
|
|
|
|
let uiStep: number
|
|
if (hasGrid) {
|
|
uiStep = 9 // grid-review
|
|
} else if (hasStructure) {
|
|
uiStep = 8 // grid-build
|
|
} else if (hasWords) {
|
|
uiStep = 7 // structure
|
|
} else {
|
|
uiStep = dbStepToKombiV2Ui(dbStep)
|
|
}
|
|
|
|
// Sessions only exist after upload, so always skip the upload step
|
|
if (uiStep === 0) {
|
|
uiStep = 1
|
|
}
|
|
|
|
const skipIds: string[] = []
|
|
const isSubSession = !!data.parent_session_id
|
|
if (isSubSession && dbStep >= 5) {
|
|
skipIds.push('upload', 'orientation', 'page-split', 'deskew', 'dewarp', 'content-crop')
|
|
if (uiStep < 6) uiStep = 6
|
|
} else if (isSubSession && dbStep >= 2) {
|
|
skipIds.push('upload', 'orientation')
|
|
if (uiStep < 2) uiStep = 2
|
|
}
|
|
|
|
setSteps(
|
|
KOMBI_V2_STEPS.map((s, i) => ({
|
|
...s,
|
|
status: skipIds.includes(s.id)
|
|
? 'skipped'
|
|
: i < uiStep ? 'completed' : i === uiStep ? 'active' : 'pending',
|
|
})),
|
|
)
|
|
setCurrentStep(uiStep)
|
|
} catch (e) {
|
|
console.error('Failed to open session:', e)
|
|
}
|
|
}, [])
|
|
|
|
// ---- Deep link handling ----
|
|
|
|
useEffect(() => {
|
|
if (deepLinkHandled.current) return
|
|
const urlSession = searchParams.get('session')
|
|
const urlStep = searchParams.get('step')
|
|
if (urlSession) {
|
|
deepLinkHandled.current = true
|
|
openSession(urlSession).then(() => {
|
|
if (urlStep) {
|
|
const stepIdx = parseInt(urlStep, 10)
|
|
if (!isNaN(stepIdx) && stepIdx >= 0 && stepIdx < KOMBI_V2_STEPS.length) {
|
|
setCurrentStep(stepIdx)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}, [searchParams, openSession])
|
|
|
|
// ---- Step navigation ----
|
|
|
|
const goToStep = useCallback((step: number) => {
|
|
setCurrentStep(step)
|
|
setSteps(prev =>
|
|
prev.map((s, i) => ({
|
|
...s,
|
|
status: i < step ? 'completed' : i === step ? 'active' : 'pending',
|
|
})),
|
|
)
|
|
}, [])
|
|
|
|
const handleStepClick = useCallback((index: number) => {
|
|
if (index <= currentStep || steps[index].status === 'completed') {
|
|
setCurrentStep(index)
|
|
}
|
|
}, [currentStep, steps])
|
|
|
|
const handleNext = useCallback(() => {
|
|
if (currentStep >= steps.length - 1) {
|
|
// Last step → return to session list
|
|
setSteps(initSteps())
|
|
setCurrentStep(0)
|
|
setSessionId(null)
|
|
setSubSessions([])
|
|
setParentSessionId(null)
|
|
loadSessions()
|
|
return
|
|
}
|
|
|
|
const nextStep = currentStep + 1
|
|
setSteps(prev =>
|
|
prev.map((s, i) => {
|
|
if (i === currentStep) return { ...s, status: 'completed' }
|
|
if (i === nextStep) return { ...s, status: 'active' }
|
|
return s
|
|
}),
|
|
)
|
|
setCurrentStep(nextStep)
|
|
}, [currentStep, steps, loadSessions])
|
|
|
|
// ---- Session CRUD ----
|
|
|
|
const handleNewSession = useCallback(() => {
|
|
setSessionId(null)
|
|
setSessionName('')
|
|
setCurrentStep(0)
|
|
setSubSessions([])
|
|
setParentSessionId(null)
|
|
setSteps(initSteps())
|
|
}, [])
|
|
|
|
const deleteSession = useCallback(async (sid: string) => {
|
|
try {
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, { method: 'DELETE' })
|
|
setSessions(prev => prev.filter(s => s.id !== sid))
|
|
if (sessionId === sid) handleNewSession()
|
|
} catch (e) {
|
|
console.error('Failed to delete session:', e)
|
|
}
|
|
}, [sessionId, handleNewSession])
|
|
|
|
const renameSession = useCallback(async (sid: string, newName: string) => {
|
|
try {
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: newName }),
|
|
})
|
|
setSessions(prev => prev.map(s => s.id === sid ? { ...s, name: newName } : s))
|
|
if (sessionId === sid) setSessionName(newName)
|
|
} catch (e) {
|
|
console.error('Failed to rename session:', e)
|
|
}
|
|
}, [sessionId])
|
|
|
|
const updateCategory = useCallback(async (sid: string, category: DocumentCategory) => {
|
|
try {
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ document_category: category }),
|
|
})
|
|
setSessions(prev => prev.map(s => s.id === sid ? { ...s, document_category: category } : s))
|
|
if (sessionId === sid) setActiveCategory(category)
|
|
} catch (e) {
|
|
console.error('Failed to update category:', e)
|
|
}
|
|
}, [sessionId])
|
|
|
|
// ---- Orientation completion (checks for page-split sub-sessions) ----
|
|
|
|
const handleOrientationComplete = useCallback(async (sid: string) => {
|
|
setSessionId(sid)
|
|
loadSessions()
|
|
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
if (data.sub_sessions?.length > 0) {
|
|
const subs: SubSession[] = data.sub_sessions.map((s: SubSession) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
box_index: s.box_index,
|
|
current_step: s.current_step,
|
|
}))
|
|
setSubSessions(subs)
|
|
setParentSessionId(sid)
|
|
openSession(subs[0].id, true)
|
|
return
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to check for sub-sessions:', e)
|
|
}
|
|
|
|
handleNext()
|
|
}, [loadSessions, openSession, handleNext])
|
|
|
|
const handleSessionChange = useCallback((newSessionId: string) => {
|
|
openSession(newSessionId, true)
|
|
}, [openSession])
|
|
|
|
return {
|
|
// State
|
|
currentStep,
|
|
sessionId,
|
|
sessionName,
|
|
sessions,
|
|
loadingSessions,
|
|
activeCategory,
|
|
isGroundTruth,
|
|
subSessions,
|
|
parentSessionId,
|
|
steps,
|
|
gridSaveRef,
|
|
// Computed
|
|
groupedSessions,
|
|
// Actions
|
|
loadSessions,
|
|
openSession,
|
|
goToStep,
|
|
handleStepClick,
|
|
handleNext,
|
|
handleNewSession,
|
|
deleteSession,
|
|
renameSession,
|
|
updateCategory,
|
|
handleOrientationComplete,
|
|
handleSessionChange,
|
|
setSessionId,
|
|
setSubSessions,
|
|
setParentSessionId,
|
|
setSessionName,
|
|
setIsGroundTruth,
|
|
}
|
|
}
|