feat: added skeleton omr api integration

This commit is contained in:
2026-06-14 03:12:51 +05:30
parent 4c901980b2
commit 66129d4315
13 changed files with 1316 additions and 14 deletions
+28 -1
View File
@@ -1,7 +1,7 @@
import { app, BrowserWindow, ipcMain, dialog, Menu, nativeTheme } from "electron"
import { join, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { readFileSync, writeFileSync } from "node:fs"
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, readdirSync } from "node:fs"
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -69,6 +69,14 @@ ipcMain.handle("dialog:openFile", async (_event, filters: { name: string; extens
return result.filePaths
})
ipcMain.handle("dialog:openDirectory", async () => {
const result = await dialog.showOpenDialog(win!, {
properties: ["openDirectory"],
})
win?.webContents?.focus()
return result.filePaths?.[0] ?? ""
})
ipcMain.handle("dialog:saveFile", async (_event, defaultName: string, filters: { name: string; extensions: string[] }[]) => {
const result = await dialog.showSaveDialog(win!, {
defaultPath: defaultName,
@@ -97,6 +105,25 @@ ipcMain.handle("file:writeBinary", async (_event, path: string, base64: string)
writeFileSync(path, Buffer.from(base64, "base64"))
})
ipcMain.handle("dir:read", async (_event, dirPath: string) => {
if (!existsSync(dirPath)) return []
const entries = readdirSync(dirPath, { withFileTypes: true })
return entries.map((e) => ({ name: e.name, isDirectory: e.isDirectory() }))
})
ipcMain.handle("dir:ensure", async (_event, dirPath: string) => {
if (!existsSync(dirPath)) mkdirSync(dirPath, { recursive: true })
return dirPath
})
ipcMain.handle("path:exists", async (_event, path: string) => {
return existsSync(path)
})
ipcMain.handle("file:copy", async (_event, src: string, dest: string) => {
copyFileSync(src, dest)
})
app.whenReady().then(createWindow)
app.on("window-all-closed", () => {
+5
View File
@@ -12,6 +12,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
},
openFileDialog: (filters: { name: string; extensions: string[] }[]) =>
ipcRenderer.invoke("dialog:openFile", filters),
openDirectoryDialog: () => ipcRenderer.invoke("dialog:openDirectory"),
saveFileDialog: (defaultName: string, filters: { name: string; extensions: string[] }[]) =>
ipcRenderer.invoke("dialog:saveFile", defaultName, filters),
readFile: (path: string) => ipcRenderer.invoke("file:read", path),
@@ -19,4 +20,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
readImage: (path: string) => ipcRenderer.invoke("file:readImage", path),
writeBinaryFile: (path: string, base64: string) => ipcRenderer.invoke("file:writeBinary", path, base64),
getAppPath: () => ipcRenderer.invoke("app:getPath"),
readDir: (path: string) => ipcRenderer.invoke("dir:read", path),
ensureDir: (path: string) => ipcRenderer.invoke("dir:ensure", path),
pathExists: (path: string) => ipcRenderer.invoke("path:exists", path),
copyFile: (src: string, dest: string) => ipcRenderer.invoke("file:copy", src, dest),
})
+37 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from "react"
import { useEffect, useState, useRef, Component, type ReactNode } from "react"
import { Toolbar } from "./components/Toolbar"
import { TitleBar } from "./components/TitleBar"
import { LeftSidebar } from "./components/LeftSidebar"
@@ -7,15 +7,40 @@ import { PropertiesPanel } from "./components/PropertiesPanel"
import { StatusBar } from "./components/StatusBar"
import { KeyboardCheatSheet } from "./components/KeyboardCheatSheet"
import { ResizeHandle } from "./components/ResizeHandle"
import { TabBar } from "./components/TabBar"
import { ApiClientPanel } from "./components/ApiClient/ApiClientPanel"
import { useStore } from "./store/useStore"
import { loadTemplateDialog } from "./utils/loadFile"
import type { AppTab } from "./components/TabBar"
const MIN_SIDEBAR = 160
const MAX_SIDEBAR = 500
class TabErrorBoundary extends Component<{ children: ReactNode; tab: string }, { hasError: boolean; error: string }> {
constructor(props: { children: ReactNode; tab: string }) {
super(props)
this.state = { hasError: false, error: "" }
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error: error.message || String(error) }
}
render() {
if (this.state.hasError) {
return (
<div className="p-8 text-error bg-error/5 border border-error/20 rounded-lg m-4">
<h2 className="text-title-md font-medium mb-2">{this.props.tab} Tab Crashed</h2>
<pre className="text-code whitespace-pre-wrap">{this.state.error}</pre>
</div>
)
}
return this.props.children
}
}
export default function App() {
const containerRef = useRef<HTMLDivElement>(null)
const [showCheatSheet, setShowCheatSheet] = useState(false)
const [activeTab, setActiveTab] = useState<AppTab>("editor")
const [leftWidth, setLeftWidth] = useState(280)
const [rightWidth, setRightWidth] = useState(320)
const [leftCollapsed, setLeftCollapsed] = useState(false)
@@ -142,6 +167,9 @@ export default function App() {
return (
<div className="h-screen flex flex-col overflow-hidden">
<TitleBar />
<TabBar active={activeTab} onChange={setActiveTab} />
{activeTab === "editor" ? (
<TabErrorBoundary tab="Editor" key="editor">
<Toolbar />
<div ref={containerRef} className="flex flex-1 overflow-hidden">
<LeftSidebar width={leftCollapsed ? 32 : leftWidth} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((v) => !v)} />
@@ -154,6 +182,14 @@ export default function App() {
}} />}
<PropertiesPanel width={rightCollapsed ? 32 : rightWidth} collapsed={rightCollapsed} onToggle={() => setRightCollapsed((v) => !v)} />
</div>
</TabErrorBoundary>
) : (
<TabErrorBoundary tab="API Client" key="api">
<div className="flex-1 overflow-auto bg-canvas">
<ApiClientPanel />
</div>
</TabErrorBoundary>
)}
<StatusBar />
{showCheatSheet && <KeyboardCheatSheet onClose={() => setShowCheatSheet(false)} />}
</div>
@@ -0,0 +1,57 @@
import { Component, type ReactNode } from "react"
import { ServerSettings } from "./ServerSettings"
import { TemplateManager } from "./TemplateManager"
import { JobRunner } from "./JobRunner"
interface EBProps {
children: ReactNode
name: string
}
interface EBState {
hasError: boolean
error: string
}
class SafeWrap extends Component<EBProps, EBState> {
constructor(props: EBProps) {
super(props)
this.state = { hasError: false, error: "" }
}
static getDerivedStateFromError(error: Error): EBState {
return { hasError: true, error: error?.message || String(error) }
}
componentDidCatch(error: Error) {
console.error(`[SafeWrap ${this.props.name}]`, error)
}
render() {
if (this.state.hasError) {
return (
<div className="m-4 p-4 rounded-lg border border-error/20 bg-error/5 text-error">
<div className="text-caption font-medium mb-1">{this.props.name} crashed</div>
<pre className="text-code whitespace-pre-wrap">{this.state.error}</pre>
</div>
)
}
return this.props.children
}
}
export function ApiClientPanel() {
return (
<div className="flex flex-col gap-4 p-4">
<SafeWrap name="ServerSettings">
<ServerSettings />
</SafeWrap>
<SafeWrap name="TemplateManager">
<TemplateManager />
</SafeWrap>
<SafeWrap name="JobRunner">
<JobRunner />
</SafeWrap>
</div>
)
}
+463
View File
@@ -0,0 +1,463 @@
import { useState, useRef } from "react"
import { useApiStore } from "../../store/useApiStore"
import { submitJob, pollUntilComplete, getOutputUrl, getJobStatus } from "../../utils/apiClient"
import type { JobRun, JobStatusResponse } from "../../types/api"
function generateId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
async function fileFromPath(path: string): Promise<File> {
if (!window.electronAPI) throw new Error("Electron API required for file paths")
const content = await window.electronAPI.readFile(path)
const name = path.split(/[/\\]/).pop() || "file"
const ext = name.split(".").pop()?.toLowerCase() || ""
const mime = ext === "json" ? "application/json" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png"
return new File([new Blob([content], { type: mime })], name, { type: mime })
}
async function imageFileFromPath(path: string): Promise<File> {
if (!window.electronAPI) throw new Error("Electron API required for image paths")
const dataUrl = await window.electronAPI.readImage(path)
const name = path.split(/[/\\]/).pop() || "image.jpg"
const ext = name.split(".").pop()?.toLowerCase() || "jpg"
const mime = ext === "png" ? "image/png" : "image/jpeg"
const base64 = dataUrl.split(",")[1]
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new File([bytes], name, { type: mime })
}
export function JobRunner() {
const serverUrl = useApiStore((s) => s.serverUrl)
const templates = useApiStore((s) => s.templates)
const runs = useApiStore((s) => s.runs)
const addRun = useApiStore((s) => s.addRun)
const updateRun = useApiStore((s) => s.updateRun)
const activeRunId = useApiStore((s) => s.activeRunId)
const setActiveRunId = useApiStore((s) => s.setActiveRunId)
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("")
const [scannedPaths, setScannedPaths] = useState<string[]>([])
const [mode, setMode] = useState<"sync" | "async">("async")
const [statusMsg, setStatusMsg] = useState("")
const [submitting, setSubmitting] = useState(false)
const pollRefs = useRef<Record<string, boolean>>({})
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) || null
const pickFiles = async (opts: { multiple?: boolean; accept?: string } = {}) => {
if (!window.electronAPI) {
return new Promise<string[]>((resolve) => {
const input = document.createElement("input")
input.type = "file"
input.accept = opts.accept || "image/png,image/jpeg"
if (opts.multiple) input.multiple = true
input.onchange = () => {
const files = Array.from(input.files || [])
resolve(files.map((f) => f.name))
}
input.click()
})
}
const exts = opts.accept?.includes("json") ? ["json"] : ["jpg", "jpeg", "png"]
const paths = await window.electronAPI.openFileDialog([{ name: "Files", extensions: exts }])
return paths || []
}
const pickScannedFiles = async () => {
const paths = await pickFiles({ multiple: true })
if (paths.length > 0) setScannedPaths(paths)
}
const handleSubmit = async () => {
if (!selectedTemplate) { setStatusMsg("Select a template first"); return }
if (scannedPaths.length === 0) { setStatusMsg("Select at least one scanned sheet"); return }
setSubmitting(true)
setStatusMsg("Preparing files...")
try {
let templateFile: File
let metadataFile: File
let bubbleTemplateFile: File | undefined
let filledTemplateFile: File | undefined
if (window.electronAPI) {
templateFile = await imageFileFromPath(selectedTemplate.templatePath)
metadataFile = await fileFromPath(selectedTemplate.metadataPath)
if (selectedTemplate.bubbleTemplatePath) bubbleTemplateFile = await imageFileFromPath(selectedTemplate.bubbleTemplatePath)
if (selectedTemplate.filledTemplatePath) filledTemplateFile = await imageFileFromPath(selectedTemplate.filledTemplatePath)
} else {
const tplBlob = await fetch(selectedTemplate.templatePath).then((r) => r.blob())
const metaBlob = await fetch(selectedTemplate.metadataPath).then((r) => r.blob())
templateFile = new File([tplBlob], "template.jpg", { type: "image/jpeg" })
metadataFile = new File([metaBlob], "metadata.json", { type: "application/json" })
if (selectedTemplate.bubbleTemplatePath) {
const btBlob = await fetch(selectedTemplate.bubbleTemplatePath).then((r) => r.blob())
bubbleTemplateFile = new File([btBlob], "bubble_template.jpg", { type: "image/jpeg" })
}
if (selectedTemplate.filledTemplatePath) {
const ftBlob = await fetch(selectedTemplate.filledTemplatePath).then((r) => r.blob())
filledTemplateFile = new File([ftBlob], "filled_template.jpg", { type: "image/jpeg" })
}
}
const runId = generateId()
const run: JobRun = {
id: runId,
templateId: selectedTemplate.id,
templateName: selectedTemplate.name,
scannedPaths: [...scannedPaths],
serverUrl,
mode,
status: "running",
jobs: [],
createdAt: Date.now(),
}
addRun(run)
for (const scanPath of scannedPaths) {
let scannedFile: File
if (window.electronAPI) {
scannedFile = await imageFileFromPath(scanPath)
} else {
const input = document.createElement("input")
input.type = "file"
input.accept = "image/png,image/jpeg"
scannedFile = await new Promise((resolve) => {
input.onchange = () => resolve(input.files?.[0] as File)
input.click()
})
}
setStatusMsg(`Submitting ${scannedFile.name}...`)
const response = await submitJob(serverUrl, {
template: templateFile,
metadata: metadataFile,
scanned: scannedFile,
bubbleTemplate: bubbleTemplateFile || null,
filledTemplate: filledTemplateFile || null,
}, mode === "sync")
const jobStatus: JobStatusResponse = {
job_id: response.job_id,
status: response.status === "queued" ? "queued" : "processing",
}
updateRun(runId, (currentRun) => ({
jobs: [...currentRun.jobs, jobStatus],
}))
if (mode === "async") {
pollRefs.current[response.job_id] = true
pollUntilComplete(serverUrl, response.job_id, (status) => {
if (!pollRefs.current[response.job_id]) return
updateRun(runId, (currentRun) => ({
jobs: currentRun.jobs.map((j) =>
j.job_id === status.job_id ? status : j
),
}))
}).then((final) => {
if (!pollRefs.current[response.job_id]) return
updateRun(runId, (currentRun) => ({
jobs: currentRun.jobs.map((j) =>
j.job_id === final.job_id ? final : j
),
status: final.status === "failed" ? "failed" : "completed",
}))
}).catch((err) => {
setStatusMsg(`Polling error: ${err.message}`)
})
} else {
const finalStatus = await getJobStatus(serverUrl, response.job_id)
updateRun(runId, (currentRun) => ({
jobs: currentRun.jobs.map((j) =>
j.job_id === finalStatus.job_id ? finalStatus : j
),
status: finalStatus.status === "failed" ? "failed" : "completed",
}))
}
}
setStatusMsg(`Submitted ${scannedPaths.length} job(s) in ${mode} mode`)
} catch (err) {
setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setSubmitting(false)
}
}
const activeRun = runs.find((r) => r.id === activeRunId) || runs[runs.length - 1] || null
return (
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-col gap-3 bg-surface-card border border-hairline rounded-lg p-4">
<span className="text-title-sm font-medium text-ink">Run Grading</span>
<div className="flex flex-col gap-1">
<label className="text-caption text-muted">Select Template</label>
<select
value={selectedTemplateId}
onChange={(e) => setSelectedTemplateId(e.target.value)}
className="px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">-- Choose a template --</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-caption text-muted">Scanned Sheets</label>
<button
onClick={pickScannedFiles}
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
>
{scannedPaths.length > 0
? `${scannedPaths.length} file(s) selected`
: "Select Scanned Images"}
</button>
{scannedPaths.length > 0 && (
<ul className="text-caption text-muted mt-1 max-h-24 overflow-auto bg-surface-soft rounded-md px-2 py-1">
{scannedPaths.map((p, i) => (
<li key={i}>{p.split(/[/\\]/).pop() || p}</li>
))}
</ul>
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-caption text-muted">Processing Mode</label>
<div className="flex gap-2">
{(["async", "sync"] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={
mode === m
? "flex-1 px-3 py-2 rounded-md bg-primary/10 text-primary border border-primary/20 text-nav font-medium transition-all"
: "flex-1 px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all"
}
>
{m === "async" ? "Async (poll)" : "Sync (block)"}
</button>
))}
</div>
</div>
<button
onClick={handleSubmit}
disabled={submitting || !selectedTemplateId || scannedPaths.length === 0}
className="px-4 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all shadow-sm disabled:opacity-50"
>
{submitting ? "Submitting..." : "Grade Now"}
</button>
{statusMsg && (
<div className="text-caption px-3 py-2 rounded-md border bg-surface-soft border-hairline text-body">
{statusMsg}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<span className="text-title-sm font-medium text-ink">Runs</span>
{runs.length === 0 && (
<div className="text-caption text-muted px-3 py-4 rounded-md border border-hairline bg-surface-soft text-center">
No grading runs yet.
</div>
)}
{runs.map((run) => (
<button
key={run.id}
onClick={() => setActiveRunId(run.id)}
className={
(activeRunId === run.id
? "border-primary/30 bg-primary/5 "
: "border-hairline bg-surface-soft hover:bg-surface-card ") +
"flex flex-col gap-1 px-3 py-2 rounded-md border text-left transition-all"
}
>
<div className="flex items-center justify-between">
<span className="text-nav font-medium text-ink">{run.templateName}</span>
<span className={
run.status === "completed"
? "text-caption text-success font-medium"
: run.status === "failed"
? "text-caption text-error font-medium"
: "text-caption text-warning font-medium"
}>
{run.status.toUpperCase()}
</span>
</div>
<span className="text-caption text-muted">
{run.scannedPaths.length} sheet(s) · {run.mode} · {new Date(run.createdAt).toLocaleTimeString()}
</span>
</button>
))}
</div>
{activeRun && <ResultsPanel run={activeRun} />}
</div>
)
}
function ResultsPanel({ run }: { run: JobRun }) {
const serverUrl = useApiStore((s) => s.serverUrl)
const [expandedJob, setExpandedJob] = useState<string | null>(null)
const allCompleted = run.jobs.length > 0 && run.jobs.every((j) => j.status === "completed")
const anyFailed = run.jobs.some((j) => j.status === "failed")
const overallStatus = anyFailed ? "failed" : allCompleted ? "completed" : "running"
return (
<div className="flex flex-col gap-3 bg-surface-card border border-hairline rounded-lg p-4">
<div className="flex items-center justify-between">
<span className="text-title-sm font-medium text-ink">Results</span>
<span className={
overallStatus === "completed"
? "text-caption text-success font-medium px-2 py-0.5 rounded bg-success/10"
: overallStatus === "failed"
? "text-caption text-error font-medium px-2 py-0.5 rounded bg-error/10"
: "text-caption text-warning font-medium px-2 py-0.5 rounded bg-warning/10"
}>
{overallStatus.toUpperCase()}
</span>
</div>
{run.jobs.length === 0 && (
<div className="text-caption text-muted px-3 py-4 rounded-md border border-hairline bg-surface-soft text-center">
No jobs yet.
</div>
)}
{run.jobs.map((job) => (
<JobResultCard
key={job.job_id}
job={job}
serverUrl={serverUrl}
expanded={expandedJob === job.job_id}
onToggle={() => setExpandedJob(expandedJob === job.job_id ? null : job.job_id)}
/>
))}
</div>
)
}
function JobResultCard({
job,
serverUrl,
expanded,
onToggle,
}: {
job: JobStatusResponse
serverUrl: string
expanded: boolean
onToggle: () => void
}) {
const statusColor =
job.status === "completed"
? "text-success border-success/20 bg-success/5"
: job.status === "failed"
? "text-error border-error/20 bg-error/5"
: "text-warning border-warning/20 bg-warning/5"
const resultData = job.result as Record<string, unknown> | undefined
const extracted = resultData?.ExtractedData as Record<string, unknown> | undefined
return (
<div className={`rounded-md border p-3 transition-all ${statusColor}`}>
<button
onClick={onToggle}
className="flex items-center justify-between w-full text-left"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">
{job.status === "completed" ? "check_circle" : job.status === "failed" ? "error" : "pending"}
</span>
<span className="text-nav font-medium">Job {job.job_id.slice(0, 8)}...</span>
</div>
<span className="text-caption font-medium uppercase">{job.status}</span>
</button>
{expanded && (
<div className="flex flex-col gap-3 mt-3 animate-fade-in">
{job.error_message && (
<div className="text-caption text-error bg-error/10 px-3 py-2 rounded border border-error/20">
{job.error_message}
</div>
)}
{extracted && Object.keys(extracted).length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-caption text-muted font-medium">Extracted Answers</span>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{Object.entries(extracted).map(([qNum, rawValue]) => {
const label = isNaN(Number(qNum)) ? qNum : `Q${qNum}`
const answers = formatExtractedValue(rawValue)
return (
<div
key={qNum}
className="flex items-center justify-between px-3 py-2 rounded-md bg-canvas border border-hairline"
>
<span className="text-caption text-muted">{label}</span>
<span className="text-nav font-medium text-ink">{answers}</span>
</div>
)
})}
</div>
</div>
)}
{job.output_files && job.output_files.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-caption text-muted font-medium">Output Files</span>
<div className="grid grid-cols-2 gap-2">
{job.output_files.map((file) => (
<a
key={file}
href={getOutputUrl(serverUrl, job.job_id, file)}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-md bg-canvas border border-hairline text-caption text-primary hover:bg-surface-soft transition-all"
>
<span className="material-symbols-outlined text-[16px]">open_in_new</span>
<span className="truncate">{file}</span>
</a>
))}
</div>
</div>
)}
{job.result && !extracted && (
<div className="flex flex-col gap-1">
<span className="text-caption text-muted font-medium">Raw Result</span>
<pre className="text-code bg-canvas border border-hairline rounded-md p-2 overflow-auto max-h-48">
{JSON.stringify(job.result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
)
}
function formatExtractedValue(value: unknown): string {
if (value === null || value === undefined) return "—"
if (Array.isArray(value)) {
return value.map((v) => formatExtractedValue(v)).join(", ") || "—"
}
if (typeof value === "object") {
// Some APIs return objects for each answer; try to extract meaningful string
const obj = value as Record<string, unknown>
if ("answer" in obj && typeof obj.answer === "string") return obj.answer
if ("value" in obj && typeof obj.value === "string") return obj.value
if ("selected" in obj && typeof obj.selected === "string") return obj.selected
return JSON.stringify(value)
}
return String(value)
}
@@ -0,0 +1,59 @@
import { useState } from "react"
import { useApiStore } from "../../store/useApiStore"
import { checkHealth } from "../../utils/apiClient"
export function ServerSettings() {
const serverUrl = useApiStore((s) => s.serverUrl)
const setServerUrl = useApiStore((s) => s.setServerUrl)
const [inputUrl, setInputUrl] = useState(serverUrl)
const [checking, setChecking] = useState(false)
const [health, setHealth] = useState<{ ok: boolean; msg: string } | null>(null)
const handleCheck = async () => {
setChecking(true)
setHealth(null)
try {
const data = await checkHealth(inputUrl)
setHealth({ ok: true, msg: `Server OK — ${data.status} (Redis: ${data.redis}, Storage: ${data.storage})` })
setServerUrl(inputUrl)
} catch (err) {
setHealth({ ok: false, msg: `Unreachable: ${err instanceof Error ? err.message : String(err)}` })
} finally {
setChecking(false)
}
}
return (
<div className="flex flex-col gap-3 p-4 bg-surface-card border border-hairline rounded-lg">
<span className="text-title-sm font-medium text-ink">Server Settings</span>
<div className="flex gap-2">
<input
type="text"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder="http://localhost:8000"
className="flex-1 px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<button
onClick={handleCheck}
disabled={checking}
className="px-4 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all shadow-sm disabled:opacity-50"
>
{checking ? "Checking..." : "Check"}
</button>
</div>
{health && (
<div
className={
(health.ok
? "text-success bg-success/5 border-success/20 "
: "text-error bg-error/5 border-error/20 ") +
"text-caption px-3 py-2 rounded-md border"
}
>
{health.msg}
</div>
)}
</div>
)
}
@@ -0,0 +1,345 @@
import { useState, useEffect, useRef } from "react"
import { useApiStore } from "../../store/useApiStore"
import { loadTemplatesFromDir, saveTemplatesToDir, copyTemplateFiles } from "../../utils/templateStorage"
import type { TemplateEntry } from "../../types/api"
function generateId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
export function TemplateManager() {
const templateDir = useApiStore((s) => s.templateDir)
const setTemplateDir = useApiStore((s) => s.setTemplateDir)
const templates = useApiStore((s) => s.templates)
const loadTemplates = useApiStore((s) => s.loadTemplates)
const addTemplate = useApiStore((s) => s.addTemplate)
const removeTemplate = useApiStore((s) => s.removeTemplate)
const updateRun = useApiStore((s) => s.updateRun)
const [isElectron] = useState(() => !!window.electronAPI)
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState("")
// Add form state
const [newName, setNewName] = useState("")
const [templateFile, setTemplateFile] = useState<File | null>(null)
const [metadataFile, setMetadataFile] = useState<File | null>(null)
const [bubbleTemplateFile, setBubbleTemplateFile] = useState<File | null>(null)
const [filledTemplateFile, setFilledTemplateFile] = useState<File | null>(null)
// Edit state
const [editingId, setEditingId] = useState<string | null>(null)
const [editName, setEditName] = useState("")
const hasLoaded = useRef(false)
useEffect(() => {
if (hasLoaded.current) return
hasLoaded.current = true
if (!templateDir) return
setLoading(true)
loadTemplatesFromDir(templateDir)
.then((list) => loadTemplates(list))
.catch(() => {})
.finally(() => setLoading(false))
}, [templateDir, loadTemplates])
const pickDirectory = async () => {
if (!window.electronAPI) return
const dir = await window.electronAPI.openDirectoryDialog()
if (dir) {
setTemplateDir(dir)
setLoading(true)
const list = await loadTemplatesFromDir(dir)
loadTemplates(list)
setLoading(false)
}
}
const resetForm = () => {
setNewName("")
setTemplateFile(null)
setMetadataFile(null)
setBubbleTemplateFile(null)
setFilledTemplateFile(null)
}
const handleAddTemplate = async () => {
if (!templateDir) { setMessage("Select a template directory first"); return }
if (!templateFile || !metadataFile) { setMessage("Select both template image and metadata.json"); return }
if (!newName.trim()) { setMessage("Enter a template name"); return }
setLoading(true)
try {
let templatePath = ""
let metadataPath = ""
let bubblePath: string | undefined
let filledPath: string | undefined
if (window.electronAPI) {
const tmpDir = await window.electronAPI.ensureDir(`${templateDir}/.tmp`)
const tmpTemplate = `${tmpDir}/${templateFile.name}`
const tmpMetadata = `${tmpDir}/${metadataFile.name}`
const tmpBubble = bubbleTemplateFile ? `${tmpDir}/${bubbleTemplateFile.name}` : undefined
const tmpFilled = filledTemplateFile ? `${tmpDir}/${filledTemplateFile.name}` : undefined
const tplReader = new FileReader()
const metaReader = new FileReader()
const tplBase64 = await new Promise<string>((resolve) => {
tplReader.onload = () => resolve(tplReader.result as string)
tplReader.readAsDataURL(templateFile)
})
const metaText = await new Promise<string>((resolve) => {
metaReader.onload = () => resolve(metaReader.result as string)
metaReader.readAsText(metadataFile)
})
await window.electronAPI.writeBinaryFile(tmpTemplate, tplBase64.split(",")[1])
await window.electronAPI.writeFile(tmpMetadata, metaText)
if (tmpBubble && bubbleTemplateFile) {
const btReader = new FileReader()
const btBase64 = await new Promise<string>((resolve) => {
btReader.onload = () => resolve(btReader.result as string)
btReader.readAsDataURL(bubbleTemplateFile)
})
await window.electronAPI.writeBinaryFile(tmpBubble, btBase64.split(",")[1])
}
if (tmpFilled && filledTemplateFile) {
const ftReader = new FileReader()
const ftBase64 = await new Promise<string>((resolve) => {
ftReader.onload = () => resolve(ftReader.result as string)
ftReader.readAsDataURL(filledTemplateFile)
})
await window.electronAPI.writeBinaryFile(tmpFilled, ftBase64.split(",")[1])
}
const copied = await copyTemplateFiles(tmpTemplate, tmpMetadata, templateDir, newName.trim(), tmpBubble, tmpFilled)
templatePath = copied.templatePath
metadataPath = copied.metadataPath
bubblePath = copied.bubbleTemplatePath
filledPath = copied.filledTemplatePath
} else {
templatePath = URL.createObjectURL(templateFile)
metadataPath = URL.createObjectURL(metadataFile)
if (bubbleTemplateFile) bubblePath = URL.createObjectURL(bubbleTemplateFile)
if (filledTemplateFile) filledPath = URL.createObjectURL(filledTemplateFile)
}
const entry: TemplateEntry = {
id: generateId(),
name: newName.trim(),
templatePath,
metadataPath,
bubbleTemplatePath: bubblePath,
filledTemplatePath: filledPath,
createdAt: Date.now(),
}
const next = [...templates, entry]
await saveTemplatesToDir(templateDir, next)
addTemplate(entry)
resetForm()
setMessage(`Template "${entry.name}" saved`)
} catch (err) {
setMessage(`Error: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setLoading(false)
}
}
const handleDelete = async (id: string) => {
if (!templateDir) return
const next = templates.filter((t) => t.id !== id)
await saveTemplatesToDir(templateDir, next)
removeTemplate(id)
setMessage("Template removed")
}
const startEdit = (t: TemplateEntry) => {
setEditingId(t.id)
setEditName(t.name)
}
const cancelEdit = () => {
setEditingId(null)
setEditName("")
}
const saveEdit = async (id: string) => {
if (!editName.trim()) { setMessage("Name cannot be empty"); return }
if (!templateDir) return
const next = templates.map((t) => t.id === id ? { ...t, name: editName.trim() } : t)
await saveTemplatesToDir(templateDir, next)
loadTemplates(next)
setEditingId(null)
setEditName("")
setMessage("Template updated")
}
const selectFile = (accept: string, callback: (file: File) => void) => {
const input = document.createElement("input")
input.type = "file"
input.accept = accept
input.onchange = () => {
const file = input.files?.[0]
if (file) callback(file)
}
input.click()
}
return (
<div className="flex flex-col gap-4 p-4">
<div className="flex items-center gap-3">
<span className="text-title-sm font-medium text-ink">Template Directory</span>
{isElectron && (
<button
onClick={pickDirectory}
className="px-3 py-1.5 rounded-md bg-surface-card border border-hairline text-nav hover:bg-surface-cream-strong transition-all"
>
{templateDir ? "Change Directory" : "Choose Directory"}
</button>
)}
</div>
{templateDir && (
<div className="text-caption text-muted bg-surface-soft px-3 py-2 rounded-md border border-hairline">
{templateDir}
</div>
)}
{!isElectron && (
<div className="text-caption text-warning bg-warning/5 px-3 py-2 rounded-md border border-warning/20">
Running in web mode. Templates will not persist across reloads.
</div>
)}
<div className="flex flex-col gap-3 bg-surface-card border border-hairline rounded-lg p-4">
<span className="text-title-sm font-medium text-ink">Add New Template</span>
<input
type="text"
placeholder="Template name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => selectFile("image/png,image/jpeg", setTemplateFile)}
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
>
{templateFile ? `Template: ${templateFile.name}` : "Select Template Image"}
</button>
<button
onClick={() => selectFile("application/json", setMetadataFile)}
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
>
{metadataFile ? `Metadata: ${metadataFile.name}` : "Select metadata.json"}
</button>
<button
onClick={() => selectFile("image/png,image/jpeg", setBubbleTemplateFile)}
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
>
{bubbleTemplateFile ? `Bubble: ${bubbleTemplateFile.name}` : "Select Bubble Template (opt)"}
</button>
<button
onClick={() => selectFile("image/png,image/jpeg", setFilledTemplateFile)}
className="px-3 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all text-left"
>
{filledTemplateFile ? `Filled: ${filledTemplateFile.name}` : "Select Filled Bubble (opt)"}
</button>
</div>
<div className="flex gap-2">
<button
onClick={handleAddTemplate}
disabled={loading}
className="flex-1 px-4 py-2 bg-primary text-on-primary rounded-md text-button font-medium hover:bg-primary-active active:scale-[0.97] transition-all shadow-sm disabled:opacity-50"
>
{loading ? "Saving..." : "Save Template"}
</button>
<button
onClick={resetForm}
className="px-4 py-2 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all"
>
Clear
</button>
</div>
</div>
{message && (
<div className="text-caption px-3 py-2 rounded-md border bg-surface-soft border-hairline text-body">
{message}
</div>
)}
<div className="flex flex-col gap-2">
<span className="text-title-sm font-medium text-ink">Stored Templates</span>
{templates.length === 0 && (
<div className="text-caption text-muted px-3 py-4 rounded-md border border-hairline bg-surface-soft text-center">
No templates yet. Add one above.
</div>
)}
{templates.map((t) => (
<div
key={t.id}
className="flex flex-col gap-2 px-3 py-2 rounded-md border border-hairline bg-surface-soft hover:bg-surface-card transition-all"
>
{editingId === t.id ? (
<div className="flex flex-col gap-2">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="px-3 py-2 rounded-md border border-hairline bg-canvas text-body-md focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<div className="flex gap-2">
<button
onClick={() => saveEdit(t.id)}
className="px-3 py-1.5 rounded-md bg-primary text-on-primary text-nav font-medium hover:bg-primary-active transition-all"
>
Save
</button>
<button
onClick={cancelEdit}
className="px-3 py-1.5 rounded-md border border-hairline bg-surface-soft text-nav hover:bg-surface-cream-strong transition-all"
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-nav font-medium text-ink">{t.name}</span>
<span className="text-caption text-muted">
{new Date(t.createdAt).toLocaleString()}
{t.bubbleTemplatePath && " · bubble"}
{t.filledTemplatePath && " · filled"}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => startEdit(t)}
className="p-1.5 rounded-md text-muted hover:text-primary hover:bg-primary/5 transition-all"
title="Edit name"
>
<span className="material-symbols-outlined text-[18px]">edit</span>
</button>
<button
onClick={() => handleDelete(t.id)}
className="p-1.5 rounded-md text-muted hover:text-error hover:bg-error/5 transition-all"
title="Delete"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { useState } from "react"
export type AppTab = "editor" | "api"
interface TabBarProps {
active: AppTab
onChange: (tab: AppTab) => void
}
export function TabBar({ active, onChange }: TabBarProps) {
const tabs: { key: AppTab; label: string }[] = [
{ key: "editor", label: "Editor" },
{ key: "api", label: "API Client" },
]
return (
<div className="h-10 bg-surface-soft border-b border-hairline flex items-center px-4 gap-1 shrink-0 select-none">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => onChange(t.key)}
className={
active === t.key
? "px-4 py-1.5 rounded-md bg-primary/10 text-primary border border-primary/20 text-nav font-medium transition-all"
: "px-4 py-1.5 rounded-md text-muted hover:text-ink hover:bg-surface-card text-nav font-medium transition-all"
}
>
{t.label}
</button>
))}
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
import { create } from "zustand"
import type { TemplateEntry, JobRun } from "../types/api"
function safeGetItem(key: string, fallback: string): string {
try {
return localStorage.getItem(key) || fallback
} catch {
return fallback
}
}
function safeSetItem(key: string, value: string) {
try {
localStorage.setItem(key, value)
} catch {
// ignore
}
}
export interface ApiStore {
serverUrl: string
setServerUrl: (url: string) => void
templates: TemplateEntry[]
addTemplate: (t: TemplateEntry) => void
removeTemplate: (id: string) => void
loadTemplates: (list: TemplateEntry[]) => void
templateDir: string
setTemplateDir: (dir: string) => void
runs: JobRun[]
addRun: (run: JobRun) => void
updateRun: (id: string, patch: Partial<JobRun> | ((run: JobRun) => Partial<JobRun>)) => void
removeRun: (id: string) => void
loadRuns: (list: JobRun[]) => void
activeRunId: string | null
setActiveRunId: (id: string | null) => void
}
export const useApiStore = create<ApiStore>((set, get) => ({
serverUrl: safeGetItem("omr_server_url", "http://localhost:8000"),
setServerUrl: (url) => {
safeSetItem("omr_server_url", url)
set({ serverUrl: url })
},
templates: [],
addTemplate: (t) => set((s) => ({ templates: [...s.templates, t] })),
removeTemplate: (id) => set((s) => ({ templates: s.templates.filter((t) => t.id !== id) })),
loadTemplates: (list) => set({ templates: list }),
templateDir: safeGetItem("omr_template_dir", ""),
setTemplateDir: (dir) => {
safeSetItem("omr_template_dir", dir)
set({ templateDir: dir })
},
runs: [],
addRun: (run) => set((s) => ({ runs: [...s.runs, run], activeRunId: run.id })),
updateRun: (id, patch) =>
set((s) => {
const run = s.runs.find((r) => r.id === id)
if (!run) return {}
const next = typeof patch === "function" ? (patch as (run: JobRun) => Partial<JobRun>)(run) : patch
return {
runs: s.runs.map((r) => (r.id === id ? { ...r, ...next } : r)),
}
}),
removeRun: (id) => set((s) => ({ runs: s.runs.filter((r) => r.id !== id) })),
loadRuns: (list) => set({ runs: list }),
activeRunId: null,
setActiveRunId: (id) => set({ activeRunId: id }),
}))
+56
View File
@@ -0,0 +1,56 @@
export interface ProcessResponse {
job_id: string
status: string
message?: string
}
export interface JobStatusResponse {
job_id: string
status: "queued" | "processing" | "completed" | "failed" | "unknown"
progress?: string | null
result?: Record<string, unknown> | null
output_files?: string[] | null
error_code?: string | null
error_message?: string | null
created_at?: string | null
started_at?: string | null
completed_at?: string | null
}
export interface JobListResponse {
jobs: JobStatusResponse[]
total: number
page: number
per_page: number
}
export interface HealthResponse {
status: string
version: string
redis: string
storage: string
database: string
}
export interface TemplateEntry {
id: string
name: string
templatePath: string
metadataPath: string
bubbleTemplatePath?: string
filledTemplatePath?: string
createdAt: number
}
export interface JobRun {
id: string
templateId: string
templateName: string
scannedPaths: string[]
serverUrl: string
mode: "sync" | "async"
status: "idle" | "running" | "completed" | "failed"
jobs: JobStatusResponse[]
createdAt: number
completedAt?: number
}
+6
View File
@@ -7,11 +7,17 @@ export interface ElectronAPI {
isMaximized: () => Promise<boolean>
onMaximizeChange: (callback: (maximized: boolean) => void) => void
openFileDialog: (filters: { name: string; extensions: string[] }[]) => Promise<string[]>
openDirectoryDialog: () => Promise<string>
saveFileDialog: (defaultName: string, filters: { name: string; extensions: string[] }[]) => Promise<string>
readFile: (path: string) => Promise<string>
writeFile: (path: string, data: string) => Promise<void>
readImage: (path: string) => Promise<string>
writeBinaryFile: (path: string, base64: string) => Promise<void>
getAppPath: () => Promise<string>
readDir: (path: string) => Promise<{ name: string; isDirectory: boolean }[]>
ensureDir: (path: string) => Promise<string>
pathExists: (path: string) => Promise<boolean>
copyFile: (src: string, dest: string) => Promise<void>
}
declare global {
+75
View File
@@ -0,0 +1,75 @@
import type { ProcessResponse, JobStatusResponse, HealthResponse } from "../types/api"
export async function checkHealth(baseUrl: string): Promise<HealthResponse> {
const res = await fetch(`${baseUrl}/health`, { method: "GET" })
if (!res.ok) throw new Error(`Health check failed: ${res.status}`)
return res.json()
}
export async function submitJob(
baseUrl: string,
files: {
template: File
metadata: File
scanned: File
bubbleTemplate?: File | null
filledTemplate?: File | null
},
sync = false,
): Promise<ProcessResponse> {
const form = new FormData()
form.append("template", files.template)
form.append("metadata", files.metadata)
form.append("scanned", files.scanned)
if (files.bubbleTemplate) form.append("bubble_template", files.bubbleTemplate)
if (files.filledTemplate) form.append("filled_template", files.filledTemplate)
const endpoint = sync ? "/v1/process/sync" : "/v1/process"
const res = await fetch(`${baseUrl}${endpoint}`, {
method: "POST",
body: form,
})
if (!res.ok) {
const text = await res.text().catch(() => "Unknown error")
throw new Error(`API error ${res.status}: ${text}`)
}
return res.json()
}
export async function getJobStatus(baseUrl: string, jobId: string): Promise<JobStatusResponse> {
const res = await fetch(`${baseUrl}/v1/jobs/${jobId}`)
if (!res.ok) throw new Error(`Failed to fetch job status: ${res.status}`)
return res.json()
}
export function getOutputUrl(baseUrl: string, jobId: string, filename: string): string {
return `${baseUrl}/v1/jobs/${jobId}/output/${encodeURIComponent(filename)}`
}
export async function listJobs(baseUrl: string, page = 1, perPage = 20): Promise<{ jobs: JobStatusResponse[]; total: number }> {
const res = await fetch(`${baseUrl}/v1/jobs?page=${page}&per_page=${perPage}`)
if (!res.ok) throw new Error(`Failed to list jobs: ${res.status}`)
const data = await res.json()
return { jobs: data.jobs ?? [], total: data.total ?? 0 }
}
export async function deleteJob(baseUrl: string, jobId: string): Promise<void> {
const res = await fetch(`${baseUrl}/v1/jobs/${jobId}`, { method: "DELETE" })
if (!res.ok) throw new Error(`Failed to delete job: ${res.status}`)
}
export async function pollUntilComplete(
baseUrl: string,
jobId: string,
onUpdate?: (status: JobStatusResponse) => void,
intervalMs = 2000,
maxAttempts = 300,
): Promise<JobStatusResponse> {
for (let i = 0; i < maxAttempts; i++) {
const status = await getJobStatus(baseUrl, jobId)
onUpdate?.(status)
if (status.status === "completed" || status.status === "failed") return status
await new Promise((r) => setTimeout(r, intervalMs))
}
throw new Error("Polling timed out")
}
+64
View File
@@ -0,0 +1,64 @@
import type { TemplateEntry } from "../types/api"
const TEMPLATES_FILE = "templates.json"
function getTemplatesPath(dir: string) {
return `${dir}/${TEMPLATES_FILE}`
}
export async function loadTemplatesFromDir(dir: string): Promise<TemplateEntry[]> {
if (!window.electronAPI) return []
const path = getTemplatesPath(dir)
const exists = await window.electronAPI.pathExists(path)
if (!exists) return []
const content = await window.electronAPI.readFile(path)
try {
const parsed = JSON.parse(content)
if (Array.isArray(parsed)) return parsed as TemplateEntry[]
return []
} catch {
return []
}
}
export async function saveTemplatesToDir(dir: string, templates: TemplateEntry[]): Promise<void> {
if (!window.electronAPI) return
await window.electronAPI.ensureDir(dir)
const path = getTemplatesPath(dir)
await window.electronAPI.writeFile(path, JSON.stringify(templates, null, 2))
}
export async function copyTemplateFiles(
templatePath: string,
metadataPath: string,
destDir: string,
name: string,
bubbleTemplatePath?: string,
filledTemplatePath?: string,
): Promise<{ templatePath: string; metadataPath: string; bubbleTemplatePath?: string; filledTemplatePath?: string }> {
if (!window.electronAPI) throw new Error("Electron API not available")
await window.electronAPI.ensureDir(destDir)
const safeName = name.replace(/[^a-zA-Z0-9_\-]/g, "_")
const tplExt = templatePath.split(".").pop() || "jpg"
const destTemplate = `${destDir}/${safeName}_template.${tplExt}`
const destMetadata = `${destDir}/${safeName}_metadata.json`
await window.electronAPI.copyFile(templatePath, destTemplate)
await window.electronAPI.copyFile(metadataPath, destMetadata)
let destBubbleTemplate: string | undefined
let destFilledTemplate: string | undefined
if (bubbleTemplatePath) {
const btExt = bubbleTemplatePath.split(".").pop() || "jpg"
destBubbleTemplate = `${destDir}/${safeName}_bubble_template.${btExt}`
await window.electronAPI.copyFile(bubbleTemplatePath, destBubbleTemplate)
}
if (filledTemplatePath) {
const ftExt = filledTemplatePath.split(".").pop() || "jpg"
destFilledTemplate = `${destDir}/${safeName}_filled_template.${ftExt}`
await window.electronAPI.copyFile(filledTemplatePath, destFilledTemplate)
}
return { templatePath: destTemplate, metadataPath: destMetadata, bubbleTemplatePath: destBubbleTemplate, filledTemplatePath: destFilledTemplate }
}