Phase 1 — Python (klausur-service): 5 monoliths → 36 files - dsfa_corpus_ingestion.py (1,828 LOC → 5 files) - cv_ocr_engines.py (2,102 LOC → 7 files) - cv_layout.py (3,653 LOC → 10 files) - vocab_worksheet_api.py (2,783 LOC → 8 files) - grid_build_core.py (1,958 LOC → 6 files) Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files - staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3) - policy_handlers.go (700 → 2), repository.go (684 → 2) - search.go (592 → 2), ai_extraction_handlers.go (554 → 2) - seed_data.go (591 → 2), grade_service.go (646 → 2) Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files - sdk/types.ts (2,108 → 16 domain files) - ai/rag/page.tsx (2,686 → 14 files) - 22 page.tsx files split into _components/ + _hooks/ - 11 component files split into sub-components - 10 SDK data catalogs added to loc-exceptions - Deleted dead backup index_original.ts (4,899 LOC) All original public APIs preserved via re-export facades. Zero new errors: Python imports verified, Go builds clean, TypeScript tsc --noEmit shows only pre-existing errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
import { useCallback, useState } from 'react'
|
|
import type { StructuredGrid, GridZone } from './types'
|
|
|
|
interface ActionDeps {
|
|
grid: StructuredGrid | null
|
|
setGrid: React.Dispatch<React.SetStateAction<StructuredGrid | null>>
|
|
setDirty: React.Dispatch<React.SetStateAction<boolean>>
|
|
pushUndo: (zones: GridZone[]) => void
|
|
}
|
|
|
|
export function useGridEditorActions(deps: ActionDeps) {
|
|
const { grid, setGrid, setDirty, pushUndo } = deps
|
|
|
|
// ------------------------------------------------------------------
|
|
// Multi-select state
|
|
// ------------------------------------------------------------------
|
|
|
|
const [selectedCells, setSelectedCells] = useState<Set<string>>(new Set())
|
|
|
|
const toggleCellSelection = useCallback(
|
|
(cellId: string) => {
|
|
setSelectedCells((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(cellId)) next.delete(cellId)
|
|
else next.add(cellId)
|
|
return next
|
|
})
|
|
},
|
|
[],
|
|
)
|
|
|
|
const clearCellSelection = useCallback(() => {
|
|
setSelectedCells(new Set())
|
|
}, [])
|
|
|
|
// ------------------------------------------------------------------
|
|
// Cell editing
|
|
// ------------------------------------------------------------------
|
|
|
|
const updateCellText = useCallback(
|
|
(cellId: string, newText: string) => {
|
|
if (!grid) return
|
|
pushUndo(grid.zones)
|
|
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((zone) => {
|
|
// Check if cell exists
|
|
const existing = zone.cells.find((c) => c.cell_id === cellId)
|
|
if (existing) {
|
|
return {
|
|
...zone,
|
|
cells: zone.cells.map((cell) =>
|
|
cell.cell_id === cellId ? { ...cell, text: newText } : cell,
|
|
),
|
|
}
|
|
}
|
|
// Cell doesn't exist — create it if the cellId belongs to this zone
|
|
// cellId format: Z{zone}_R{row}_C{col}
|
|
const match = cellId.match(/^Z(\d+)_R(\d+)_C(\d+)$/)
|
|
if (!match || parseInt(match[1]) !== zone.zone_index) return zone
|
|
const rowIndex = parseInt(match[2])
|
|
const colIndex = parseInt(match[3])
|
|
const col = zone.columns.find((c) => c.index === colIndex)
|
|
const newCell = {
|
|
cell_id: cellId,
|
|
zone_index: zone.zone_index,
|
|
row_index: rowIndex,
|
|
col_index: colIndex,
|
|
col_type: col?.label ?? '',
|
|
text: newText,
|
|
confidence: 0,
|
|
bbox_px: { x: 0, y: 0, w: 0, h: 0 },
|
|
bbox_pct: { x: 0, y: 0, w: 0, h: 0 },
|
|
word_boxes: [],
|
|
ocr_engine: 'manual',
|
|
is_bold: false,
|
|
}
|
|
return { ...zone, cells: [...zone.cells, newCell] }
|
|
}),
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid, pushUndo, setGrid, setDirty],
|
|
)
|
|
|
|
// ------------------------------------------------------------------
|
|
// Cell color
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Set a manual color override on a cell.
|
|
* - hex string (e.g. "#dc2626"): force text color
|
|
* - null: force no color (clear bar)
|
|
* - undefined: remove override, restore OCR-detected color
|
|
*/
|
|
const setCellColor = useCallback(
|
|
(cellId: string, color: string | null | undefined) => {
|
|
if (!grid) return
|
|
pushUndo(grid.zones)
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((zone) => ({
|
|
...zone,
|
|
cells: zone.cells.map((cell) => {
|
|
if (cell.cell_id !== cellId) return cell
|
|
if (color === undefined) {
|
|
// Remove override entirely — restore OCR behavior
|
|
const { color_override: _, ...rest } = cell
|
|
return rest
|
|
}
|
|
return { ...cell, color_override: color }
|
|
}),
|
|
})),
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid, pushUndo, setGrid, setDirty],
|
|
)
|
|
|
|
// ------------------------------------------------------------------
|
|
// Column formatting
|
|
// ------------------------------------------------------------------
|
|
|
|
const toggleColumnBold = useCallback(
|
|
(zoneIndex: number, colIndex: number) => {
|
|
if (!grid) return
|
|
pushUndo(grid.zones)
|
|
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((zone) => {
|
|
if (zone.zone_index !== zoneIndex) return zone
|
|
const col = zone.columns.find((c) => c.index === colIndex)
|
|
const newBold = col ? !col.bold : true
|
|
return {
|
|
...zone,
|
|
columns: zone.columns.map((c) =>
|
|
c.index === colIndex ? { ...c, bold: newBold } : c,
|
|
),
|
|
cells: zone.cells.map((cell) =>
|
|
cell.col_index === colIndex
|
|
? { ...cell, is_bold: newBold }
|
|
: cell,
|
|
),
|
|
}
|
|
}),
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid, pushUndo, setGrid, setDirty],
|
|
)
|
|
|
|
// ------------------------------------------------------------------
|
|
// Row formatting
|
|
// ------------------------------------------------------------------
|
|
|
|
const toggleRowHeader = useCallback(
|
|
(zoneIndex: number, rowIndex: number) => {
|
|
if (!grid) return
|
|
pushUndo(grid.zones)
|
|
|
|
// Cycle: normal -> header -> footer -> normal
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((zone) => {
|
|
if (zone.zone_index !== zoneIndex) return zone
|
|
return {
|
|
...zone,
|
|
rows: zone.rows.map((r) => {
|
|
if (r.index !== rowIndex) return r
|
|
if (!r.is_header && !r.is_footer) {
|
|
return { ...r, is_header: true, is_footer: false }
|
|
} else if (r.is_header) {
|
|
return { ...r, is_header: false, is_footer: true }
|
|
} else {
|
|
return { ...r, is_header: false, is_footer: false }
|
|
}
|
|
}),
|
|
}
|
|
}),
|
|
}
|
|
})
|
|
setDirty(true)
|
|
},
|
|
[grid, pushUndo, setGrid, setDirty],
|
|
)
|
|
|
|
// ------------------------------------------------------------------
|
|
// Column pattern auto-correction
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Detect dominant prefix+number patterns per column and complete
|
|
* partial matches. E.g. if 3+ cells read "p.70", "p.71", etc.,
|
|
* a cell reading ".65" is corrected to "p.65".
|
|
* Returns the number of corrections made.
|
|
*/
|
|
const autoCorrectColumnPatterns = useCallback(() => {
|
|
if (!grid) return 0
|
|
pushUndo(grid.zones)
|
|
|
|
let totalFixed = 0
|
|
const numberPattern = /^(.+?)(\d+)\s*$/
|
|
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((zone) => {
|
|
// Group cells by column
|
|
const cellsByCol = new Map<number, { cell: (typeof zone.cells)[0]; idx: number }[]>()
|
|
zone.cells.forEach((cell, idx) => {
|
|
const arr = cellsByCol.get(cell.col_index) || []
|
|
arr.push({ cell, idx })
|
|
cellsByCol.set(cell.col_index, arr)
|
|
})
|
|
|
|
const newCells = [...zone.cells]
|
|
|
|
for (const [, colEntries] of cellsByCol) {
|
|
// Count prefix occurrences
|
|
const prefixCounts = new Map<string, number>()
|
|
for (const { cell } of colEntries) {
|
|
const m = cell.text.trim().match(numberPattern)
|
|
if (m) {
|
|
prefixCounts.set(m[1], (prefixCounts.get(m[1]) || 0) + 1)
|
|
}
|
|
}
|
|
|
|
// Find dominant prefix (>= 3 occurrences)
|
|
let dominantPrefix = ''
|
|
let maxCount = 0
|
|
for (const [prefix, count] of prefixCounts) {
|
|
if (count >= 3 && count > maxCount) {
|
|
dominantPrefix = prefix
|
|
maxCount = count
|
|
}
|
|
}
|
|
if (!dominantPrefix) continue
|
|
|
|
// Fix partial matches — entries that are just [.?\s*]NUMBER
|
|
for (const { cell, idx } of colEntries) {
|
|
const text = cell.text.trim()
|
|
if (!text || text.startsWith(dominantPrefix)) continue
|
|
|
|
const numMatch = text.match(/^[.\s]*(\d+)\s*$/)
|
|
if (numMatch) {
|
|
newCells[idx] = { ...newCells[idx], text: `${dominantPrefix}${numMatch[1]}` }
|
|
totalFixed++
|
|
}
|
|
}
|
|
}
|
|
|
|
return { ...zone, cells: newCells }
|
|
}),
|
|
}
|
|
})
|
|
|
|
if (totalFixed > 0) setDirty(true)
|
|
return totalFixed
|
|
}, [grid, pushUndo, setGrid, setDirty])
|
|
|
|
// ------------------------------------------------------------------
|
|
// Multi-select bulk formatting
|
|
// ------------------------------------------------------------------
|
|
|
|
/** Toggle bold on all selected cells (and their columns). */
|
|
const toggleSelectedBold = useCallback(() => {
|
|
if (!grid || selectedCells.size === 0) return
|
|
pushUndo(grid.zones)
|
|
|
|
// Determine if we're turning bold on or off (majority rule)
|
|
const cells = grid.zones.flatMap((z) => z.cells)
|
|
const selectedArr = cells.filter((c) => selectedCells.has(c.cell_id))
|
|
const boldCount = selectedArr.filter((c) => c.is_bold).length
|
|
const newBold = boldCount < selectedArr.length / 2
|
|
|
|
setGrid((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
zones: prev.zones.map((zone) => ({
|
|
...zone,
|
|
cells: zone.cells.map((cell) =>
|
|
selectedCells.has(cell.cell_id) ? { ...cell, is_bold: newBold } : cell,
|
|
),
|
|
})),
|
|
}
|
|
})
|
|
setDirty(true)
|
|
setSelectedCells(new Set())
|
|
}, [grid, selectedCells, pushUndo, setGrid, setDirty])
|
|
|
|
return {
|
|
// Cell editing
|
|
updateCellText,
|
|
setCellColor,
|
|
// Column formatting
|
|
toggleColumnBold,
|
|
// Row formatting
|
|
toggleRowHeader,
|
|
// Pattern correction
|
|
autoCorrectColumnPatterns,
|
|
// Multi-select
|
|
selectedCells,
|
|
toggleCellSelection,
|
|
clearCellSelection,
|
|
toggleSelectedBold,
|
|
}
|
|
}
|