diff --git a/api/routes/templates.py b/api/routes/templates.py index 10e7aea..64db2e0 100644 --- a/api/routes/templates.py +++ b/api/routes/templates.py @@ -1,3 +1,4 @@ +import re from datetime import datetime, timezone from pathlib import Path @@ -9,6 +10,8 @@ TemplateCreate, TemplateResponse, TemplateUploadResponse, + MakeFillableRequest, + MakeFillableResponse, ) from api.db.repositories import create_template, list_templates from api.db.models import Template @@ -77,12 +80,68 @@ 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() + extracted = _extract_pdf_fields(relative_path) return TemplateUploadResponse( filename=target_path.name, - pdf_path=target_path.relative_to(PROJECT_ROOT).as_posix(), + pdf_path=relative_path, + field_count=None if extracted is None else len(extracted), + fields=extracted or [], ) +# PDF field-type codes -> the type values the frontend field builder uses. +_FIELD_TYPE_BY_FT = {"/Tx": "string", "/Btn": "checkbox", "/Ch": "list", "/Sig": "signature"} + + +def _pdf_text(value) -> str: + """Decode a pdfrw string (field name / tooltip) to plain text.""" + if value is None: + return "" + if hasattr(value, "to_unicode"): + return value.to_unicode().strip() + return str(value).strip() + + +def _humanize(name: str) -> str: + """Turn a raw field name into a readable description (JobTitle -> Job Title).""" + text = re.sub(r"_+", " ", name) + text = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +def _extract_pdf_fields(pdf_path: str) -> list[dict] | None: + """Fillable widgets in the same order Filler.fill_form writes them + (top-to-bottom, left-to-right per page), so seeded rows line up with the + fill order. Returns None if the PDF can't be read.""" + try: + from pdfrw import PdfReader + candidate = Path(pdf_path) + if not candidate.is_absolute(): + candidate = (PROJECT_ROOT / candidate).resolve() + pdf = PdfReader(str(candidate)) + fields: list[dict] = [] + for page in pdf.pages: + widgets = [a for a in (page.Annots or []) if a.Subtype == "/Widget" and a.T] + widgets.sort(key=lambda a: (-float(a.Rect[1]), float(a.Rect[0]))) + for annot in widgets: + name = _pdf_text(annot.T) + fields.append({ + "name": name, + "description": _pdf_text(annot.TU) or _humanize(name), + "type": _FIELD_TYPE_BY_FT.get(str(annot.FT), "string"), + }) + return fields + except Exception: + return None + + +def _count_pdf_widgets(pdf_path: str) -> int | None: + """Number of fillable widgets in a PDF, or None if unreadable.""" + fields = _extract_pdf_fields(pdf_path) + return None if fields is None else len(fields) + + @router.get("", response_model=list[TemplateResponse]) def get_templates(db: Session = Depends(get_db)): return list_templates(db) @@ -98,12 +157,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..df39832 100644 --- a/api/schemas/templates.py +++ b/api/schemas/templates.py @@ -5,16 +5,34 @@ 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 +class ExtractedField(BaseModel): + name: str + description: str + type: str + + class TemplateUploadResponse(BaseModel): filename: str pdf_path: str + field_count: int | None = None + fields: list[ExtractedField] = [] diff --git a/frontend/app.js b/frontend/app.js index 2157b33..2aab25b 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,97 @@ 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; + maybeSeedFieldRows(upload.fields); + renderFieldCountBadge(); + } catch (_error) { + // Silent failure — the explicit Create / Make Fillable paths surface errors. + } +} + +// Prefill the field rows from the PDF's own form fields, but never overwrite +// rows the user has already started filling in. +function maybeSeedFieldRows(fields) { + if (!Array.isArray(fields) || !fields.length) return; + syncFieldRowsFromDom(); + if (!fieldRows.every((row) => !row.name.trim())) return; + + fieldRows = fields.map((f) => ({ + name: f.description || f.name || "", + type: normalizeFieldType(f.type), + })); + saveFieldRows(); + renderFieldRows(); + setStatus( + elements.templateFormMessage, + `Loaded ${fieldRows.length} field${fieldRows.length === 1 ? "" : "s"} from the PDF — edit the descriptions as needed.`, + "info" + ); +} + +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 +369,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 +400,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 +411,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 +450,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 +678,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 +701,263 @@ function renderTemplates() { }); } +function buildFieldsTable(fieldsDict) { + const table = document.createElement("table"); + table.className = "fields-table"; + + const thead = document.createElement("thead"); + thead.innerHTML = "
No PDF selected.
++ What information should be filled in? Add one row per field. Fields are filled + into your PDF in the order shown — drag to reorder. +
+ + + @@ -140,6 +157,6 @@