diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0baf015 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/_site diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..69b90d1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,123 @@ +# Contributing to TPEN-Prompts + +Thank you for contributing split-tool interfaces that help researchers transcribe manuscripts using LLM assistance. + +## Goals + +1. Receive context from a parent TPEN frame using postMessage. +2. Generate copy-ready prompts for a user-chosen LLM. +3. Accept LLM candidate output (text + canvas-relative bounds). +4. Validate candidates and deterministically build TPEN annotation envelopes. +5. PUT the full Page object with updated items to `/project/:projectId/page/:pageId`. + +## Repository Layout + +- `_pages/`: Split-tool page templates and UI. +- `_scripts/`: Runtime JavaScript modules. +- `_tools/`: Markdown docs for API access, data formats, and workflow guidance. + +## Minimal Message Contract + +The parent frame sends a `TPEN_CONTEXT` message with required fields: + +```json +{ + "type": "TPEN_CONTEXT", + "projectId": "69e28b7ac3ca82132fd140c3", + "pageId": "69e28b7ac3ca82132fd140c6", + "canvasId": "https://t-pen.org/TPEN/canvas/13252824", + "imageUrl": "https://t-pen.org/TPEN/pageImage?folio=13252824" +} +``` + +Optional fields: + +```json +{ + "currentLineId": "https://store.rerum.io/v1/id/69e291337a53a991d10ddbff", + "columns": 1, + "manifestUri": "https://t-pen.org/TPEN/manifest/7306?version=3", + "canvasWidth": 2200, + "canvasHeight": 3400 +} +``` + +`TPEN_ID_TOKEN` is requested separately and should not be bundled into `TPEN_CONTEXT`. + +## Candidate Format + +Preferred LLM output is tool-call style JSON: + +```json +{ + "tool": "save_tpen_annotations", + "arguments": { + "candidates": [ + { + "text": "example transcription", + "bounds": { + "x": 12.34, + "y": 45.67, + "w": 8.9, + "h": 2.1, + "unit": "percent" + } + } + ] + } +} +``` + +Notes: + +- Percent bounds are preferred for model portability across image sizes. +- When Canvas dimensions are available, convert percent bounds to integer coordinates derived from Canvas dimensions before save. + +## Save Flow + +1. Parse and extract candidates from model JSON. +2. Normalize bounds and convert percent values to integer coordinates derived from Canvas dimensions where possible. +3. Validate candidates in `_scripts/tpen-api.js`. +4. Build annotations envelope in `_scripts/tpen-api.js`. +5. Save with PUT to `/project/:projectId/page/:pageId`. + +## Runtime Modules + +- `_scripts/message-bridge.js` + - Origin allow-list validation + - Context subscription + - Separate ID token request/subscribe flow +- `_scripts/prompt-builder.js` + - Prompt generation with concise, tool-call-oriented schema +- `_scripts/tpen-api.js` + - Candidate normalization and validation + - Envelope building and API save helper +- `_scripts/transcription-assist.js` + - UI orchestration, parsing, status updates, save action + +## Security Requirements + +1. Validate event origins in `_scripts/message-bridge.js`. +2. Treat `idToken` as sensitive. +3. Do not log full tokens. +4. Display token only in obscured form in UI summary. +5. Require explicit user action before save. +6. Validate all model output before API calls. + +## Local Development + +1. Run Jekyll locally: + - `bundle exec jekyll serve` +2. Open the tool: + - `http://localhost:4000/split-tools/transcription-assist/` +3. Ensure parent origin is allow-listed in `_scripts/message-bridge.js`. + +## Pull Request Expectations + +1. Document expected model output schema. +2. Include sample input prompt and sample model output. +3. Show how `_scripts/tpen-api.js` validators/builders are used. +4. Confirm TPEN context parsing and ID token flow. +5. Keep generated `_site` files out of source edits. + +Questions? Open an issue or contact the TPEN team. diff --git a/README.md b/README.md index 99dbd2d..c8251c4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ # TPEN-Prompts -An AI assistant prompt generator for TPEN3. It is a split screen tool for the transcription interface. + +An AI assistant prompt generator for TPEN3. It is a split screen tool for the transcription interface. + +## Project Layout + +- `_pages/`: GitHub Pages interfaces for split-screen tools. +- `_scripts/`: Runtime JavaScript modules (message bridge, prompt builders, API helpers). +- `_tools/`: Markdown tool docs (API access, data format, and workflow guidance). +- `CONTRIBUTING.md`: Interface contract and contribution guide. + +## Example Interface + +- Split tool page: `/split-tools/transcription-assist/` +- Source page file: `_pages/transcription-assist.html` +- Source helper modules: `_scripts/message-bridge.js`, `_scripts/prompt-builder.js`, `_scripts/tpen-api.js` + +This repository is configured for Jekyll collections so files in `_pages`, `_scripts`, and `_tools` are output by GitHub Pages. diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..6b30a60 --- /dev/null +++ b/_config.yml @@ -0,0 +1,20 @@ +title: TPEN Prompts + +collections: + pages: + output: true + permalink: /:path/ + tools: + output: true + permalink: /tools/:name + scripts: + output: true + permalink: /scripts/:name/ + +defaults: + - scope: + path: "" + type: tools + values: + layout: null + sitemap: false diff --git a/_pages/transcription-assist.html b/_pages/transcription-assist.html new file mode 100644 index 0000000..39a1063 --- /dev/null +++ b/_pages/transcription-assist.html @@ -0,0 +1,288 @@ +--- +layout: null +title: TPEN Transcription Assist +permalink: /split-tools/transcription-assist/ +--- + + + + +
+
+

TPEN Split Tool: Transcription Assist

+

This page accepts project context from a parent TPEN frame and creates a copy-ready LLM prompt.

+ +

Incoming Context

+
+
OriginWaiting for parent...
+
Project ID-
+
Page ID-
+
Manifest ID-
+
Canvas ID-
+
Image URL-
+
ID TokenNot requested
+
+ +
+ + + +
+ +
+ This scaffold intentionally keeps API wiring explicit. Confirm your TPEN endpoint and payload shape before production use. +
+
+ +
+

LLM Prompt

+ +
+ + +
+
No context yet.
+
+ +
+
+ Manual TPEN Update (Fallback for Read-Only Models) +

+ If your model can only GET resources, have it return a short instruction and a serialized JSON payload. + Paste the payload into the matching box below and submit from this browser to call TPEN. +

+ +
+
+

Update Page

+

PUT /project/:projectId/page/:pageId with a Page JSON body (typically containing items).

+ + +
+ +
+

Update Columns

+

Paste JSON for create, merge, or patch. Method is inferred automatically for the column endpoint.

+ + +
+ +
+

Update Lines

+

Paste JSON with lineId and text (or value) for line text patch.

+ + +
+
+
+
+
diff --git a/_scripts/message-bridge.js b/_scripts/message-bridge.js new file mode 100644 index 0000000..133f277 --- /dev/null +++ b/_scripts/message-bridge.js @@ -0,0 +1,131 @@ +const TRUSTED_ORIGINS = new Set([ + "https://app.t-pen.org", + "http://localhost:4000" +]) + +const CONTEXT_MESSAGE_TYPE = "TPEN_CONTEXT" +const REQUEST_CONTEXT_MESSAGE_TYPE = "REQUEST_TPEN_CONTEXT" +const ID_TOKEN_MESSAGE_TYPE = "TPEN_ID_TOKEN" +const REQUEST_ID_TOKEN_MESSAGE_TYPE = "REQUEST_ID_TOKEN" + +export const isTrustedOrigin = (origin) => TRUSTED_ORIGINS.has(origin) + +const getParentOrigin = () => { + if (!document.referrer) { + return "*" + } + + try { + return new URL(document.referrer).origin + } catch { + return "*" + } +} + +const postToParent = (message) => { + if (window.parent === window) { + return false + } + + window.parent.postMessage(message, getParentOrigin()) + return true +} + +export const requestTPENContext = () => postToParent({ type: REQUEST_CONTEXT_MESSAGE_TYPE }) + +export const requestTPENIdToken = () => postToParent({ type: REQUEST_ID_TOKEN_MESSAGE_TYPE }) + +const trimToHexId = (value) => { + if (!value || typeof value !== "string") { + return value ?? null + } + + const match = value.match(/[a-f0-9]{24}$/i) + return match ? match[0] : value +} + +const asPositiveNumberOrNull = (value) => { + const num = Number(value) + if (!Number.isFinite(num) || num <= 0) { + return null + } + + return num +} + +const validateTPENContext = (payload) => { + const errors = [] + if (!payload.projectId) errors.push("Missing projectId") + if (!payload.pageId) errors.push("Missing pageId") + if (!payload.canvasId) errors.push("Missing canvasId") + if (!payload.imageUrl) errors.push("Missing imageUrl") + + if (errors.length > 0) { + throw new Error(`Invalid TPEN_CONTEXT: ${ errors.join(", ") }`) + } + + const manifestUri = payload.manifestId ?? payload.manifestUri ?? payload.canvasManifestUri ?? payload.manifest ?? null + const manifestId = manifestUri + + return { + projectId: trimToHexId(payload.projectId), + pageId: trimToHexId(payload.pageId), + manifestId, + manifestUri, + canvasId: payload.canvasId, + canvasWidth: asPositiveNumberOrNull(payload.canvasWidth ?? payload.width), + canvasHeight: asPositiveNumberOrNull(payload.canvasHeight ?? payload.height), + imageUrl: payload.imageUrl, + currentLineId: payload.currentLineId || null, + columns: payload.columns || 1, + idToken: null + } +} + +export const subscribeToContext = (onContext) => { + const handler = (event) => { + if (!isTrustedOrigin(event.origin)) { + return + } + + const payload = event.data + const messageType = payload?.type + if (messageType !== CONTEXT_MESSAGE_TYPE) { + return + } + + try { + const context = validateTPENContext(payload) + onContext(context, event.origin) + } catch (error) { + console.error("TPEN_CONTEXT validation error:", error.message) + } + } + + window.addEventListener("message", handler) + return () => window.removeEventListener("message", handler) +} + +export const subscribeToIdToken = (onIdToken) => { + const handler = (event) => { + if (!isTrustedOrigin(event.origin)) { + return + } + + const payload = event.data + if (payload?.type !== ID_TOKEN_MESSAGE_TYPE) { + return + } + + const idToken = typeof payload.idToken === "string" ? payload.idToken.trim() : "" + if (!idToken) { + console.error("TPEN_ID_TOKEN validation error: Missing idToken") + return + } + + onIdToken(idToken, event.origin) + } + + window.addEventListener("message", handler) + return () => window.removeEventListener("message", handler) +} diff --git a/_scripts/prompt-builder.js b/_scripts/prompt-builder.js new file mode 100644 index 0000000..f5e6cd6 --- /dev/null +++ b/_scripts/prompt-builder.js @@ -0,0 +1,124 @@ +const DEFAULT_INSTRUCTIONS = [ + "You are assisting with TPEN manuscript transcription.", + "Use the instruction files below as the authoritative contract for task flow, geometry rules, API behavior, and fallback handling.", + "Do not restate or override those instruction files.", + "Do not invent transcription lines or bounds when required evidence is unavailable.", + "Only produce the save JSON payload when explicitly asked to provide save-ready output." +] + +const buildToolsBaseUrl = () => { + if (typeof window === "undefined" || !window.location) { + return "/tools" + } + + const { origin, pathname } = window.location + const splitToolsMarker = "/split-tools/" + const markerIndex = pathname.indexOf(splitToolsMarker) + + if (markerIndex >= 0) { + const basePrefix = pathname.slice(0, markerIndex) + return `${ origin }${ basePrefix }/tools` + } + + const trimmedPath = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname + const parentPath = trimmedPath.slice(0, Math.max(0, trimmedPath.lastIndexOf("/"))) + return `${ origin }${ parentPath }/tools` +} + +const buildInstructionFileUrls = () => { + const base = buildToolsBaseUrl() + return [ + `${ base }/COMMON_TASKS.md`, + `${ base }/IMAGE_ANALYSIS.md`, + `${ base }/HANDWRITING_TEXT_RECOGNITION.md`, + `${ base }/TPEN_API.md` + ] +} + +const chooseDefaultTask = (context = {}) => { + const hasLineArray = Array.isArray(context.lines) && context.lines.length > 0 + const hasCurrentLine = `${ context.currentLineId ?? "" }`.trim().length > 0 + const hasLineHints = hasLineArray || hasCurrentLine + + const columnCount = Number(context.columns) + const hasColumns = Array.isArray(context.columnData) + ? context.columnData.length > 0 + : Number.isFinite(columnCount) && columnCount > 0 + + if (hasLineHints) { + return "Text Recognition Within Known Bounds" + } + + if (hasColumns) { + return "Line Detection" + } + + return "Bounds Detection Followed by Text Recognition" +} + +export const buildTranscriptionPrompt = (context = {}) => { + const { + projectId, + pageId, + manifestId, + manifestUri, + canvasId, + canvasWidth, + canvasHeight, + idToken, + imageUrl, + lines + } = context + + const lineHints = Array.isArray(lines) && lines.length > 0 + ? lines.map((line, index) => { + const id = line?.id ?? `line-${index + 1}` + const boundary = line?.xywh ?? "position unknown" + return `- ${id}: ${boundary}` + }).join("\n") + : "- (No existing lines; detect new lines from the image.)" + + const defaultTask = chooseDefaultTask(context) + const instructionFileUrls = buildInstructionFileUrls() + + return [ + ...DEFAULT_INSTRUCTIONS, + "", + "Instruction Files (authoritative):", + ...instructionFileUrls.map((url) => `- ${ url }`), + "", + `Default task when not otherwise specified: COMMON_TASKS.md -> '${ defaultTask }'.`, + "", + "Context:", + `Project ID: ${projectId ?? "unknown"}`, + `Page ID: ${pageId ?? "unknown"}`, + `Manifest ID: ${manifestId ?? "unknown"}`, + `Manifest URI: ${manifestUri ?? "unknown"}`, + `Canvas ID: ${canvasId ?? "unknown"}`, + `Canvas Width: ${canvasWidth ?? "unknown"}`, + `Canvas Height: ${canvasHeight ?? "unknown"}`, + `ID Token: ${idToken ?? "unknown"}`, + "", + "Image URL:", + imageUrl ?? "unknown", + "", + "Existing or expected lines:", + lineHints, + "", + "When explicitly asked for save-ready output, return JSON in this tool-call format:", + "{", + ' "tool": "save_tpen_annotations",', + ' "arguments": {', + ' "candidates": [', + ' {', + ' "text": "example transcription",', + ' "bounds": { "x": 120, "y": 340, "w": 560, "h": 42 }', + ' }', + ' ]', + ' }', + "}", + "", + "If you are not explicitly asked for save-ready output yet, provide normal analysis/explanation instead of JSON.", + "If required resources are inaccessible, state what is missing and do not fabricate candidates." + ].join("\n") +} diff --git a/_scripts/tpen-api.js b/_scripts/tpen-api.js new file mode 100644 index 0000000..4f7396f --- /dev/null +++ b/_scripts/tpen-api.js @@ -0,0 +1,207 @@ +const DEFAULT_API_BASE = "https://api.t-pen.org" +const ANNO_CONTEXT = "http://www.w3.org/ns/anno.jsonld" +const SELECTOR_CONFORMS_TO = "http://www.w3.org/TR/media-frags/" + +const toError = async (response) => { + const text = await response.text() + return new Error(`TPEN API ${ response.status }: ${ text }`) +} + +const genId = () => `${ Date.now() }-${ Math.random().toString(36).slice(2) }` + +const asString = (value, fieldName) => { + const str = `${ value ?? "" }`.trim() + if (!str) { + throw new Error(`Missing required ${ fieldName }`) + } + + return str +} + +const normalizeCoord = (value, fieldName) => { + if (!Number.isFinite(value)) { + throw new Error(`Invalid numeric value for ${ fieldName }`) + } + + if (value < 0) { + throw new Error(`Value must be non-negative for ${ fieldName }`) + } + + return Math.round(value * 1000) / 1000 +} + +const buildSelectorValue = (bounds) => { + const { x, y, w, h, unit } = bounds + const prefix = unit === "pct" ? "pct:" : "" + return `xywh=${ prefix }${ x },${ y },${ w },${ h }` +} + +const normalizeLineCandidate = (candidate, index) => { + if (!candidate || typeof candidate !== "object") { + throw new Error(`Line ${ index + 1 }: candidate is not an object`) + } + + const text = `${ candidate.text ?? candidate.content ?? "" }`.trim() + if (!text) { + throw new Error(`Line ${ index + 1 }: missing text or content`) + } + + const bounds = candidate.bounds ?? candidate.bbox + if (!bounds || typeof bounds !== "object") { + throw new Error(`Line ${ index + 1 }: missing bounds object`) + } + + const x = normalizeCoord(bounds.x, "x") + const y = normalizeCoord(bounds.y, "y") + const w = normalizeCoord(bounds.w ?? bounds.width, "w") + const h = normalizeCoord(bounds.h ?? bounds.height, "h") + + if (w <= 0 || h <= 0) { + throw new Error(`Line ${ index + 1 }: width and height must be positive`) + } + + const rawUnit = `${ bounds.unit ?? "" }`.trim().toLowerCase() + const unit = rawUnit === "percent" || rawUnit === "pct" + ? "pct" + : null + + if (rawUnit && rawUnit !== "percent" && rawUnit !== "pct") { + throw new Error(`Line ${ index + 1 }: bounds.unit must be omitted for Canvas-dimension coordinates or set to ''pct'' for percentages`) + } + + if (unit === "pct" && (x > 100 || y > 100 || x + w > 100 || y + h > 100)) { + throw new Error(`Line ${ index + 1 }: percent bounds exceed canvas [0,100]`) + } + + return { + id: `${ candidate.id ?? genId() }`.trim(), + text, + bounds: { x, y, w, h, unit } + } +} + +const buildAnnotation = ({ candidate, canvasId, creatorAgent, index }) => { + const normalized = normalizeLineCandidate(candidate, index) + + return { + id: normalized.id, + type: "Annotation", + "@context": ANNO_CONTEXT, + body: [{ + type: "TextualBody", + value: normalized.text, + format: "text/plain" + }], + target: { + source: canvasId, + type: "SpecificResource", + selector: { + type: "FragmentSelector", + conformsTo: SELECTOR_CONFORMS_TO, + value: buildSelectorValue(normalized.bounds) + } + }, + creator: creatorAgent, + motivation: "transcribing" + } +} + +export const getPageContext = (context = {}) => { + const projectId = asString(context.projectId, "projectId") + const pageId = asString(context.pageId, "pageId") + const canvasId = asString(context.canvasId, "canvasId") + const manifestUri = context.manifestUri || null + const imageUrl = context.imageUrl || "" + const creatorAgent = context.creatorAgent || context.creator || "" + const idToken = context.idToken || null + const canvasWidth = Number.isFinite(context.canvasWidth) ? context.canvasWidth : null + const canvasHeight = Number.isFinite(context.canvasHeight) ? context.canvasHeight : null + + return { + projectId, + pageId, + canvasId, + manifestUri, + canvasWidth, + canvasHeight, + imageUrl, + creatorAgent, + idToken + } +} + +export const validateCandidates = (candidates = []) => { + if (!Array.isArray(candidates)) { + throw new Error("Candidates must be an array") + } + + if (candidates.length === 0) { + throw new Error("No candidates to validate") + } + + const ids = new Set() + const validated = candidates.map((candidate, index) => { + const norm = normalizeLineCandidate(candidate, index) + if (ids.has(norm.id)) { + throw new Error(`Duplicate line id at index ${ index }: ${ norm.id }`) + } + + ids.add(norm.id) + return norm + }) + + return validated +} + +export const buildPagePayload = ({ + candidates, + canvasId, + creatorAgent +}) => { + const validated = validateCandidates(candidates) + return { + items: validated.map((candidate, index) => + buildAnnotation({ candidate, canvasId, creatorAgent, index }) + ) + } +} + +export const savePageWithCandidates = async ({ + token, + projectId, + pageId, + canvasId, + candidates, + creatorAgent, + apiBase = DEFAULT_API_BASE +}) => { + const safeProjectId = asString(projectId, "projectId") + const safePageId = asString(pageId, "pageId") + + const payload = buildPagePayload({ + candidates, + canvasId, + creatorAgent + }) + + const headers = { + "Content-Type": "application/json" + } + + if (token) { + headers.Authorization = `Bearer ${ token }` + } + + const response = await fetch(`${ apiBase }/project/${ safeProjectId }/page/${ safePageId }`, { + method: "PUT", + headers, + body: JSON.stringify(payload), + credentials: "include" + }) + + if (!response.ok) { + throw await toError(response) + } + + return response.json() +} diff --git a/_scripts/transcription-assist.js b/_scripts/transcription-assist.js new file mode 100644 index 0000000..a010aea --- /dev/null +++ b/_scripts/transcription-assist.js @@ -0,0 +1,553 @@ +import { + requestTPENContext, + requestTPENIdToken, + subscribeToContext, + subscribeToIdToken +} from "/scripts/message-bridge.js" +import { buildTranscriptionPrompt } from "/scripts/prompt-builder.js" +import { getPageContext, validateCandidates, savePageWithCandidates } from "/scripts/tpen-api.js" + +const state = { + context: {}, + origin: null +} + +const API_BASE = "https://api.t-pen.org" + +const byId = (id) => document.getElementById(id) + +const statusEl = byId("status") +const promptOutputEl = byId("promptOutput") +const manualPageJsonEl = byId("manualPageJson") +const manualColumnsJsonEl = byId("manualColumnsJson") +const manualLinesJsonEl = byId("manualLinesJson") + +const setStatus = (message) => { + statusEl.textContent = message +} + +const setMeta = (id, value) => { + const el = byId(id) + if (el) { + el.textContent = value ?? "-" + } +} + +const maskToken = (token) => { + if (!token) { + return "Not requested" + } + + const prefix = token.slice(0, 6) + return `${ prefix }…` +} + +const refreshContextDisplay = () => { + const ctx = state.context + setMeta("metaOrigin", state.origin ?? "Unknown") + setMeta("metaProjectId", ctx.projectId) + setMeta("metaPageId", ctx.pageId) + setMeta("metaManifestId", ctx.manifestId) + setMeta("metaCanvasId", ctx.canvasId) + setMeta("metaImageUrl", ctx.imageUrl) + setMeta("metaCurrentLineId", ctx.currentLineId) + setMeta("metaIdToken", maskToken(ctx.idToken)) +} + +const ensureCanvasDimensionsInState = async () => { + const pageCtx = getPageContext(state.context) + const resolvedDimensions = await resolveCanvasDimensions(pageCtx) + + state.context = { + ...state.context, + ...resolvedDimensions + } + + return resolvedDimensions +} + +const generatePrompt = async () => { + try { + setStatus("Resolving canvas dimensions for prompt...") + await ensureCanvasDimensionsInState() + refreshContextDisplay() + promptOutputEl.value = buildTranscriptionPrompt(state.context) + setStatus("Prompt generated. Review and copy into your LLM.") + } catch (error) { + setStatus(`Error: ${ error.message }`) + } +} + +const copyPrompt = async () => { + if (!promptOutputEl.value.trim()) { + setStatus("Generate a prompt first.") + return + } + + await navigator.clipboard.writeText(promptOutputEl.value) + setStatus("Prompt copied to clipboard.") +} + +const requestContext = () => { + const requested = requestTPENContext() + if (!requested) { + setStatus("No parent frame detected. Open this tool from TPEN.") + return + } + + setStatus("Context request sent. Waiting for parent response...") +} + +const requestIdToken = () => { + const requested = requestTPENIdToken() + if (!requested) { + setStatus("No parent frame detected. Open this tool from TPEN.") + return + } + + setStatus("ID token requested. Waiting for parent response...") +} + +const parseObjectIfString = (value) => { + if (!value) { + return null + } + + if (typeof value === "string") { + try { + return JSON.parse(value) + } catch { + return null + } + } + + return value +} + +const extractCandidates = (parsed) => { + const argumentsObject = parseObjectIfString(parsed?.arguments) + const toolCallArguments = parseObjectIfString(parsed?.toolCall?.arguments) + + return ( + parsed?.candidates + ?? parsed?.lines + ?? argumentsObject?.candidates + ?? toolCallArguments?.candidates + ?? [] + ) +} + +const canonicalizeUri = (value) => { + if (!value || typeof value !== "string") { + return "" + } + + try { + const parsed = new URL(value) + const base = `${ parsed.origin }${ parsed.pathname }` + return base.replace(/\/+$/, "") + } catch { + return value.replace(/[?#].*$/, "").replace(/\/+$/, "") + } +} + +const asPositiveNumberOrNull = (value) => { + const num = Number(value) + if (!Number.isFinite(num) || num <= 0) { + return null + } + + return num +} + +const parseCanvasDimensions = (obj) => { + if (!obj || typeof obj !== "object") { + return { canvasWidth: null, canvasHeight: null } + } + + return { + canvasWidth: asPositiveNumberOrNull(obj.width), + canvasHeight: asPositiveNumberOrNull(obj.height) + } +} + +const fetchJson = async (uri) => { + const response = await fetch(uri, { + method: "GET", + headers: { Accept: "application/json" }, + }) + + if (!response.ok) { + throw new Error(`Failed to load ${ uri } (${ response.status })`) + } + + return response.json() +} + +const findCanvasInManifest = (manifest, canvasId) => { + if (!manifest || typeof manifest !== "object") { + return null + } + + const queue = [manifest.items] + const normalizedCanvasId = canonicalizeUri(canvasId) + + while (queue.length > 0) { + const current = queue.shift() + if (!current) { + continue + } + + if (Array.isArray(current)) { + for (const entry of current) { + queue.push(entry) + } + continue + } + + if (typeof current !== "object") { + continue + } + + const currentId = canonicalizeUri(current.id) + if (currentId && currentId === normalizedCanvasId) { + return current + } + + if (Array.isArray(current.items)) { + queue.push(current.items) + } + } + + return null +} + +const resolveCanvasDimensions = async ({ canvasId, manifestUri, canvasWidth, canvasHeight }) => { + if (Number.isFinite(canvasWidth) && Number.isFinite(canvasHeight)) { + return { canvasWidth, canvasHeight } + } + + let loadedCanvas = null + try { + loadedCanvas = await fetchJson(canvasId) + } catch { + loadedCanvas = null + } + + const directDimensions = parseCanvasDimensions(loadedCanvas) + if (Number.isFinite(directDimensions.canvasWidth) && Number.isFinite(directDimensions.canvasHeight)) { + return directDimensions + } + + if (!manifestUri) { + throw new Error("Missing canvas dimensions and manifestUri fallback in context") + } + + const manifest = await fetchJson(manifestUri) + const matchingCanvas = findCanvasInManifest(manifest, canvasId) + const fallbackDimensions = parseCanvasDimensions(matchingCanvas) + + if (!Number.isFinite(fallbackDimensions.canvasWidth) || !Number.isFinite(fallbackDimensions.canvasHeight)) { + throw new Error("Could not resolve canvas width/height from Canvas URI or Manifest items") + } + + return fallbackDimensions +} + +const normalizeBoundsToCanvasIntegerCoordinates = (candidates, { canvasWidth, canvasHeight }) => { + const canConvertPercent = Number.isFinite(canvasWidth) && Number.isFinite(canvasHeight) + + return candidates.map((candidate) => { + const bounds = candidate?.bounds ?? candidate?.bbox + if (!bounds) { + return candidate + } + + const rawUnit = `${ bounds.unit ?? "" }`.trim().toLowerCase() + const unit = rawUnit === "percent" || rawUnit === "pct" + ? "pct" + : null + + const rawX = Number(bounds.x) + const rawY = Number(bounds.y) + const rawW = Number(bounds.w ?? bounds.width) + const rawH = Number(bounds.h ?? bounds.height) + + if (unit === "pct") { + if (!canConvertPercent) { + throw new Error("Cannot convert percent bounds without canvas width/height") + } + + const x = Math.round((rawX / 100) * canvasWidth) + const y = Math.round((rawY / 100) * canvasHeight) + const w = Math.round((rawW / 100) * canvasWidth) + const h = Math.round((rawH / 100) * canvasHeight) + + return { + ...candidate, + bounds: { + ...bounds, + x, + y, + w, + h, + unit: null + } + } + } + + const x = Math.round(rawX) + const y = Math.round(rawY) + const w = Math.round(rawW) + const h = Math.round(rawH) + + return { + ...candidate, + bounds: { + ...bounds, + x, + y, + w, + h, + unit: null + } + } + }) +} + +const parseLLMOutput = (jsonString) => { + try { + const parsed = JSON.parse(jsonString) + const candidates = extractCandidates(parsed) + return Array.isArray(candidates) ? candidates : [] + } catch (e) { + throw new Error("Invalid JSON output from LLM") + } +} + +const parseJsonTextarea = (element, operationName) => { + const raw = element?.value?.trim() ?? "" + if (!raw) { + throw new Error(`${ operationName }: paste a JSON payload first`) + } + + try { + return JSON.parse(raw) + } catch { + throw new Error(`${ operationName }: payload must be valid JSON`) + } +} + +const fetchTpenWithJson = async ({ token, url, method, payload }) => { + const headers = { + "Content-Type": "application/json" + } + + if (token) { + headers.Authorization = `Bearer ${ token }` + } + + const response = await fetch(url, { + method, + headers, + body: JSON.stringify(payload), + credentials: "include" + }) + + if (!response.ok) { + throw new Error(`TPEN API ${ response.status }: ${ await response.text() }`) + } + + const text = await response.text() + if (!text.trim()) { + return null + } + + try { + return JSON.parse(text) + } catch { + return text + } +} + +const inferColumnMethod = (payload) => { + if (payload && typeof payload === "object") { + if (Array.isArray(payload.columnLabelsToMerge) && payload.newLabel) { + return "PUT" + } + + if (Array.isArray(payload.annotationIdsToAdd) && payload.columnLabel) { + return "PATCH" + } + } + + return "POST" +} + +const submitManualPageUpdate = async () => { + if (!state.context.idToken) { + setStatus("Request ID token before submitting manual updates.") + return + } + + try { + const pageCtx = getPageContext(state.context) + const payload = parseJsonTextarea(manualPageJsonEl, "Update Page") + const url = `${ API_BASE }/project/${ pageCtx.projectId }/page/${ pageCtx.pageId }` + + setStatus("Submitting Update Page...") + await fetchTpenWithJson({ + token: pageCtx.idToken, + url, + method: "PUT", + payload + }) + setStatus("Update Page submitted successfully.") + } catch (error) { + setStatus(`Error: ${ error.message }`) + } +} + +const submitManualColumnsUpdate = async () => { + if (!state.context.idToken) { + setStatus("Request ID token before submitting manual updates.") + return + } + + try { + const pageCtx = getPageContext(state.context) + const payload = parseJsonTextarea(manualColumnsJsonEl, "Update Columns") + const method = inferColumnMethod(payload) + const url = `${ API_BASE }/project/${ pageCtx.projectId }/page/${ pageCtx.pageId }/column` + + setStatus(`Submitting Update Columns (${ method })...`) + await fetchTpenWithJson({ + token: pageCtx.idToken, + url, + method, + payload + }) + setStatus(`Update Columns submitted successfully via ${ method }.`) + } catch (error) { + setStatus(`Error: ${ error.message }`) + } +} + +const submitManualLinesUpdate = async () => { + if (!state.context.idToken) { + setStatus("Request ID token before submitting manual updates.") + return + } + + try { + const pageCtx = getPageContext(state.context) + const payload = parseJsonTextarea(manualLinesJsonEl, "Update Lines") + const lineId = `${ payload.lineId ?? payload.id ?? "" }`.trim() + const textValue = `${ payload.text ?? payload.value ?? "" }` + + if (!lineId) { + throw new Error("Update Lines: payload must include lineId") + } + + const headers = { + "Content-Type": "text/plain", + Authorization: `Bearer ${ pageCtx.idToken }` + } + + const url = `${ API_BASE }/project/${ pageCtx.projectId }/page/${ pageCtx.pageId }/line/${ lineId }/text` + setStatus("Submitting Update Lines (PATCH)...") + + const response = await fetch(url, { + method: "PATCH", + headers, + body: textValue, + credentials: "include" + }) + + if (!response.ok) { + throw new Error(`TPEN API ${ response.status }: ${ await response.text() }`) + } + + setStatus("Update Lines submitted successfully.") + } catch (error) { + setStatus(`Error: ${ error.message }`) + } +} + +const saveLLMCandidates = async () => { + const promptText = promptOutputEl.value + if (!promptText.trim()) { + setStatus("Generate a prompt and run it in your LLM first.") + return + } + + if (!state.context.idToken) { + setStatus("Request ID token before saving annotations.") + return + } + + try { + const pageCtx = getPageContext(state.context) + setStatus("Resolving canvas dimensions...") + const resolvedDimensions = await ensureCanvasDimensionsInState() + setStatus("Validating candidates...") + + const userOutput = prompt("Paste the LLM JSON output here:") + if (!userOutput) { + setStatus("Cancelled.") + return + } + + const candidates = parseLLMOutput(userOutput) + const normalizedBounds = normalizeBoundsToCanvasIntegerCoordinates(candidates, resolvedDimensions) + const validated = validateCandidates(normalizedBounds) + + setStatus("Saving to TPEN...") + await savePageWithCandidates({ + token: pageCtx.idToken, + projectId: pageCtx.projectId, + pageId: pageCtx.pageId, + canvasId: pageCtx.canvasId, + candidates: validated, + creatorAgent: pageCtx.creatorAgent + }) + + setStatus(`Saved ${ validated.length } line(s) to TPEN. Page updated successfully.`) + promptOutputEl.value = "" + } catch (error) { + setStatus(`Error: ${ error.message }`) + } +} + +byId("generatePromptBtn")?.addEventListener("click", generatePrompt) +byId("copyPromptBtn")?.addEventListener("click", copyPrompt) +byId("saveAnnotationsBtn")?.addEventListener("click", saveLLMCandidates) +byId("requestContextBtn")?.addEventListener("click", requestContext) +byId("requestIdTokenBtn")?.addEventListener("click", requestIdToken) +byId("submitManualPageBtn")?.addEventListener("click", submitManualPageUpdate) +byId("submitManualColumnsBtn")?.addEventListener("click", submitManualColumnsUpdate) +byId("submitManualLinesBtn")?.addEventListener("click", submitManualLinesUpdate) + +subscribeToContext((context, origin) => { + state.context = { + ...context, + idToken: null + } + state.origin = origin + promptOutputEl.value = "" + refreshContextDisplay() + setStatus("Context received. Click Generate Prompt when ready.") +}) + +subscribeToIdToken((idToken, origin) => { + state.context = { + ...state.context, + idToken + } + state.origin = origin + refreshContextDisplay() + setStatus("ID token received and stored for API use.") +}) + +refreshContextDisplay() +setStatus("Waiting for context from parent frame...") diff --git a/_tools/COMMON_TASKS.md b/_tools/COMMON_TASKS.md new file mode 100644 index 0000000..10fac80 --- /dev/null +++ b/_tools/COMMON_TASKS.md @@ -0,0 +1,215 @@ +# Common Tasks Entry Point + +## Purpose + +This is the primary task guide for models assisting TPEN transcription workflows. + +Use this file as the top-level execution contract. It defines: + +- mandatory preflight gatekeepers +- generic workflow boilerplate +- task-specific execution paths +- required save-and-report completion behavior + +Supporting references: + +- [TPEN API Guide](TPEN_API.md) +- [Image Analysis Purpose](IMAGE_ANALYSIS.md) +- [Handwriting Text Recognition Purpose](HANDWRITING_TEXT_RECOGNITION.md) + +## Fast-Fail Gatekeepers + +Run these checks first. If any check fails, stop and return a concise failure report to the user. + +1. Critical context exists. + +- Required: idToken, projectId, pageId, canvasId, manifestUri +- Required geometry source: canvasWidth/canvasHeight in context, or retrievable from canvasId, or retrievable from manifestUri items by matching canvasId +- If an Image Resource is needed for the task, imageUrl must be present and reachable + +1. Tooling capability exists. + +- Model can fetch internet resources (Canvas URI, Manifest URI, TPEN API endpoints) +- Model can perform HTTP write operations: PUT, POST, and PATCH +- If PATCH is unavailable but POST with method override is supported, use fallback +- If model can GET resources but cannot perform write operations, switch to browser-submit fallback mode: + - Return a brief instruction plus a serialized JSON payload for one target operation + - Tell the user to open the Manual TPEN Update section and choose `Update Page`, `Update Columns`, or `Update Lines` + - User submits that payload from the browser, which performs the fetch to TPEN + +1. Authorization readiness. + +- idToken is present and usable for endpoints that require auth +- If auth is missing or rejected, stop and return explicit auth failure + +1. Upstream integrity. + +- Canvas URI and Manifest URI are reachable +- Canvas width/height can be resolved through the documented fallback chain +- If unresolved, stop and report geometry resolution failure + +## Shared Boilerplate + +Apply this boilerplate in all successful workflows. + +1. Load and validate context. + +- Read context values +- Normalize identifiers and URIs +- Resolve Canvas dimensions in this order: + - use canvasWidth/canvasHeight from context + - else load canvasId and read width/height + - else load manifestUri and find matching canvas in items by id and read width/height + +1. Perform requested analysis and recognition task. + +- Follow the selected workflow below +- Keep outputs structured and deterministic + +1. Normalize geometry before save. + +- Bounds must be saved as integer coordinates derived from Canvas dimensions +- If intermediate bounds are percent, convert them to Canvas-dimension-based integer coordinates before validation + +1. Persist to TPEN. + +- Use TPEN API patterns from TPEN_API.md +- For any column verification or status check, GET the Project object, locate the Page in `project.layers`, and inspect `page.columns` +- If Project reading fails, continue with line save operations when the task allows it, and omit column association +- Save Page, Lines, or Columns according to task + +1. Report outcome to user. + +- On success: what was saved, where, and count summary +- On failure: exact stage and error details, with next corrective action +- In browser-submit fallback mode, report which operation the user must select and the payload they should paste + +## Task Workflows + +### 1. Text Recognition Within Known Bounds + +Use when line regions already exist and only text must be produced or revised. + +Steps: + +1. Input known bounds and image region references +1. Run handwriting text recognition over provided line regions +1. Produce line text candidates +1. Save text updates by line using PATCH line text endpoint when possible +1. Return success or failure with affected line IDs + +Primary references: + +- HANDWRITING_TEXT_RECOGNITION.md +- TPEN_API.md + +### 2. Column Detection + +Use when column regions are missing and need to be inferred. + +Steps: + +1. Retrieve the Project object and inspect `page.columns` to determine whether columns already exist +1. Analyze page layout and detect column regions in reading order +1. Build line annotation list for each detected column (in reading order) +1. Convert all detected bounds to integer coordinates based on Canvas dimensions +1. For each column, POST to `${TPEN.servicesURL}/project/${projectId}/page/${pageId}/column` with `{ label, annotations }` +1. Return success with created column count, or failure with HTTP status and cause + +Expected column POST payload format: + +```javascript +{ + label: "Column A", // e.g., "Column 1", "Left Column", or auto-generated identifier + annotations: [ // Array of annotation IDs (line refs) that belong to this column + "line-id-1", + "line-id-2" + ] +} +``` + +See [TPEN_API.md](TPEN_API.md) for column POST endpoint details, validation rules, and error responses. + +Verification rule: + +- Current column state is read from the Project object, not from a separate column GET route +- Locate the page inside `project.layers[*].pages[*]`, then inspect `page.columns` +- If that Project read fails, report that column verification was unavailable and do not block line-only save behavior + +Primary references: + +- IMAGE_ANALYSIS.md +- TPEN_API.md (column POST operation) + +### 3. Line Detection + +Use when lines are missing but text recognition is not requested. + +Steps: + +1. Detect line regions in reading order +1. Convert bounds to integer coordinates based on Canvas dimensions +1. Build valid annotation candidates with placeholder or empty text policy as configured +1. Save to Page via PUT +1. Return success or failure with line count summary + +Primary references: + +- IMAGE_ANALYSIS.md +- TPEN_API.md + +### 4. Column and Line Detection + +Use when both structural layers are missing. + +Steps: + +1. Retrieve the Project object and inspect `page.columns` to determine whether columns already exist or need merge/update handling +1. Detect columns first in reading order +1. Detect lines within each column, preserving reading order +1. Build line annotations with proper bounds in integer coordinates based on Canvas dimensions +1. Resolve Canvas dimensions +1. POST each column via column API with `{ label, annotations }` where annotations are line IDs +1. Collect all line annotations and PUT them to the page via [TPEN_API.md](TPEN_API.md) PUT endpoint +1. Return success or failure with column count, line count, and HTTP status + +Execution order: + +- Create columns first (POST operations) +- Then create/update all lines on the page (PUT operation) +- Ensure line annotations match column annotation assignments +- If Project reading fails before column verification, skip column association and still save lines through Page PUT + +Primary references: + +- IMAGE_ANALYSIS.md +- TPEN_API.md (column POST and page PUT operations) + +### 5. Bounds Detection Followed by Text Recognition + +Use for end-to-end page transcription from image. + +Steps: + +1. Detect line (or column plus line) bounds +1. Perform handwriting text recognition over detected regions +1. Resolve uncertainty with conservative defaults +1. Convert all bounds to integer coordinates based on Canvas dimensions +1. Build valid annotation candidates with recognized text +1. Save Page via PUT and optionally patch specific line text updates where needed +1. Return end-to-end success or failure with counts and notable ambiguities + +Primary references: + +- IMAGE_ANALYSIS.md +- HANDWRITING_TEXT_RECOGNITION.md +- TPEN_API.md + +## Completion Requirement + +Every successful run must end with a TPEN save action and a user-visible result report. + +Allowed completion outputs: + +- Success: includes operation type, target object, and saved count +- Failure: includes failing stage, HTTP status or validation cause, and recommended next step diff --git a/_tools/HANDWRITING_TEXT_RECOGNITION.md b/_tools/HANDWRITING_TEXT_RECOGNITION.md new file mode 100644 index 0000000..f1ac0ca --- /dev/null +++ b/_tools/HANDWRITING_TEXT_RECOGNITION.md @@ -0,0 +1,54 @@ +# Handwriting Text Recognition Purpose + +## Scope + +Handwriting Text Recognition (HTR) converts line-level manuscript image regions into textual transcription candidates. + +## Default Objective + +Interpret likely historical scripts and produce faithful line transcriptions suitable for TPEN annotation workflows. + +## Context Inputs + +HTR should consider any user-provided context when available: + +- language +- date or period +- script family +- place or collection conventions +- abbreviation/expansion policy + +If context is missing, apply conservative defaults and preserve uncertainty explicitly. + +## Default Recognition Rules + +1. Prioritize diplomatic transcription over normalization. +2. Preserve orthography and punctuation as observed. +3. Use explicit uncertainty markers for unclear glyphs (for example `[a?]`). +4. Do not invent expansions unless asked. +5. If expansion is requested, keep a traceable form (for example explicit markers or paired diplomatic/expanded output). + +## Handling Ambiguity + +When confidence is low: + +- return best guess with uncertainty notation +- avoid forced certainty +- keep line segmentation stable even if text is partially uncertain + +## Expected Output Shape + +Per line candidate: + +- `text`: transcription candidate +- `bounds`: aligned line geometry from analysis stage + +This stage should not call TPEN APIs directly unless explicitly orchestrated by an external tool runner. The preferred role is producing structured candidates for validated save logic. + +## Out of Scope + +- Final editorial interpretation +- Translation +- Historical argumentation + +Those belong to downstream scholarly workflows. diff --git a/_tools/IMAGE_ANALYSIS.md b/_tools/IMAGE_ANALYSIS.md new file mode 100644 index 0000000..0149f0f --- /dev/null +++ b/_tools/IMAGE_ANALYSIS.md @@ -0,0 +1,52 @@ +# Image Analysis Purpose + +## Scope + +Image Analysis identifies transcription-relevant regions in manuscript images and proposes candidate line regions for downstream text recognition. + +## Default Objective + +Given a manuscript image, find meaning-bearing regions and represent them as structured layout candidates: + +- columns (major reading blocks) +- lines (ordered transcription units inside each column) + +The goal is not artistic segmentation; it is reliable reading-order segmentation for transcription workflows. + +## Default Assumptions + +- Material may be historical and visually degraded. +- Common images are page-level color photographs or microfilm scans, but may include folio spreads or cropped details. +- Layout can include multiple columns, marginalia, headers, or irregular spacing. +- Text orientation is usually horizontal but may vary by manuscript tradition. + +## Expected Output Shape + +Image Analysis should output machine-usable geometric candidates and confidence notes, for example: + +- `columns`: list of column regions in reading order +- `lines`: list of line regions in reading order +- bounds can be proposed as percent for model portability + +Final persistence rule: bounds must be saved as integer coordinates computed from Canvas dimensions. + +Canvas dimension resolution should follow this order: + +1. Use `canvasWidth` and `canvasHeight` from context when present. +2. Otherwise load the Canvas object via `canvasId` and read `width`/`height`. +3. If Canvas URI fails or lacks dimensions, load `manifestUri` and find the matching Canvas in `items` by id. + +## Quality Priorities + +1. Preserve reading order. +2. Prefer high recall for likely text lines over aggressive pruning. +3. Keep region boundaries tight enough for line-level recognition. +4. Flag ambiguous regions rather than silently dropping them. + +## Out of Scope + +- Definitive textual interpretation +- Editorial normalization +- Historical commentary + +Those belong to recognition and editorial stages, not layout detection. diff --git a/_tools/TPEN_API.md b/_tools/TPEN_API.md new file mode 100644 index 0000000..4eefb77 --- /dev/null +++ b/_tools/TPEN_API.md @@ -0,0 +1,401 @@ +# TPEN API Usage Guide + +This guide provides generic request patterns for TPEN calls that require: + +- `idToken` for bearer auth +- `projectId` for project scope +- `pageId` for page scope + +Use these patterns when your workflow needs to fetch context, save detected lines, or update line text. + +## Common Parameters + +- `TPEN.servicesURL`: base API URL (default: `https://api.t-pen.org`) +- `idToken`: bearer token from a separate secure token request +- `projectId`: TPEN project identifier +- `pageId`: TPEN page identifier +- `layerId`: TPEN layer identifier +- `lineId`: TPEN line identifier +- `canvasId`: Canvas URI in context +- `manifestUri`: Manifest URI in context (fallback source for canvas dimensions) + +## Context Requirements for Geometry + +Before saving annotations, bounds must be integer coordinates derived from the resolved Canvas width/height. + +Required context inputs: + +- `canvasId` should be present in context. +- `manifestUri` should be present in context. + +Resolution order for width/height: + +1. Use `canvasWidth`/`canvasHeight` in context when present. +2. Otherwise fetch `canvasId` and read `width`/`height`. +3. If Canvas URI fails or lacks `width`/`height`, fetch `manifestUri`, find the matching Canvas in `items` by `id`, and read `width`/`height` there. +4. If dimensions still cannot be resolved, fail the save operation and report an explicit error. + +## Standard Headers + +```javascript +const headers = { + Authorization: `Bearer ${idToken}` +} +``` + +Add content type per endpoint: + +- JSON payloads: `"Content-Type": "application/json"` +- Plain-text patch payload: `"Content-Type": "text/plain"` + +Credential policy: + +- Use bearer auth for project-scoped routes that require it. +- Only project GET and any PUT, POST requires auth by default in this workflow. +- For Canvas/Manifest fetches and other open routes, do not use a credentials header. +- Avoid `credentials: "include"` on cross-origin endpoints that return wildcard CORS headers. + +## Read-Only Model Fallback (Browser Submit) + +If a model can GET resources but cannot perform PUT/POST/PATCH calls itself, the model should still produce a save-ready JSON payload and a short instruction. + +Fallback contract: + +1. Model returns a brief instruction and one serialized JSON payload. +2. User opens the split-tool "Manual TPEN Update" section. +3. User selects one operation and pastes payload: + - `Update Page` -> PUT `/project/:projectId/page/:pageId` + - `Update Columns` -> `/project/:projectId/page/:pageId/column` (POST/PUT/PATCH inferred from payload shape) + - `Update Lines` -> PATCH `/project/:projectId/page/:pageId/line/:lineId/text` +4. Browser fires authenticated fetch to TPEN using the user's token. + +## GET: Project and Open Resources + +Use authenticated GET for project metadata and open GET for Canvas/Manifest resources. + +```javascript +const authHeaders = { + Authorization: `Bearer ${idToken}` +} + +const projectResponse = await fetch( + `${TPEN.servicesURL}/project/${projectId}`, + { method: "GET", headers: authHeaders, credentials: "include" } +) + +const pageResponse = await fetch( + `${TPEN.servicesURL}/project/${projectId}/page/${pageId}`, + { method: "GET", headers: authHeaders, credentials: "include" } +) + +const canvasResponse = await fetch( + canvasId, + { method: "GET", headers: { Accept: "application/json" } } +) + +const layerResponse = await fetch( + `${TPEN.servicesURL}/project/${projectId}/layer/${layerId}`, + { method: "GET", headers: authHeaders } +) + +const manifestResponse = await fetch( + manifestUri, + { method: "GET", headers: { Accept: "application/json" } } +) +``` + +Notes: + +- Check `response.ok` before parsing. +- Use `credentials: "include"` to preserve browser-session compatibility. + +### Verify Current Columns From The Project Object + +There is no separate documented GET column endpoint in this workflow. + +To inspect current columns on a page, retrieve the Project object, find the matching Page inside `project.layers[].pages`, then read `page.columns`. + +```javascript +const projectResponse = await fetch( + `${TPEN.servicesURL}/project/${projectId}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${idToken}` + }, + credentials: "include" + } +) + +if (!projectResponse.ok) { + throw new Error(`TPEN API ${projectResponse.status}: ${await projectResponse.text()}`) +} + +const project = await projectResponse.json() +const page = project.layers + ?.flatMap(layer => layer.pages ?? []) + .find(candidate => candidate.id?.split("/").pop() === pageId) + +if (!page) { + throw new Error(`Page ${pageId} not found in project ${projectId}`) +} + +const currentColumns = page.columns ?? [] +``` + +Notes: + +- Use the Project object when you need to verify whether columns already exist before creating or merging them. +- Column labels are page-scoped and must be unique on that page. +- `page.columns` is the authoritative source for current column membership and labels in this workflow. +- If the Project read fails, treat column verification as unavailable rather than blocking all persistence work. +- When a workflow can still save lines through Page PUT, it is acceptable to save lines without associating them to columns. + +## PUT: Save Detected Lines to a Page + +Use PUT to save a full updated page envelope with annotation items. + +Important geometry rule: + +- Save bounds as integer coordinates derived from Canvas dimensions and serialize them in `xywh=x,y,w,h` format. +- If model output is percent, convert using resolved Canvas width/height before validation and PUT. + +Endpoint: + +- `${TPEN.servicesURL}/project/${projectId}/page/${pageId}` + +Request format: + +```javascript +const payload = { + items: [ + { + type: "Annotation", + "@context": "http://www.w3.org/ns/anno.jsonld", + body: [ + { + type: "TextualBody", + value: "transcribed line text", + format: "text/plain" + } + ], + target: { + source: canvasId, + type: "SpecificResource", + selector: { + type: "FragmentSelector", + conformsTo: "http://www.w3.org/TR/media-frags/", + value: "xywh=120,340,560,42" + } + }, + motivation: "transcribing" + } + ] +} + +const putResponse = await fetch( + `${TPEN.servicesURL}/project/${projectId}/page/${pageId}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${idToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload), + credentials: "include" + } +) +``` + +## PATCH: Update Existing Line Text + +Use PATCH when you need to update only a single line text. + +Endpoint: + +- `${TPEN.servicesURL}/project/${projectId}/page/${pageId}/line/${lineId}/text` + +Request format (plain text body): + +```javascript +const textValue = "updated transcription text" + +const patchResponse = await fetch( + `${TPEN.servicesURL}/project/${projectId}/page/${pageId}/line/${lineId}/text`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${idToken}`, + "Content-Type": "text/plain" + }, + body: textValue, + credentials: "include" + } +) +``` + +## POST/PUT/PATCH: Column Operations + +Use these endpoints to manage columns on a page. Columns group line annotations into logical structural units (e.g., physical columns in a multi-column page). + +Column association is best-effort when it depends on reading current page state from the Project object. If Project lookup fails, do not treat that failure as blocking for line-only persistence. + +### POST: Create a New Column + +Endpoint: + +- `${TPEN.servicesURL}/project/${projectId}/page/${pageId}/column` + +Request format: + +```javascript +const payload = { + label: "Column A", // Human-readable label for the column + annotations: [ // Array of annotation IDs (lines) belonging to this column + "annotation-id-1", + "annotation-id-2", + "annotation-id-3" + ] +} + +const postResponse = await fetch( + `${TPEN.servicesURL}/project/${projectId}/page/${pageId}/column`, + { + method: "POST", + headers: { + Authorization: `Bearer ${idToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload), + credentials: "include" + } +) + +``` + +Response (201 Created): + +```javascript +{ + _id: "column-record-id", + label: "Column A", + lines: ["annotation-id-1", "annotation-id-2", "annotation-id-3"] +} +``` + +Validation rules: + +- `label` must be a non-empty string and unique on the page (no duplicate labels) +- `annotations` must be a non-empty array of existing annotation IDs on the page +- Annotations can be reassigned between columns (automatically removed from previous column assignments if fully transferred) + +### PUT: Merge Multiple Columns + +Endpoint: + +- `${TPEN.servicesURL}/project/${projectId}/page/${pageId}/column` + +Request format: + +```javascript +const payload = { + newLabel: "Merged Column", // Label for the new merged column + columnLabelsToMerge: ["Column A", "Column B"] // Labels of columns to merge +} + +const putResponse = await fetch( + `${TPEN.servicesURL}/project/${projectId}/page/${pageId}/column`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${idToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload), + credentials: "include" + } +) +``` + +Response (200 OK): + +```javascript +{ + _id: "merged-column-record-id", + label: "Merged Column", + lines: ["annotation-id-1", "annotation-id-2", "annotation-id-3", "annotation-id-4"] +} +``` + +Validation rules: + +- `newLabel` must be a non-empty string and unique on the page +- `columnLabelsToMerge` must contain at least 2 labels +- All specified columns must exist on the page +- Annotations from the merged columns cannot conflict with other columns + +### PATCH: Add Annotations to Existing Column + +Endpoint: + +- `${TPEN.servicesURL}/project/${projectId}/page/${pageId}/column` + +Request format: + +```javascript +const payload = { + columnLabel: "Column A", // Label of the column to update + annotationIdsToAdd: [ // Annotation IDs to add to this column + "annotation-id-4", + "annotation-id-5" + ] +} + +const patchResponse = await fetch( + `${TPEN.servicesURL}/project/${projectId}/page/${pageId}/column`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${idToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload), + credentials: "include" + } +) +``` + +Response (200 OK): + +```javascript +{ + message: "Column updated successfully." +} +``` + +Validation rules: + +- `columnLabel` must match an existing column on the page +- `annotationIdsToAdd` must be a non-empty array of existing annotation IDs on the page +- Annotations cannot already be assigned to other columns (prevents duplicate assignments across columns) + +## Error Handling Pattern + +```javascript +const toError = async (response) => { + const text = await response.text() + throw new Error(`TPEN API ${response.status}: ${text}`) +} + +if (!response.ok) { + await toError(response) +} +``` + +## Workflow Summary + +1. Resolve `idToken` securely and keep it out of logs. +2. Resolve `projectId` and `pageId` from current context. +3. Optionally GET project/page/layer objects for canonical state. +4. Build valid payloads and submit via PUT (annotations), PATCH (line text), or POST/PUT/PATCH (column operations). +5. Check status, parse response, and report success/failure.