Add JSON presets and CSV data import/export

This commit is contained in:
Sean McElwain 2026-06-09 23:41:16 -05:00
commit a7b5e4f641
6 changed files with 1809 additions and 0 deletions

189
app/routes/doc_generator.py Normal file
View File

@ -0,0 +1,189 @@
import csv
import json
import re
from pathlib import Path
from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from tools.doc_generator.logic.document_types import get_document_type, list_document_types
from tools.doc_generator.logic.renderer import generate_docx
router = APIRouter()
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
EXPORTS_DIR = PROJECT_ROOT / "exports"
INPUTS_DIR = PROJECT_ROOT / "inputs"
UPLOADS_DIR = INPUTS_DIR / "uploads"
JSON_UPLOADS_DIR = UPLOADS_DIR / "json"
DATA_UPLOADS_DIR = UPLOADS_DIR / "data"
class GenerateDocRequest(BaseModel):
document_type_id: str
data: dict
def safe_filename(filename: str) -> str:
filename = Path(filename or "upload").name
filename = re.sub(r"[^A-Za-z0-9._ -]+", "", filename)
filename = re.sub(r"\s+", "_", filename).strip("._ ")
return filename or "upload"
def save_upload(upload: UploadFile, directory: Path) -> Path:
directory.mkdir(parents=True, exist_ok=True)
filename = safe_filename(upload.filename)
path = directory / filename
counter = 1
while path.exists():
stem = path.stem
suffix = path.suffix
path = directory / f"{stem}_{counter}{suffix}"
counter += 1
with path.open("wb") as f:
f.write(upload.file.read())
return path
@router.get("/document-types")
def document_types():
return {"document_types": list_document_types()}
@router.get("/document-types/{document_type_id}")
def document_type(document_type_id: str):
try:
return get_document_type(document_type_id)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
@router.post("/upload-json")
async def upload_json(file: UploadFile = File(...)):
if not file.filename.lower().endswith(".json"):
raise HTTPException(status_code=400, detail="Upload must be a JSON file.")
path = save_upload(file, JSON_UPLOADS_DIR)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {exc}")
return {
"ok": True,
"filename": path.name,
"saved_path": str(path.relative_to(PROJECT_ROOT)),
"json": data,
}
@router.get("/uploaded-json")
def uploaded_json_files():
JSON_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
files = []
for path in sorted(JSON_UPLOADS_DIR.glob("*.json")):
files.append({
"filename": path.name,
"saved_path": str(path.relative_to(PROJECT_ROOT)),
"modified": path.stat().st_mtime,
})
return {"files": files}
@router.get("/uploaded-json/{filename}")
def get_uploaded_json(filename: str):
file_path = JSON_UPLOADS_DIR / safe_filename(filename)
if not file_path.exists():
raise HTTPException(status_code=404, detail="Uploaded JSON not found.")
try:
data = json.loads(file_path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {exc}")
return {
"ok": True,
"filename": file_path.name,
"saved_path": str(file_path.relative_to(PROJECT_ROOT)),
"json": data,
}
@router.delete("/uploaded-json/{filename}")
def delete_uploaded_json(filename: str):
file_path = JSON_UPLOADS_DIR / safe_filename(filename)
if not file_path.exists():
raise HTTPException(status_code=404, detail="Uploaded JSON not found.")
file_path.unlink()
return {
"ok": True,
"deleted": file_path.name,
}
@router.post("/upload-data")
async def upload_data(file: UploadFile = File(...)):
if not file.filename.lower().endswith(".csv"):
raise HTTPException(status_code=400, detail="For now, upload data as CSV.")
path = save_upload(file, DATA_UPLOADS_DIR)
try:
with path.open("r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
rows = list(reader)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Could not read CSV: {exc}")
if not rows:
raise HTTPException(status_code=400, detail="CSV has no data rows.")
return {
"ok": True,
"filename": path.name,
"saved_path": str(path.relative_to(PROJECT_ROOT)),
"data": rows[0],
"row_count": len(rows),
}
@router.post("/generate")
def generate_document(request: GenerateDocRequest):
try:
output_path = generate_docx(request.document_type_id, request.data)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {
"ok": True,
"filename": output_path.name,
"download_url": f"/api/doc-generator/download/{output_path.name}"
}
@router.get("/download/{filename}")
def download(filename: str):
file_path = EXPORTS_DIR / safe_filename(filename)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(
path=file_path,
filename=file_path.name,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)

613
static/app.js Normal file
View File

@ -0,0 +1,613 @@
const documentTypeSelect = document.getElementById("documentTypeSelect");
const documentDescription = document.getElementById("documentDescription");
const fieldsContainer = document.getElementById("fieldsContainer");
const docForm = document.getElementById("docForm");
const statusBox = document.getElementById("status");
const clearFormButton = document.getElementById("clearFormButton");
const presetFileInput = document.getElementById("presetFileInput");
const dataFileInput = document.getElementById("dataFileInput");
const downloadDataButton = document.getElementById("downloadDataButton");
const downloadJsonButton = document.getElementById("downloadJsonButton");
const defaultDocumentOptions = document.getElementById("defaultDocumentOptions");
const uploadedJsonOptions = document.getElementById("uploadedJsonOptions");
const documentPickerToggle = document.getElementById("documentPickerToggle");
const documentPickerMenu = document.getElementById("documentPickerMenu");
const selectedDocumentLabel = document.getElementById("selectedDocumentLabel");
let currentDocumentType = null;
let currentFields = [];
let defaultDocumentTypes = [];
let activePickerKind = "default";
let activePickerId = "";
function setStatus(message) {
statusBox.innerHTML = message || "";
}
function setActivePicker(kind, id, label = "") {
activePickerKind = kind;
activePickerId = id;
document.querySelectorAll(".picker-item").forEach(item => {
item.classList.toggle(
"active",
item.dataset.kind === kind && item.dataset.id === id
);
});
if (label) {
selectedDocumentLabel.textContent = label;
}
}
function normalizeField(field, index, inheritedSection = "General Fields") {
return {
name: field.name || `field${index + 1}`,
label: field.label || field.name || `Field ${index + 1}`,
type: field.type || "text",
required: Boolean(field.required),
section: field.section || inheritedSection,
value: field.value ?? "",
options: field.options || []
};
}
function createInput(field) {
let input;
if (field.type === "textarea") {
input = document.createElement("textarea");
} else if (field.type === "select") {
input = document.createElement("select");
for (const option of field.options || []) {
const opt = document.createElement("option");
opt.value = option.value ?? option;
opt.textContent = option.label ?? option;
input.appendChild(opt);
}
} else {
input = document.createElement("input");
input.type = field.type || "text";
}
input.id = field.name;
input.name = field.name;
input.required = field.required;
input.value = field.value ?? "";
return input;
}
function makeFormGroup(field) {
const group = document.createElement("div");
group.className = "form-group";
const label = document.createElement("label");
label.setAttribute("for", field.name);
label.textContent = field.label;
group.appendChild(label);
group.appendChild(createInput(field));
return group;
}
function appendFieldRows(parent, fields) {
for (let i = 0; i < fields.length; i += 3) {
const row = document.createElement("div");
row.className = "form-row";
for (const field of fields.slice(i, i + 3)) {
row.appendChild(makeFormGroup(field));
}
parent.appendChild(row);
}
}
function renderSection(section, level = 2) {
const sectionWrapper = document.createElement("div");
sectionWrapper.className = "json-section";
const collapsible = section.collapsible !== false;
const defaultOpen = Boolean(section.defaultOpen);
let contentParent = sectionWrapper;
if (collapsible) {
const details = document.createElement("details");
details.open = defaultOpen;
const summary = document.createElement("summary");
summary.textContent = section.heading || "Section";
details.appendChild(summary);
sectionWrapper.appendChild(details);
contentParent = details;
} else {
const heading = document.createElement(`h${Math.min(level, 4)}`);
heading.textContent = section.heading || "Section";
sectionWrapper.appendChild(heading);
}
const normalizedFields = (section.fields || []).map((field, index) =>
normalizeField(field, currentFields.length + index, section.heading || "General Fields")
);
currentFields.push(...normalizedFields);
appendFieldRows(contentParent, normalizedFields);
for (const subsection of section.subsections || []) {
contentParent.appendChild(renderSection(subsection, level + 1));
}
return sectionWrapper;
}
function renderFlatFields(fields) {
const bySection = {};
const normalizedFields = fields.map((field, index) => normalizeField(field, index));
for (const field of normalizedFields) {
const section = field.section || "General Fields";
if (!bySection[section]) bySection[section] = [];
bySection[section].push(field);
}
currentFields = [];
for (const [sectionName, sectionFields] of Object.entries(bySection)) {
fieldsContainer.appendChild(renderSection({
heading: sectionName,
collapsible: sectionName !== "General Fields",
defaultOpen: sectionName === "General Fields",
fields: sectionFields
}));
}
}
function renderDocumentType(documentType) {
fieldsContainer.innerHTML = "";
currentFields = [];
if (Array.isArray(documentType.sections)) {
for (const section of documentType.sections) {
fieldsContainer.appendChild(renderSection(section));
}
return;
}
renderFlatFields(documentType.fields || []);
}
function applyDataToForm(data) {
for (const field of currentFields) {
const el = document.getElementById(field.name);
if (el && data[field.name] !== undefined) {
el.value = data[field.name];
}
}
}
function loadPresetObject(preset) {
if (preset.documentTypeId || preset.document_type_id) {
documentTypeSelect.value = preset.documentTypeId || preset.document_type_id;
}
if (preset.sections) {
currentDocumentType = {
...currentDocumentType,
...preset,
id: currentDocumentType?.id || preset.documentTypeId || preset.document_type_id || "uploaded_json"
};
documentDescription.textContent = currentDocumentType.description || "";
renderDocumentType(currentDocumentType);
return;
}
if (Array.isArray(preset.fields)) {
currentDocumentType = {
...currentDocumentType,
...preset,
id: currentDocumentType?.id || preset.documentTypeId || preset.document_type_id || "uploaded_json",
sections: [
{
heading: preset.heading || preset.name || "Imported Fields",
collapsible: false,
defaultOpen: true,
fields: preset.fields
}
]
};
documentDescription.textContent = currentDocumentType.description || "";
renderDocumentType(currentDocumentType);
return;
}
if (preset.data && typeof preset.data === "object") {
applyDataToForm(preset.data);
return;
}
if (typeof preset === "object") {
currentDocumentType = {
...currentDocumentType,
id: currentDocumentType?.id || "uploaded_json",
name: "Uploaded JSON",
sections: [
{
heading: "Imported Fields",
collapsible: false,
defaultOpen: true,
fields: Object.entries(preset).map(([key, value]) => ({
name: key,
label: key,
type: typeof value === "string" && value.length > 80 ? "textarea" : "text",
value
}))
}
]
};
renderDocumentType(currentDocumentType);
}
}
async function loadDocumentTypes() {
const response = await fetch("/api/doc-generator/document-types");
const json = await response.json();
defaultDocumentTypes = json.document_types || [];
renderDefaultDocumentOptions();
if (defaultDocumentTypes.length > 0) {
await loadDefaultDocumentType(defaultDocumentTypes[0].id);
}
await loadUploadedJsonOptions();
}
function renderDefaultDocumentOptions() {
defaultDocumentOptions.innerHTML = "";
for (const documentType of defaultDocumentTypes) {
const item = document.createElement("button");
item.type = "button";
item.className = "picker-item";
item.dataset.kind = "default";
item.dataset.id = documentType.id;
item.textContent = documentType.name;
item.addEventListener("click", async () => {
await loadDefaultDocumentType(documentType.id);
});
defaultDocumentOptions.appendChild(item);
}
}
async function loadDefaultDocumentType(documentTypeId) {
const response = await fetch(`/api/doc-generator/document-types/${documentTypeId}`);
currentDocumentType = await response.json();
documentTypeSelect.value = documentTypeId;
documentDescription.textContent = currentDocumentType.description || "";
renderDocumentType(currentDocumentType);
setActivePicker("default", documentTypeId, currentDocumentType.name || documentTypeId);
closeDocumentPicker();
}
async function loadUploadedJsonOptions() {
const response = await fetch("/api/doc-generator/uploaded-json");
const json = await response.json();
uploadedJsonOptions.innerHTML = "";
if (!json.files || json.files.length === 0) {
const empty = document.createElement("div");
empty.className = "picker-empty";
empty.textContent = "No uploaded JSON files yet.";
uploadedJsonOptions.appendChild(empty);
return;
}
for (const file of json.files) {
const row = document.createElement("div");
row.className = "uploaded-json-row";
const loadButton = document.createElement("button");
loadButton.type = "button";
loadButton.className = "picker-item uploaded-json-load";
loadButton.dataset.kind = "uploaded";
loadButton.dataset.id = file.filename;
loadButton.textContent = file.filename;
loadButton.addEventListener("click", async () => {
await loadUploadedJson(file.filename);
});
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "delete-json-button";
deleteButton.textContent = "x";
deleteButton.title = `Delete ${file.filename}`;
deleteButton.addEventListener("click", async event => {
event.stopPropagation();
if (!confirm(`Delete uploaded JSON file?\n\n${file.filename}`)) {
return;
}
const response = await fetch(`/api/doc-generator/uploaded-json/${encodeURIComponent(file.filename)}`, {
method: "DELETE"
});
const result = await response.json();
if (!response.ok) {
setStatus(`Could not delete JSON: ${result.detail || "Delete failed."}`);
return;
}
setStatus(`Deleted uploaded JSON: ${result.deleted}`);
await loadUploadedJsonOptions();
if (activePickerKind === "uploaded" && activePickerId === file.filename) {
if (defaultDocumentTypes.length > 0) {
await loadDefaultDocumentType(defaultDocumentTypes[0].id);
}
}
});
row.appendChild(loadButton);
row.appendChild(deleteButton);
uploadedJsonOptions.appendChild(row);
}
}
async function loadUploadedJson(filename) {
const response = await fetch(`/api/doc-generator/uploaded-json/${encodeURIComponent(filename)}`);
const result = await response.json();
if (!response.ok) {
setStatus(`Could not load JSON: ${result.detail || "Load failed."}`);
return;
}
loadPresetObject(result.json);
setActivePicker("uploaded", filename, filename);
closeDocumentPicker();
setStatus(`Loaded uploaded JSON: ${filename}`);
}
function getFormData(useFieldNameForBlanks = true) {
const data = {};
for (const field of currentFields) {
const el = document.getElementById(field.name);
const value = el ? el.value.trim() : "";
data[field.name] = useFieldNameForBlanks ? (value || field.name) : value;
}
return data;
}
function csvEscape(value) {
const text = String(value ?? "");
if (/[",\n\r]/.test(text)) {
return `"${text.replaceAll('"', '""')}"`;
}
return text;
}
function downloadCurrentDataCsv() {
const headers = currentFields.map(field => field.name);
const data = getFormData(false);
const csv = [
headers.map(csvEscape).join(","),
headers.map(header => csvEscape(data[header] ?? "")).join(",")
].join("\n");
const blob = new Blob([csv], {type: "text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${currentDocumentType?.id || "document"}_data.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function cloneWithCurrentValues(value) {
const byName = {};
for (const field of currentFields) {
const el = document.getElementById(field.name);
byName[field.name] = el ? el.value : "";
}
const clone = JSON.parse(JSON.stringify(value));
function patchSection(section) {
for (const field of section.fields || []) {
if (field.name && byName[field.name] !== undefined) {
field.value = byName[field.name];
}
}
for (const subsection of section.subsections || []) {
patchSection(subsection);
}
}
if (Array.isArray(clone.sections)) {
for (const section of clone.sections) {
patchSection(section);
}
} else if (Array.isArray(clone.fields)) {
for (const field of clone.fields) {
if (field.name && byName[field.name] !== undefined) {
field.value = byName[field.name];
}
}
}
return clone;
}
function downloadCurrentJson() {
if (!currentDocumentType) {
setStatus("No document type selected.");
return;
}
const exportJson = cloneWithCurrentValues({
...currentDocumentType,
exportedAt: new Date().toISOString()
});
const text = JSON.stringify(exportJson, null, 2);
const blob = new Blob([text], {type: "application/json;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${currentDocumentType.id || "document"}_preset.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
clearFormButton.addEventListener("click", () => {
for (const field of currentFields) {
const el = document.getElementById(field.name);
if (el) el.value = "";
}
setStatus("");
});
presetFileInput.addEventListener("change", async event => {
const file = event.target.files[0];
if (!file) return;
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/doc-generator/upload-json", {
method: "POST",
body: formData
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || "JSON upload failed.");
}
await loadUploadedJsonOptions();
loadPresetObject(result.json);
setActivePicker("uploaded", result.filename, result.filename);
closeDocumentPicker();
setStatus(`Uploaded JSON: ${result.filename}<br>Saved to: ${result.saved_path}`);
} catch (error) {
setStatus(`Could not upload JSON: ${error.message}`);
} finally {
presetFileInput.value = "";
}
});
dataFileInput.addEventListener("change", async event => {
const file = event.target.files[0];
if (!file) return;
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/doc-generator/upload-data", {
method: "POST",
body: formData
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || "CSV upload failed.");
}
applyDataToForm(result.data);
setStatus(`Uploaded data CSV: ${result.filename}<br>Saved to: ${result.saved_path}<br>Rows found: ${result.row_count}`);
} catch (error) {
setStatus(`Could not upload CSV: ${error.message}`);
} finally {
dataFileInput.value = "";
}
});
downloadDataButton.addEventListener("click", () => {
downloadCurrentDataCsv();
});
downloadJsonButton.addEventListener("click", () => {
downloadCurrentJson();
});
docForm.addEventListener("submit", async event => {
event.preventDefault();
setStatus("Generating DOCX...");
const response = await fetch("/api/doc-generator/generate", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
document_type_id: currentDocumentType.id,
data: getFormData(true)
})
});
const json = await response.json();
if (!response.ok) {
setStatus(`Error: ${json.detail || "Generation failed."}`);
return;
}
setStatus(`<a href="${json.download_url}">Download ${json.filename}</a>`);
});
loadDocumentTypes().catch(error => {
setStatus(`Startup error: ${error.message}`);
});
function openDocumentPicker() {
documentPickerMenu.classList.add("open");
documentPickerToggle.classList.add("open");
}
function closeDocumentPicker() {
documentPickerMenu.classList.remove("open");
documentPickerToggle.classList.remove("open");
}
function toggleDocumentPicker() {
if (documentPickerMenu.classList.contains("open")) {
closeDocumentPicker();
} else {
openDocumentPicker();
}
}
documentPickerToggle.addEventListener("click", event => {
event.stopPropagation();
toggleDocumentPicker();
});
document.addEventListener("click", event => {
if (!event.target.closest(".dropdown-picker")) {
closeDocumentPicker();
}
});

67
static/index.html Normal file
View File

@ -0,0 +1,67 @@
<!doctype html>
<html>
<head>
<title>Document Generator</title>
<link rel="stylesheet" href="/static/styles.css?v=picker1">
</head>
<body>
<div class="container">
<div class="heading">
<h1>Document Generator</h1>
</div>
<button id="clearFormButton" type="button">Clear Form</button>
<label class="file-button">
Upload JSON
<input type="file" id="presetFileInput" accept=".json,application/json">
</label>
<button id="downloadJsonButton" type="button">Download JSON</button>
<label class="file-button">
Upload Data CSV
<input type="file" id="dataFileInput" accept=".csv,text/csv">
</label>
<button id="downloadDataButton" type="button">Download Data CSV</button>
<h2>Document Type</h2>
<input type="hidden" id="documentTypeSelect">
<div class="document-picker dropdown-picker">
<button id="documentPickerToggle" class="document-picker-toggle" type="button">
<span id="selectedDocumentLabel">Select Document Type</span>
<span class="dropdown-arrow"></span>
</button>
<div id="documentPickerMenu" class="document-picker-menu">
<h3>Default Templates</h3>
<div id="defaultDocumentOptions" class="picker-list"></div>
<h3>Uploaded JSONs</h3>
<div id="uploadedJsonOptions" class="picker-list"></div>
</div>
</div>
<div id="documentDescription"></div>
<form id="docForm">
<div id="fieldsContainer"></div>
<h2>Generate Documents</h2>
<div class="form-row">
<div class="form-group">
<input type="submit" value="Generate DOCX">
</div>
</div>
</form>
<div id="status"></div>
</div>
<script src="/static/app.js?v=picker1"></script>
</body>
</html>

303
static/styles.css Normal file
View File

@ -0,0 +1,303 @@
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f0f0f0;
}
.container {
max-width: 800px;
margin: 20px auto;
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1);
}
.heading {
text-align: center;
margin-bottom: 20px;
}
.heading h1 {
font-size: 24px;
margin: 0;
color: #333;
}
h2 {
font-size: 20px;
margin: 20px 0 10px;
color: #555;
}
h3 {
font-size: 18px;
margin: 10px 0 5px;
color: #666;
}
.form-row {
display: flex;
flex-wrap: wrap;
margin-bottom: 15px;
justify-content: space-between;
}
.form-group {
flex-basis: calc(33.33% - 10px);
padding: 0 10px;
box-sizing: border-box;
margin: 5px;
}
.form-group label {
font-weight: bold;
display: block;
margin-bottom: 5px;
color: #333;
font-size: 14px;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 5px;
box-sizing: border-box;
font-size: 14px;
font-family: Arial, sans-serif;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: #007bff;
outline: none;
}
textarea {
height: 100px;
resize: vertical;
}
button,
.file-button,
input[type="submit"] {
display: inline-block;
padding: 5px 10px;
margin: 4px;
border: 1px solid #aaa;
border-radius: 3px;
background-color: #e9e9ef;
color: #222;
font-size: 14px;
font-family: Arial, sans-serif;
cursor: pointer;
}
.file-button input {
display: none;
}
#documentDescription {
color: #666;
margin: 10px 5px;
}
#status {
margin-top: 20px;
padding: 12px;
min-height: 32px;
background-color: #eee;
word-break: break-word;
}
#status a {
font-weight: bold;
color: #0645ad;
}
.json-section {
margin-top: 10px;
}
.json-section details {
margin: 10px 0;
}
.json-section summary {
font-size: 18px;
margin: 10px 0 8px;
color: #666;
font-weight: bold;
cursor: pointer;
}
.json-section h2 {
font-size: 20px;
margin: 20px 0 10px;
color: #555;
}
.json-section h3,
.json-section h4 {
font-size: 18px;
margin: 10px 0 5px;
color: #666;
}
.document-picker {
margin: 10px 0 18px;
padding: 12px;
background: #f7f7f7;
border: 1px solid #ddd;
border-radius: 6px;
}
.document-picker h3 {
margin: 8px 0 6px;
color: #555;
}
.picker-list {
margin-bottom: 10px;
}
.picker-item {
display: block;
width: 100%;
text-align: left;
margin: 4px 0;
padding: 7px 10px;
}
.picker-item.active {
border-color: #6b35ff;
outline: 2px solid #6b35ff;
background: #f1edff;
}
.uploaded-json-row {
display: flex;
gap: 6px;
align-items: center;
}
.uploaded-json-load {
flex: 1;
}
.delete-json-button {
flex: 0 0 34px;
width: 34px;
text-align: center;
color: #8a0000;
font-weight: bold;
}
.picker-empty {
color: #777;
font-style: italic;
padding: 4px 0 8px;
}
.dropdown-picker {
position: relative;
margin: 10px 0 18px;
padding: 0;
background: transparent;
border: none;
}
.document-picker-toggle {
display: flex;
justify-content: space-between;
align-items: center;
width: 300px;
text-align: left;
padding: 8px 10px;
margin: 4px 0;
border: 1px solid #999;
border-radius: 4px;
background: #e9e9ef;
font-size: 14px;
}
.document-picker-toggle.open {
border-color: #6b35ff;
outline: 2px solid #6b35ff;
}
.dropdown-arrow {
margin-left: 12px;
font-size: 12px;
}
.document-picker-menu {
display: none;
position: absolute;
z-index: 1000;
width: 420px;
max-height: 420px;
overflow-y: auto;
margin-top: 4px;
padding: 12px;
background: #f7f7f7;
border: 1px solid #bbb;
border-radius: 6px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
}
.document-picker-menu.open {
display: block;
}
.document-picker-menu h3 {
margin: 8px 0 6px;
color: #555;
}
.picker-list {
margin-bottom: 10px;
}
.picker-item {
display: block;
width: 100%;
text-align: left;
margin: 4px 0;
padding: 7px 10px;
}
.picker-item.active {
border-color: #6b35ff;
outline: 2px solid #6b35ff;
background: #f1edff;
}
.uploaded-json-row {
display: flex;
gap: 6px;
align-items: center;
}
.uploaded-json-load {
flex: 1;
}
.delete-json-button {
flex: 0 0 34px;
width: 34px;
text-align: center;
color: #8a0000;
font-weight: bold;
}
.picker-empty {
color: #777;
font-style: italic;
padding: 4px 0 8px;
}

View File

@ -0,0 +1,637 @@
{
"id": "blank_merge",
"name": "Blank Merge Form",
"description": "Generic merge form with 100 placeholder fields.",
"template": "blank_merge.docx",
"outputFilename": "blank_merge_{timestamp_YYYY-MM-DD_HH-mm-ss}.docx",
"sections": [
{
"heading": "Fields 1-25",
"collapsible": false,
"defaultOpen": true,
"fields": [
{
"name": "field1",
"label": "Field 1",
"type": "text",
"required": false
},
{
"name": "field2",
"label": "Field 2",
"type": "text",
"required": false
},
{
"name": "field3",
"label": "Field 3",
"type": "text",
"required": false
},
{
"name": "field4",
"label": "Field 4",
"type": "text",
"required": false
},
{
"name": "field5",
"label": "Field 5",
"type": "text",
"required": false
},
{
"name": "field6",
"label": "Field 6",
"type": "text",
"required": false
},
{
"name": "field7",
"label": "Field 7",
"type": "text",
"required": false
},
{
"name": "field8",
"label": "Field 8",
"type": "text",
"required": false
},
{
"name": "field9",
"label": "Field 9",
"type": "text",
"required": false
},
{
"name": "field10",
"label": "Field 10",
"type": "textarea",
"required": false
},
{
"name": "field11",
"label": "Field 11",
"type": "text",
"required": false
},
{
"name": "field12",
"label": "Field 12",
"type": "text",
"required": false
},
{
"name": "field13",
"label": "Field 13",
"type": "text",
"required": false
},
{
"name": "field14",
"label": "Field 14",
"type": "text",
"required": false
},
{
"name": "field15",
"label": "Field 15",
"type": "text",
"required": false
},
{
"name": "field16",
"label": "Field 16",
"type": "text",
"required": false
},
{
"name": "field17",
"label": "Field 17",
"type": "text",
"required": false
},
{
"name": "field18",
"label": "Field 18",
"type": "text",
"required": false
},
{
"name": "field19",
"label": "Field 19",
"type": "text",
"required": false
},
{
"name": "field20",
"label": "Field 20",
"type": "textarea",
"required": false
},
{
"name": "field21",
"label": "Field 21",
"type": "text",
"required": false
},
{
"name": "field22",
"label": "Field 22",
"type": "text",
"required": false
},
{
"name": "field23",
"label": "Field 23",
"type": "text",
"required": false
},
{
"name": "field24",
"label": "Field 24",
"type": "text",
"required": false
},
{
"name": "field25",
"label": "Field 25",
"type": "text",
"required": false
}
]
},
{
"heading": "Fields 26-50",
"collapsible": true,
"defaultOpen": false,
"fields": [
{
"name": "field26",
"label": "Field 26",
"type": "text",
"required": false
},
{
"name": "field27",
"label": "Field 27",
"type": "text",
"required": false
},
{
"name": "field28",
"label": "Field 28",
"type": "text",
"required": false
},
{
"name": "field29",
"label": "Field 29",
"type": "text",
"required": false
},
{
"name": "field30",
"label": "Field 30",
"type": "textarea",
"required": false
},
{
"name": "field31",
"label": "Field 31",
"type": "text",
"required": false
},
{
"name": "field32",
"label": "Field 32",
"type": "text",
"required": false
},
{
"name": "field33",
"label": "Field 33",
"type": "text",
"required": false
},
{
"name": "field34",
"label": "Field 34",
"type": "text",
"required": false
},
{
"name": "field35",
"label": "Field 35",
"type": "text",
"required": false
},
{
"name": "field36",
"label": "Field 36",
"type": "text",
"required": false
},
{
"name": "field37",
"label": "Field 37",
"type": "text",
"required": false
},
{
"name": "field38",
"label": "Field 38",
"type": "text",
"required": false
},
{
"name": "field39",
"label": "Field 39",
"type": "text",
"required": false
},
{
"name": "field40",
"label": "Field 40",
"type": "textarea",
"required": false
},
{
"name": "field41",
"label": "Field 41",
"type": "text",
"required": false
},
{
"name": "field42",
"label": "Field 42",
"type": "text",
"required": false
},
{
"name": "field43",
"label": "Field 43",
"type": "text",
"required": false
},
{
"name": "field44",
"label": "Field 44",
"type": "text",
"required": false
},
{
"name": "field45",
"label": "Field 45",
"type": "text",
"required": false
},
{
"name": "field46",
"label": "Field 46",
"type": "text",
"required": false
},
{
"name": "field47",
"label": "Field 47",
"type": "text",
"required": false
},
{
"name": "field48",
"label": "Field 48",
"type": "text",
"required": false
},
{
"name": "field49",
"label": "Field 49",
"type": "text",
"required": false
},
{
"name": "field50",
"label": "Field 50",
"type": "textarea",
"required": false
}
]
},
{
"heading": "Fields 51-75",
"collapsible": true,
"defaultOpen": false,
"fields": [
{
"name": "field51",
"label": "Field 51",
"type": "text",
"required": false
},
{
"name": "field52",
"label": "Field 52",
"type": "text",
"required": false
},
{
"name": "field53",
"label": "Field 53",
"type": "text",
"required": false
},
{
"name": "field54",
"label": "Field 54",
"type": "text",
"required": false
},
{
"name": "field55",
"label": "Field 55",
"type": "text",
"required": false
},
{
"name": "field56",
"label": "Field 56",
"type": "text",
"required": false
},
{
"name": "field57",
"label": "Field 57",
"type": "text",
"required": false
},
{
"name": "field58",
"label": "Field 58",
"type": "text",
"required": false
},
{
"name": "field59",
"label": "Field 59",
"type": "text",
"required": false
},
{
"name": "field60",
"label": "Field 60",
"type": "textarea",
"required": false
},
{
"name": "field61",
"label": "Field 61",
"type": "text",
"required": false
},
{
"name": "field62",
"label": "Field 62",
"type": "text",
"required": false
},
{
"name": "field63",
"label": "Field 63",
"type": "text",
"required": false
},
{
"name": "field64",
"label": "Field 64",
"type": "text",
"required": false
},
{
"name": "field65",
"label": "Field 65",
"type": "text",
"required": false
},
{
"name": "field66",
"label": "Field 66",
"type": "text",
"required": false
},
{
"name": "field67",
"label": "Field 67",
"type": "text",
"required": false
},
{
"name": "field68",
"label": "Field 68",
"type": "text",
"required": false
},
{
"name": "field69",
"label": "Field 69",
"type": "text",
"required": false
},
{
"name": "field70",
"label": "Field 70",
"type": "textarea",
"required": false
},
{
"name": "field71",
"label": "Field 71",
"type": "text",
"required": false
},
{
"name": "field72",
"label": "Field 72",
"type": "text",
"required": false
},
{
"name": "field73",
"label": "Field 73",
"type": "text",
"required": false
},
{
"name": "field74",
"label": "Field 74",
"type": "text",
"required": false
},
{
"name": "field75",
"label": "Field 75",
"type": "text",
"required": false
}
]
},
{
"heading": "Fields 76-100",
"collapsible": true,
"defaultOpen": false,
"fields": [
{
"name": "field76",
"label": "Field 76",
"type": "text",
"required": false
},
{
"name": "field77",
"label": "Field 77",
"type": "text",
"required": false
},
{
"name": "field78",
"label": "Field 78",
"type": "text",
"required": false
},
{
"name": "field79",
"label": "Field 79",
"type": "text",
"required": false
},
{
"name": "field80",
"label": "Field 80",
"type": "textarea",
"required": false
},
{
"name": "field81",
"label": "Field 81",
"type": "text",
"required": false
},
{
"name": "field82",
"label": "Field 82",
"type": "text",
"required": false
},
{
"name": "field83",
"label": "Field 83",
"type": "text",
"required": false
},
{
"name": "field84",
"label": "Field 84",
"type": "text",
"required": false
},
{
"name": "field85",
"label": "Field 85",
"type": "text",
"required": false
},
{
"name": "field86",
"label": "Field 86",
"type": "text",
"required": false
},
{
"name": "field87",
"label": "Field 87",
"type": "text",
"required": false
},
{
"name": "field88",
"label": "Field 88",
"type": "text",
"required": false
},
{
"name": "field89",
"label": "Field 89",
"type": "text",
"required": false
},
{
"name": "field90",
"label": "Field 90",
"type": "textarea",
"required": false
},
{
"name": "field91",
"label": "Field 91",
"type": "text",
"required": false
},
{
"name": "field92",
"label": "Field 92",
"type": "text",
"required": false
},
{
"name": "field93",
"label": "Field 93",
"type": "text",
"required": false
},
{
"name": "field94",
"label": "Field 94",
"type": "text",
"required": false
},
{
"name": "field95",
"label": "Field 95",
"type": "text",
"required": false
},
{
"name": "field96",
"label": "Field 96",
"type": "text",
"required": false
},
{
"name": "field97",
"label": "Field 97",
"type": "text",
"required": false
},
{
"name": "field98",
"label": "Field 98",
"type": "text",
"required": false
},
{
"name": "field99",
"label": "Field 99",
"type": "text",
"required": false
},
{
"name": "field100",
"label": "Field 100",
"type": "textarea",
"required": false
}
]
}
]
}

Binary file not shown.