From 3dfde3d29d26ec2ead5b4b80d689cec12f233067 Mon Sep 17 00:00:00 2001
From: vharkins <105030530+vharkins1@users.noreply.github.com>
Date: Tue, 19 May 2026 22:17:25 -0700
Subject: [PATCH 1/5] feat: friendlier template-creation UI
- Replace raw Fields JSON textarea with a row-based field builder
(per-row description + friendly type dropdown, add/delete, drag-reorder)
- Make commonforms opt-in via an explicit "Make this PDF fillable" button
instead of always running it on upload
- Show a live field-count badge that flags mismatches between PDF widgets
and the rows being built
- Append field type as a hint to the LLM prompt
- Untrack sample PDFs in src/inputs (now covered by .gitignore)
- Add CLAUDE.md
Co-Authored-By: Claude Opus 4.7
---
CLAUDE.md | 64 +++++
api/routes/templates.py | 57 +++-
api/schemas/templates.py | 11 +
frontend/app.js | 419 ++++++++++++++++++++++++++--
frontend/index.html | 39 ++-
frontend/styles.css | 187 ++++++++++++-
src/controller.py | 4 +-
src/file_manipulator.py | 9 +-
src/inputs/file.pdf | Bin 65350 -> 0 bytes
src/inputs/file_filled.pdf | Bin 71608 -> 0 bytes
src/inputs/file_template.pdf | Bin 74414 -> 0 bytes
src/inputs/file_template_manual.pdf | Bin 65350 -> 0 bytes
src/llm.py | 9 +-
src/prompt.txt | 1 +
14 files changed, 749 insertions(+), 51 deletions(-)
create mode 100644 CLAUDE.md
delete mode 100644 src/inputs/file.pdf
delete mode 100644 src/inputs/file_filled.pdf
delete mode 100644 src/inputs/file_template.pdf
delete mode 100644 src/inputs/file_template_manual.pdf
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..8b8a02c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,64 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project
+
+FireForm is a "report once, file everywhere" tool for first responders: a single text/voice input is sent to a local LLM (Mistral via Ollama), extracted into JSON, and used to fill multiple agency PDF forms. Everything runs locally — no cloud dependencies, no PII leaves the machine. Recognized as a UN Digital Public Good.
+
+## Architecture
+
+Three-tier system glued together by Docker Compose:
+
+1. **`api/`** — FastAPI service (port 8000). Two routers:
+ - `routes/templates.py` — upload PDFs, run them through `commonforms` to create fillable templates, list/preview.
+ - `routes/forms.py` — given a template id + free-form text, runs the extraction + fill pipeline and persists a `FormSubmission`.
+ - Lifespan handler calls `init_db()` which creates SQLModel tables and seeds template id=2 (the "Manual Test Template" with the default employee fields). DB lives at `~/.fireform/fireform.db` (SQLite), **not** in the repo.
+ - All exceptions in routes funnel through `api/errors/handlers.py` via `AppError`.
+
+2. **`src/`** — The PDF-filling core, called from API routes via `src.controller.Controller`:
+ - `controller.py` → `file_manipulator.py` → (`llm.py`, `filler.py`). Controller is the only entry point routes use; never reach into `filler`/`llm` from the API layer.
+ - `llm.LLM.main_loop()` iterates *one Ollama call per field* using `prompt.txt` as the template, with retry/timeout. `OLLAMA_HOST` env var points at the Ollama service (default `http://localhost:11434`, set to `http://ollama:11434` inside Docker).
+ - `filler.Filler.fill_form()` uses `pdfrw` and assigns answers to PDF widget annotations sorted **top-to-bottom, left-to-right by `Rect`** — answers in `LLM._json` are ordered to match. If you change field iteration order in `LLM`, you must change the sort in `Filler` or fills will misalign.
+ - `file_manipulator.create_template()` calls `commonforms.prepare_form()`. `commonforms` pulls in `rfdetr` which tries to use CUDA; both `api/main.py` and `src/main.py` set `CUDA_VISIBLE_DEVICES=""` *and* monkey-patch `rfdetr.detr._ensure_model_on_device` to force CPU. **Keep these patches** — they exist because Mac Silicon / Docker has no NVIDIA drivers.
+
+3. **`frontend/`** — Electron app (`electron.js`) loading a plain HTML/JS UI (`index.html`, `app.js`). In dev it assumes the backend is already running (Docker). In a packaged build it spawns a bundled `bin/api-backend` binary from `process.resourcesPath`. There is no bundler/transpiler — vanilla JS only.
+
+### Important wiring details
+- `PYTHONPATH=/app` (repo root) is required so imports like `from api.db...` and `from src.controller...` resolve. Set in `Dockerfile` and `docker-compose.yml`.
+- The `Template.fields` column is a JSON dict (`{field_name: type_str}`). Iteration order of this dict determines the order answers are filled into the PDF.
+- `_resolve_target_directory` / `_resolve_project_file` in `routes/templates.py` enforce that all upload/preview paths stay inside `PROJECT_ROOT`. Don't loosen these checks — the directory-traversal test in `tests/test_api.py` covers them.
+
+## Commands
+
+The Makefile is the canonical interface; `make help` prints the menu.
+
+| Task | Command |
+|------|---------|
+| Build images, start containers, pull Mistral | `make fireform` |
+| Start / stop only | `make up` / `make down` |
+| Tail logs (all / one service) | `make logs` / `make logs-app` / `make logs-frontend` / `make logs-ollama` |
+| Python shell inside app container | `make shell` |
+| Run the full test suite | `make test` (= `docker compose exec app python3 -m pytest tests/ -v`) |
+| Run a single test | `docker compose exec app python3 -m pytest tests/test_api.py::TestFormEndpoints::test_fill_form_success -v` |
+| Pull Mistral into Ollama manually | `make pull-model` |
+| Nuke containers + volumes | `make clean` (or `super-clean` for `docker system prune`) |
+
+Services after `make up`: frontend at `http://localhost:5173`, API at `http://localhost:8000`, docs at `http://localhost:8000/docs`, Ollama at `http://localhost:11434`.
+
+### Frontend (Electron desktop app)
+```
+cd frontend
+npm install
+npm start # run Electron against the running backend
+npm run dist # build .dmg/.exe/.AppImage via electron-builder
+```
+The packaged app expects a `bin/api-backend` binary in `extraResources` — produced by the release workflow, not present in dev.
+
+## Tests
+
+`pytest` with `tests/conftest.py` providing `client`, `db`, `mock_controller`, and `pdf_upload` fixtures. Heavy deps (LLM, `commonforms`, filesystem) are **mocked** — tests do not require Ollama or a real PDF stack. When adding a route that calls `Controller`, extend `mock_controller` in `conftest.py` rather than hitting the real implementation.
+
+## CI
+
+`.github/workflows/` runs `tests.yml`, `lint.yml`, `docker-build.yml`, and `release.yml` (electron-builder, triggered by tags — see `frontend/package.json` `build` config).
diff --git a/api/routes/templates.py b/api/routes/templates.py
index 10e7aea..0bf6990 100644
--- a/api/routes/templates.py
+++ b/api/routes/templates.py
@@ -9,6 +9,8 @@
TemplateCreate,
TemplateResponse,
TemplateUploadResponse,
+ MakeFillableRequest,
+ MakeFillableResponse,
)
from api.db.repositories import create_template, list_templates
from api.db.models import Template
@@ -77,12 +79,27 @@ async def upload_template_pdf(
with target_path.open("wb") as output_file:
output_file.write(content)
+ relative_path = target_path.relative_to(PROJECT_ROOT).as_posix()
return TemplateUploadResponse(
filename=target_path.name,
- pdf_path=target_path.relative_to(PROJECT_ROOT).as_posix(),
+ pdf_path=relative_path,
+ field_count=_count_pdf_widgets(relative_path),
)
+def _count_pdf_widgets(pdf_path: str) -> int | None:
+ """Return number of fillable widgets in a PDF, or None if unreadable."""
+ try:
+ from pypdf import PdfReader
+ candidate = Path(pdf_path)
+ if not candidate.is_absolute():
+ candidate = (PROJECT_ROOT / candidate).resolve()
+ reader = PdfReader(str(candidate))
+ return len(reader.get_fields() or {})
+ except Exception:
+ return None
+
+
@router.get("", response_model=list[TemplateResponse])
def get_templates(db: Session = Depends(get_db)):
return list_templates(db)
@@ -98,12 +115,42 @@ def preview_template_pdf(path: str = Query(..., description="Project-relative PD
if resolved_path.suffix.lower() != ".pdf":
raise HTTPException(status_code=400, detail="Only PDF files can be previewed.")
- return FileResponse(resolved_path, media_type="application/pdf", filename=resolved_path.name)
+ return FileResponse(
+ resolved_path,
+ media_type="application/pdf",
+ filename=resolved_path.name,
+ content_disposition_type="inline",
+ )
@router.post("/create", response_model=TemplateResponse)
def create(template: TemplateCreate, db: Session = Depends(get_db)):
+ tpl = Template(**template.model_dump())
+ created = create_template(db, tpl)
+ return TemplateResponse(
+ id=created.id,
+ name=created.name,
+ pdf_path=created.pdf_path,
+ fields=created.fields,
+ field_count=_count_pdf_widgets(created.pdf_path),
+ )
+
+
+@router.post("/make-fillable", response_model=MakeFillableResponse)
+def make_fillable(req: MakeFillableRequest):
+ # Validate the path stays inside the project root.
+ resolved = _resolve_project_file(req.pdf_path)
+ if not resolved.exists() or not resolved.is_file():
+ raise HTTPException(status_code=404, detail="PDF file not found.")
+
controller = Controller()
- template_path = controller.create_template(template.pdf_path)
- tpl = Template(**template.model_dump(exclude={"pdf_path"}), pdf_path=template_path)
- return create_template(db, tpl)
+ new_absolute = controller.prepare_fillable(str(resolved))
+ new_path = Path(new_absolute)
+ if not new_path.is_absolute():
+ new_path = (PROJECT_ROOT / new_path).resolve()
+ relative_path = new_path.relative_to(PROJECT_ROOT).as_posix()
+
+ return MakeFillableResponse(
+ pdf_path=relative_path,
+ field_count=_count_pdf_widgets(relative_path),
+ )
diff --git a/api/schemas/templates.py b/api/schemas/templates.py
index 4997f04..91076e8 100644
--- a/api/schemas/templates.py
+++ b/api/schemas/templates.py
@@ -5,11 +5,21 @@ class TemplateCreate(BaseModel):
pdf_path: str
fields: dict
+
+class MakeFillableRequest(BaseModel):
+ pdf_path: str
+
+
+class MakeFillableResponse(BaseModel):
+ pdf_path: str
+ field_count: int | None = None
+
class TemplateResponse(BaseModel):
id: int
name: str
pdf_path: str
fields: dict
+ field_count: int | None = None
class Config:
from_attributes = True
@@ -18,3 +28,4 @@ class Config:
class TemplateUploadResponse(BaseModel):
filename: str
pdf_path: str
+ field_count: int | None = None
diff --git a/frontend/app.js b/frontend/app.js
index 2157b33..c8bd747 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -1,8 +1,26 @@
const STORAGE_TEMPLATES_KEY = "fireform.templates.v1";
const STORAGE_LAST_OUTPUT_KEY = "fireform.lastOutputPath.v1";
const STORAGE_TEMPLATE_DIR_KEY = "fireform.templateDirectory.v1";
+const STORAGE_FIELD_ROWS_KEY = "fireform.fieldRows.v1";
const API_BASE_URL = "http://127.0.0.1:8000";
+// UI label <-> stored type-string mapping. The stored values stay backward
+// compatible with the existing default "string" type.
+const FIELD_TYPES = [
+ { label: "Text", value: "string" },
+ { label: "Long Text", value: "long_text" },
+ { label: "Number", value: "number" },
+ { label: "Date", value: "date" },
+ { label: "Time", value: "time" },
+ { label: "Email", value: "email" },
+ { label: "Phone", value: "phone" },
+ { label: "Signature", value: "signature" },
+ { label: "Checkbox", value: "checkbox" },
+ { label: "List", value: "list" },
+];
+const TYPE_VALUE_TO_LABEL = Object.fromEntries(FIELD_TYPES.map((t) => [t.value, t.label]));
+const DEFAULT_FIELD_ROWS = [{ name: "", type: "string" }];
+
const elements = {
tabs: Array.from(document.querySelectorAll(".tab")),
panels: Array.from(document.querySelectorAll(".panel")),
@@ -12,7 +30,12 @@ const elements = {
pdfDropZone: document.getElementById("pdfDropZone"),
selectedFileMeta: document.getElementById("selectedFileMeta"),
templateDirectory: document.getElementById("templateDirectory"),
- templateFields: document.getElementById("templateFields"),
+ makeFillableBtn: document.getElementById("makeFillableBtn"),
+ makeFillableHelpBtn: document.getElementById("makeFillableHelpBtn"),
+ makeFillableHelp: document.getElementById("makeFillableHelp"),
+ fieldsBuilder: document.getElementById("fieldsBuilder"),
+ fieldCountBadge: document.getElementById("fieldCountBadge"),
+ addFieldBtn: document.getElementById("addFieldBtn"),
templateFormMessage: document.getElementById("templateFormMessage"),
templateFormResponse: document.getElementById("templateFormResponse"),
fillForm: document.getElementById("fillForm"),
@@ -32,6 +55,10 @@ const elements = {
let templates = loadTemplates();
let activeObjectUrl = null;
let selectedTemplateFile = null;
+let fieldRows = loadFieldRows();
+let dragSourceIndex = null;
+let uploadedPath = null;
+let uploadedFieldCount = null;
waitForBackend().then(initialize);
@@ -62,6 +89,7 @@ async function waitForBackend() {
async function initialize() {
bindEvents();
restoreTemplateDirectory();
+ renderFieldRows();
renderTemplates();
restorePreviewState();
updateSelectedFileMeta();
@@ -78,6 +106,9 @@ function bindEvents() {
elements.pdfDropZone.addEventListener("click", () => elements.templatePdfFile.click());
elements.pdfDropZone.addEventListener("keydown", handleDropZoneKeyDown);
elements.templateDirectory.addEventListener("input", handleTemplateDirectoryInput);
+ elements.addFieldBtn.addEventListener("click", handleAddFieldClick);
+ elements.makeFillableBtn.addEventListener("click", handleMakeFillableClick);
+ elements.makeFillableHelpBtn.addEventListener("click", toggleMakeFillableHelp);
bindDropZoneDragEvents();
elements.fillForm.addEventListener("submit", handleFillSubmit);
elements.templatesList.addEventListener("click", handleTemplateActionClick);
@@ -180,15 +211,76 @@ function setSelectedTemplateFile(file) {
if (!isPdfFile(file)) {
selectedTemplateFile = null;
+ uploadedPath = null;
+ uploadedFieldCount = null;
+ setMakeFillableButtonState();
+ renderFieldCountBadge();
setStatus(elements.templateFormMessage, "Please select a PDF file.", "error");
updateSelectedFileMeta();
return;
}
selectedTemplateFile = file;
+ uploadedPath = null;
+ uploadedFieldCount = null;
+ setMakeFillableButtonState();
+ renderFieldCountBadge();
clearJson(elements.templateFormResponse);
setStatus(elements.templateFormMessage, "");
updateSelectedFileMeta();
+ // Eager upload so the user gets a live field-count comparison while building rows.
+ uploadSelectedFileSilently();
+}
+
+async function uploadSelectedFileSilently() {
+ if (!selectedTemplateFile) return;
+ const directory = normalizeDirectory(elements.templateDirectory.value);
+ if (!directory) return;
+
+ const fileAtUploadStart = selectedTemplateFile;
+ try {
+ const upload = await uploadTemplatePdf(fileAtUploadStart, directory);
+ // Guard against the user picking a different file mid-upload.
+ if (fileAtUploadStart !== selectedTemplateFile) return;
+ uploadedPath = upload.pdf_path;
+ uploadedFieldCount =
+ typeof upload.field_count === "number" ? upload.field_count : null;
+ renderFieldCountBadge();
+ } catch (_error) {
+ // Silent failure — the explicit Create / Make Fillable paths surface errors.
+ }
+}
+
+function setMakeFillableButtonState() {
+ if (!elements.makeFillableBtn) return;
+ elements.makeFillableBtn.disabled = !selectedTemplateFile;
+ elements.makeFillableBtn.textContent = "Make this PDF fillable";
+}
+
+function renderFieldCountBadge() {
+ const badge = elements.fieldCountBadge;
+ if (!badge) return;
+
+ if (!selectedTemplateFile || uploadedFieldCount === null) {
+ badge.classList.add("hidden");
+ badge.classList.remove("match", "mismatch");
+ badge.textContent = "";
+ return;
+ }
+
+ const expected = uploadedFieldCount;
+ const actual = fieldRows.length;
+ const noun = (n) => `${n} fillable field${n === 1 ? "" : "s"}`;
+ const rowNoun = (n) => `${n} row${n === 1 ? "" : "s"}`;
+
+ badge.classList.remove("hidden", "match", "mismatch");
+ if (expected === actual) {
+ badge.classList.add("match");
+ badge.textContent = `PDF has ${noun(expected)} — your ${rowNoun(actual)} match.`;
+ } else {
+ badge.classList.add("mismatch");
+ badge.textContent = `PDF has ${noun(expected)} — you have ${rowNoun(actual)}.`;
+ }
}
function isPdfFile(file) {
@@ -256,16 +348,28 @@ function clearJson(preElement) {
preElement.classList.add("hidden");
}
-function normalizeFields(rawFields) {
- try {
- const parsed = JSON.parse(rawFields);
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
- return { error: "Fields must be a JSON object." };
+function collectFieldRows() {
+ syncFieldRowsFromDom();
+
+ if (fieldRows.length === 0) {
+ return { error: "Add at least one field before creating the template." };
+ }
+
+ const dict = {};
+ const seen = new Set();
+ for (const row of fieldRows) {
+ const name = row.name.trim();
+ if (!name) {
+ return { error: "Every field needs a name." };
}
- return { value: parsed };
- } catch (_error) {
- return { error: "Fields JSON is invalid. Fix syntax and try again." };
+ const key = name.toLowerCase();
+ if (seen.has(key)) {
+ return { error: `Field names must be unique ("${name}" appears more than once).` };
+ }
+ seen.add(key);
+ dict[name] = row.type || "string";
}
+ return { value: dict };
}
async function handleTemplateSubmit(event) {
@@ -275,7 +379,7 @@ async function handleTemplateSubmit(event) {
const name = elements.templateName.value.trim();
const templateDirectory = normalizeDirectory(elements.templateDirectory.value);
- const normalized = normalizeFields(elements.templateFields.value.trim());
+ const collected = collectFieldRows();
if (!name || !templateDirectory || !selectedTemplateFile) {
setStatus(
@@ -286,21 +390,26 @@ async function handleTemplateSubmit(event) {
return;
}
- if (normalized.error) {
- setStatus(elements.templateFormMessage, normalized.error, "error");
+ if (collected.error) {
+ setStatus(elements.templateFormMessage, collected.error, "error");
return;
}
try {
localStorage.setItem(STORAGE_TEMPLATE_DIR_KEY, templateDirectory);
- setStatus(elements.templateFormMessage, "Copying PDF into project directory...", "info");
-
- const upload = await uploadTemplatePdf(selectedTemplateFile, templateDirectory);
+ saveFieldRows();
+ let activePdfPath = uploadedPath;
+ if (!activePdfPath) {
+ setStatus(elements.templateFormMessage, "Copying PDF into project directory...", "info");
+ const upload = await uploadTemplatePdf(selectedTemplateFile, templateDirectory);
+ activePdfPath = upload.pdf_path;
+ uploadedPath = upload.pdf_path;
+ }
const payload = {
name,
- pdf_path: upload.pdf_path,
- fields: normalized.value,
+ pdf_path: activePdfPath,
+ fields: collected.value,
};
setStatus(elements.templateFormMessage, "Creating template...", "info");
@@ -320,12 +429,25 @@ async function handleTemplateSubmit(event) {
elements.fillTemplateId.value = String(body.id || "");
elements.serverPdfPath.value = body.pdf_path || "";
+ const expected = body.field_count;
+ const actual = Object.keys(collected.value).length;
+ let mismatchNote = "";
+ let statusLevel = "success";
+ if (typeof expected === "number" && expected !== actual) {
+ mismatchNote = ` Heads up — the PDF has ${expected} fillable field${expected === 1 ? "" : "s"}, but you added ${actual} row${actual === 1 ? "" : "s"}. Fills may be incomplete or misaligned.`;
+ statusLevel = "error";
+ }
+
setStatus(
elements.templateFormMessage,
- `Template created (id: ${body.id}). PDF saved at ${upload.pdf_path}.`,
- "success"
+ `Template created (id: ${body.id}). PDF saved at ${activePdfPath}.${mismatchNote}`,
+ statusLevel
);
showJson(elements.templateFormResponse, body);
+ uploadedPath = null;
+ uploadedFieldCount = null;
+ setMakeFillableButtonState();
+ renderFieldCountBadge();
} catch (error) {
setStatus(elements.templateFormMessage, error.message, "error");
}
@@ -535,9 +657,7 @@ function renderTemplates() {
path.className = "template-meta";
path.textContent = `pdf_path: ${template.pdf_path || ""}`;
- const fields = document.createElement("pre");
- fields.className = "json-output";
- fields.textContent = JSON.stringify(template.fields || {}, null, 2);
+ const fields = buildFieldsTable(template.fields || {});
const actions = document.createElement("div");
actions.className = "card-actions";
@@ -560,6 +680,261 @@ function renderTemplates() {
});
}
+function buildFieldsTable(fieldsDict) {
+ const table = document.createElement("table");
+ table.className = "fields-table";
+
+ const thead = document.createElement("thead");
+ thead.innerHTML = "Field Type ";
+ table.appendChild(thead);
+
+ const tbody = document.createElement("tbody");
+ const entries = Object.entries(fieldsDict || {});
+ if (!entries.length) {
+ const row = document.createElement("tr");
+ const cell = document.createElement("td");
+ cell.colSpan = 2;
+ cell.textContent = "No fields.";
+ row.appendChild(cell);
+ tbody.appendChild(row);
+ } else {
+ for (const [name, type] of entries) {
+ const row = document.createElement("tr");
+ const nameCell = document.createElement("td");
+ nameCell.textContent = name;
+ const typeCell = document.createElement("td");
+ typeCell.textContent = TYPE_VALUE_TO_LABEL[type] || "Text";
+ row.append(nameCell, typeCell);
+ tbody.appendChild(row);
+ }
+ }
+ table.appendChild(tbody);
+ return table;
+}
+
+function loadFieldRows() {
+ try {
+ const raw = localStorage.getItem(STORAGE_FIELD_ROWS_KEY);
+ if (!raw) {
+ return DEFAULT_FIELD_ROWS.map((row) => ({ ...row }));
+ }
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) {
+ return DEFAULT_FIELD_ROWS.map((row) => ({ ...row }));
+ }
+ return parsed
+ .filter((item) => item && typeof item === "object")
+ .map((item) => ({
+ name: typeof item.name === "string" ? item.name : "",
+ type: normalizeFieldType(item.type),
+ }));
+ } catch (_error) {
+ return DEFAULT_FIELD_ROWS.map((row) => ({ ...row }));
+ }
+}
+
+function normalizeFieldType(value) {
+ return TYPE_VALUE_TO_LABEL[value] ? value : "string";
+}
+
+function saveFieldRows() {
+ localStorage.setItem(STORAGE_FIELD_ROWS_KEY, JSON.stringify(fieldRows));
+}
+
+function syncFieldRowsFromDom() {
+ const rowEls = Array.from(elements.fieldsBuilder.querySelectorAll(".field-row"));
+ fieldRows = rowEls.map((rowEl) => ({
+ name: rowEl.querySelector(".field-name").value,
+ type: rowEl.querySelector(".field-type").value,
+ }));
+}
+
+function renderFieldRows() {
+ elements.fieldsBuilder.innerHTML = "";
+ fieldRows.forEach((row, index) => {
+ elements.fieldsBuilder.appendChild(buildFieldRow(row, index));
+ });
+ renderFieldCountBadge();
+}
+
+function buildFieldRow(row, index) {
+ const rowEl = document.createElement("div");
+ rowEl.className = "field-row";
+ rowEl.draggable = true;
+ rowEl.dataset.index = String(index);
+
+ const handle = document.createElement("span");
+ handle.className = "field-drag-handle";
+ handle.setAttribute("aria-hidden", "true");
+ handle.textContent = "⋮⋮"; // two-column dots — reads as a grip handle
+
+ const nameInput = document.createElement("input");
+ nameInput.type = "text";
+ nameInput.className = "field-name";
+ nameInput.placeholder = "Give description here";
+ nameInput.value = row.name || "";
+ nameInput.addEventListener("input", () => {
+ syncFieldRowsFromDom();
+ saveFieldRows();
+ });
+
+ const typeSelect = document.createElement("select");
+ typeSelect.className = "field-type";
+ FIELD_TYPES.forEach((t) => {
+ const opt = document.createElement("option");
+ opt.value = t.value;
+ opt.textContent = t.label;
+ typeSelect.appendChild(opt);
+ });
+ typeSelect.value = normalizeFieldType(row.type);
+ typeSelect.addEventListener("change", () => {
+ syncFieldRowsFromDom();
+ saveFieldRows();
+ });
+
+ const deleteBtn = document.createElement("button");
+ deleteBtn.type = "button";
+ deleteBtn.className = "field-delete-btn";
+ deleteBtn.setAttribute("aria-label", "Remove field");
+ deleteBtn.textContent = "✕"; // ✕
+ deleteBtn.addEventListener("click", () => {
+ syncFieldRowsFromDom();
+ const rowIndex = Number(rowEl.dataset.index);
+ fieldRows.splice(rowIndex, 1);
+ saveFieldRows();
+ renderFieldRows();
+ });
+
+ rowEl.addEventListener("dragstart", handleRowDragStart);
+ rowEl.addEventListener("dragover", handleRowDragOver);
+ rowEl.addEventListener("dragleave", handleRowDragLeave);
+ rowEl.addEventListener("drop", handleRowDrop);
+ rowEl.addEventListener("dragend", handleRowDragEnd);
+
+ rowEl.append(handle, nameInput, typeSelect, deleteBtn);
+ return rowEl;
+}
+
+function toggleMakeFillableHelp() {
+ const willShow = elements.makeFillableHelp.classList.contains("hidden");
+ elements.makeFillableHelp.classList.toggle("hidden", !willShow);
+ elements.makeFillableHelpBtn.setAttribute("aria-expanded", String(willShow));
+}
+
+async function handleMakeFillableClick() {
+ if (!selectedTemplateFile) {
+ setStatus(elements.templateFormMessage, "Select a PDF first.", "error");
+ return;
+ }
+
+ const templateDirectory = normalizeDirectory(elements.templateDirectory.value);
+ if (!templateDirectory) {
+ setStatus(elements.templateFormMessage, "Template directory is required.", "error");
+ return;
+ }
+
+ elements.makeFillableBtn.disabled = true;
+ const previousLabel = elements.makeFillableBtn.textContent;
+ elements.makeFillableBtn.textContent = "Working...";
+ setStatus(
+ elements.templateFormMessage,
+ "Uploading PDF and running fillable-field detection (this can take a minute)...",
+ "info"
+ );
+
+ try {
+ if (!uploadedPath) {
+ const upload = await uploadTemplatePdf(selectedTemplateFile, templateDirectory);
+ uploadedPath = upload.pdf_path;
+ }
+
+ const response = await fetch(`${API_BASE_URL}/templates/make-fillable`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ pdf_path: uploadedPath }),
+ });
+ const body = await parseJsonResponse(response);
+ if (!response.ok) {
+ throw new Error(extractErrorMessage(body, response.status));
+ }
+
+ uploadedPath = body.pdf_path;
+ const count = typeof body.field_count === "number" ? body.field_count : null;
+ uploadedFieldCount = count;
+ renderFieldCountBadge();
+ setStatus(
+ elements.templateFormMessage,
+ count !== null
+ ? `Fillable PDF created — ${count} field${count === 1 ? "" : "s"} detected.`
+ : "Fillable PDF created.",
+ "success"
+ );
+ elements.makeFillableBtn.textContent = "Re-detect fields";
+ elements.makeFillableBtn.disabled = false;
+ } catch (error) {
+ setStatus(elements.templateFormMessage, error.message, "error");
+ elements.makeFillableBtn.textContent = previousLabel;
+ elements.makeFillableBtn.disabled = false;
+ }
+}
+
+function handleAddFieldClick() {
+ syncFieldRowsFromDom();
+ fieldRows.push({ name: "", type: "string" });
+ saveFieldRows();
+ renderFieldRows();
+ const rows = elements.fieldsBuilder.querySelectorAll(".field-row .field-name");
+ if (rows.length) {
+ rows[rows.length - 1].focus();
+ }
+}
+
+function handleRowDragStart(event) {
+ const rowEl = event.currentTarget;
+ dragSourceIndex = Number(rowEl.dataset.index);
+ rowEl.classList.add("is-dragging");
+ if (event.dataTransfer) {
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("text/plain", String(dragSourceIndex));
+ }
+}
+
+function handleRowDragOver(event) {
+ event.preventDefault();
+ if (event.dataTransfer) {
+ event.dataTransfer.dropEffect = "move";
+ }
+ event.currentTarget.classList.add("drag-over");
+}
+
+function handleRowDragLeave(event) {
+ event.currentTarget.classList.remove("drag-over");
+}
+
+function handleRowDrop(event) {
+ event.preventDefault();
+ const rowEl = event.currentTarget;
+ rowEl.classList.remove("drag-over");
+ const targetIndex = Number(rowEl.dataset.index);
+ if (dragSourceIndex === null || dragSourceIndex === targetIndex) {
+ return;
+ }
+ syncFieldRowsFromDom();
+ const [moved] = fieldRows.splice(dragSourceIndex, 1);
+ fieldRows.splice(targetIndex, 0, moved);
+ dragSourceIndex = null;
+ saveFieldRows();
+ renderFieldRows();
+}
+
+function handleRowDragEnd(event) {
+ event.currentTarget.classList.remove("is-dragging");
+ elements.fieldsBuilder
+ .querySelectorAll(".field-row.drag-over")
+ .forEach((el) => el.classList.remove("drag-over"));
+ dragSourceIndex = null;
+}
+
function loadTemplates() {
try {
const raw = localStorage.getItem(STORAGE_TEMPLATES_KEY);
diff --git a/frontend/index.html b/frontend/index.html
index 81f5db5..8d62353 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -55,6 +55,25 @@ Create Template
No PDF selected.
+
+
+ Make this PDF fillable
+
+ ?
+
+
+ Use this only if your PDF doesn't already have fillable form fields. It runs
+ an AI step to detect fields and add them (slower). Skip it if your PDF is
+ already fillable.
+
+
Template Directory
Create Template
The selected PDF is copied into this project directory before template creation.
- Fields JSON
-
+ Form Fields
+
+ What information should be filled in? Add one row per field. Fields are filled
+ into your PDF in the order shown — drag to reorder.
+
+
+
+ + Add Field
Create Template
@@ -140,6 +157,6 @@ PDF Preview
-
+