feat: end-to-end migration runner and test template utilities

migrate_template.py — generic end-to-end CLI replacing the earlier
migrate_paul_template.py:
  --list                   list available Adobe Sign templates
  --template "Name"        download → convert → upload a named template
  --template "Name" --skip-upload  convert only, write JSON to migration-output/
  Picks most recently modified when multiple templates share a name.

create_adobe_template.py — utility for creating a test template in Adobe Sign
that exercises all 15+ field types. Uses the David Tag Demo Form PDF as the
base document and positions extra fields (Number, Email, Company, Title) in
the gaps of the original layout.

generate_pdfs.py — generates realistic sample PDFs with labelled form areas
matching the *-formfields.json fixtures in sample-templates/, for use in
offline testing without a live Adobe Sign account.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Huliganga 2026-04-15 19:45:31 -04:00
parent 93b6ad248a
commit 9c0910f30f
3 changed files with 781 additions and 0 deletions

View File

@ -0,0 +1,173 @@
"""
create_adobe_template.py
------------------------
Creates "Paul Adobe Template" in Adobe Sign by:
1. Using the exact field positions from the downloaded David Tag Demo Form
2. Adding 4 extra fields (Number, Email, Company, Title) in available gaps
Usage:
python3 src/create_adobe_template.py
"""
import json
import os
import sys
from dotenv import load_dotenv
load_dotenv()
sys.path.insert(0, os.path.dirname(__file__))
from adobe_api import adobe_api_post_multipart, adobe_api_post_json, adobe_api_put_json
PDF_PATH = os.path.join(
os.path.dirname(__file__), "..",
"downloads", "David Tag Demo Form__CBJCHBCA", "Tag Demo Form_docx_pdf"
)
FIELDS_JSON_PATH = os.path.join(
os.path.dirname(__file__), "..",
"downloads", "David Tag Demo Form__CBJCHBCA", "form_fields.json"
)
TEMPLATE_NAME = "Paul Adobe Template"
# Keys the Adobe Sign API accepts when writing form fields.
# We strip server-generated metadata (origin, signerIndex, font/border styling).
ALLOWED_KEYS = {
"name", "inputType", "contentType", "validation", "validationData",
"required", "readOnly", "locations", "assignee",
"hiddenOptions", "visibleOptions", "defaultValue",
"masked", "maskingText", "calculated", "urlOverridable",
"minLength", "maxLength", "minValue", "maxValue",
"validationErrMsg", "currency", "conditionalAction",
"radioCheckType",
}
# Extra fields that cover types not present in the original David Tag form:
# - NUMBER validation (TEXT_FIELD / DATA / NUMBER)
# - SIGNER_EMAIL auto-fill
# - COMPANY auto-fill
# - TITLE auto-fill
#
# Placed in the two clear vertical gaps in the original layout:
# Gap A: y=375432 (between checkboxes and Initials 1), right side (left=350)
# Gap B: y=513582 (between Date of Signing 1 and Signature block), left side (left=106)
EXTRA_FIELDS = [
{
"name": "Company",
"inputType": "TEXT_FIELD", "contentType": "COMPANY", "validation": "NONE",
"required": False, "readOnly": False,
"locations": [{"pageNumber": 1, "top": 378, "left": 350, "width": 150, "height": 24}],
"assignee": "recipient0",
},
{
"name": "Title",
"inputType": "TEXT_FIELD", "contentType": "TITLE", "validation": "NONE",
"required": False, "readOnly": False,
"locations": [{"pageNumber": 1, "top": 410, "left": 350, "width": 150, "height": 24}],
"assignee": "recipient0",
},
{
"name": "Number Field",
"inputType": "TEXT_FIELD", "contentType": "DATA", "validation": "NUMBER",
"required": False, "readOnly": False,
"locations": [{"pageNumber": 1, "top": 516, "left": 106, "width": 150, "height": 24}],
"assignee": "recipient0",
},
{
"name": "Recipient Email",
"inputType": "TEXT_FIELD", "contentType": "SIGNER_EMAIL", "validation": "NONE",
"required": False, "readOnly": True,
"locations": [{"pageNumber": 1, "top": 548, "left": 106, "width": 175, "height": 24}],
"assignee": "recipient0",
},
]
def clean_field(f):
"""Strip server-only keys, keep only what the write API accepts."""
out = {k: v for k, v in f.items() if k in ALLOWED_KEYS}
out.setdefault("validation", "NONE")
out.setdefault("required", False)
out.setdefault("readOnly", False)
out.setdefault("masked", False)
out.setdefault("maskingText", "*")
out.setdefault("calculated", False)
out.setdefault("urlOverridable", False)
out.setdefault("minLength", -1)
out.setdefault("maxLength", -1)
out.setdefault("minValue", -1.0)
out.setdefault("maxValue", -1.0)
out.setdefault("validationErrMsg", "")
out.setdefault("conditionalAction", {"anyOrAll": "ANY", "action": "SHOW"})
return out
def load_source_fields():
with open(FIELDS_JSON_PATH) as f:
data = json.load(f)
fields = [clean_field(field) for field in data["fields"]]
groups = data.get("formFieldGroups", [])
print(f" Loaded {len(fields)} fields from David Tag Demo Form download")
return fields, groups
def upload_transient_doc():
print(f"Uploading PDF: {PDF_PATH}")
with open(PDF_PATH, "rb") as f:
files = {"File": ("Tag Demo Form.pdf", f, "application/pdf")}
result = adobe_api_post_multipart("transientDocuments", files=files)
doc_id = result["transientDocumentId"]
print(f" Transient document ID: {doc_id}")
return doc_id
def create_library_doc(transient_id):
print(f"Creating library document '{TEMPLATE_NAME}'...")
body = {
"fileInfos": [{"transientDocumentId": transient_id}],
"name": TEMPLATE_NAME,
"templateTypes": ["DOCUMENT", "FORM_FIELD_LAYER"],
"sharingMode": "USER",
"state": "ACTIVE",
}
result = adobe_api_post_json("libraryDocuments", body)
lib_id = result["id"]
print(f" Library document ID: {lib_id}")
return lib_id
def put_form_fields(lib_id, source_fields, groups):
all_fields = source_fields + [clean_field(f) for f in EXTRA_FIELDS]
print(f"Writing {len(all_fields)} fields ({len(source_fields)} original + {len(EXTRA_FIELDS)} extra)...")
body = {"fields": all_fields}
if groups:
body["formFieldGroups"] = groups
result = adobe_api_put_json(f"libraryDocuments/{lib_id}/formFields", body)
saved = len(result.get("fields", []))
print(f" {saved} fields saved.")
for field in result.get("fields", []):
print(f" {field['inputType']:15} {field.get('contentType',''):20} '{field['name']}'")
return result
def main():
if not os.path.exists(PDF_PATH):
print(f"ERROR: PDF not found at {PDF_PATH}")
sys.exit(1)
if not os.path.exists(FIELDS_JSON_PATH):
print(f"ERROR: form_fields.json not found at {FIELDS_JSON_PATH}")
sys.exit(1)
source_fields, groups = load_source_fields()
transient_id = upload_transient_doc()
lib_id = create_library_doc(transient_id)
put_form_fields(lib_id, source_fields, groups)
print(f"\nDone. Template '{TEMPLATE_NAME}' created (ID: {lib_id})")
print("Note: If Company/Title fields need their contentType set, do so in Adobe Sign UI.")
if __name__ == "__main__":
main()

436
src/generate_pdfs.py Normal file
View File

@ -0,0 +1,436 @@
"""
generate_pdfs.py
----------------
Generates realistic sample PDFs for adobe-to-docusign migration testing.
Each PDF mirrors the form fields described in the matching *-formfields.json
so that tab positions map to visible labels on the document.
Adobe rect coordinates are top-left origin; DocuSign yPosition is bottom-left.
Formula: docusign_y = PAGE_HEIGHT - adobe_top - adobe_height
To place a *label* just above a field: label_y = PAGE_HEIGHT - adobe_top + 2
"""
import base64
import json
from pathlib import Path
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
W, H = letter # 612 × 792 pt
SAMPLE_DIR = Path(__file__).parent.parent / "sample-templates"
SAMPLE_DIR.mkdir(exist_ok=True)
def save_b64(pdf_path: Path) -> Path:
b64_path = pdf_path.with_suffix(".pdf.b64")
b64_path.write_text(base64.b64encode(pdf_path.read_bytes()).decode())
return b64_path
# ---------------------------------------------------------------------------
# Helper: draw a labelled field box at an Adobe-style rect
# ---------------------------------------------------------------------------
def draw_field(c: canvas.Canvas, label: str, rect: dict, page_h: float = H,
field_hint: str = ""):
left = rect["left"]
top = rect["top"]
width = rect["width"]
height = rect["height"]
# Convert Adobe top-origin to ReportLab bottom-origin
rl_y = page_h - top - height
# Label just above the box
c.setFont("Helvetica", 9)
c.setFillColorRGB(0.3, 0.3, 0.3)
c.drawString(left, rl_y + height + 3, label + (" [" + field_hint + "]" if field_hint else ":"))
# Field box
c.setStrokeColorRGB(0.5, 0.5, 0.5)
c.setFillColorRGB(0.97, 0.97, 1.0)
c.rect(left, rl_y, width, height, fill=1, stroke=1)
c.setFillColorRGB(0, 0, 0)
def draw_radio_option(c: canvas.Canvas, value: str, rect: dict, page_h: float = H):
left = rect["left"]
top = rect["top"]
size = min(rect["width"], rect["height"])
rl_y = page_h - top - size
c.setStrokeColorRGB(0.3, 0.3, 0.3)
c.setFillColorRGB(1, 1, 1)
c.circle(left + size / 2, rl_y + size / 2, size / 2, fill=1, stroke=1)
c.setFont("Helvetica", 9)
c.setFillColorRGB(0, 0, 0)
c.drawString(left + size + 3, rl_y + 1, value)
def draw_checkbox(c: canvas.Canvas, label: str, rect: dict, page_h: float = H):
left = rect["left"]
top = rect["top"]
size = min(rect["width"], rect["height"])
rl_y = page_h - top - size
c.setStrokeColorRGB(0.3, 0.3, 0.3)
c.setFillColorRGB(1, 1, 1)
c.rect(left, rl_y, size, size, fill=1, stroke=1)
c.setFont("Helvetica", 9)
c.setFillColorRGB(0, 0, 0)
c.drawString(left + size + 5, rl_y + 1, label)
def draw_signature_block(c: canvas.Canvas, label: str, rect: dict, page_h: float = H):
left = rect["left"]
top = rect["top"]
width = rect["width"]
height = rect["height"]
rl_y = page_h - top - height
c.setFont("Helvetica", 9)
c.setFillColorRGB(0.3, 0.3, 0.3)
c.drawString(left, rl_y + height + 3, label + ":")
c.setStrokeColorRGB(0.2, 0.4, 0.8)
c.setFillColorRGB(0.93, 0.96, 1.0)
c.rect(left, rl_y, width, height, fill=1, stroke=1)
c.setFillColorRGB(0.2, 0.4, 0.8)
c.setFont("Helvetica-Oblique", 8)
c.drawString(left + 4, rl_y + 4, "Sign here")
c.setFillColorRGB(0, 0, 0)
def section_header(c: canvas.Canvas, text: str, y: float):
c.setFont("Helvetica-Bold", 11)
c.setFillColorRGB(0.1, 0.2, 0.5)
c.drawString(72, y, text)
c.setStrokeColorRGB(0.1, 0.2, 0.5)
c.line(72, y - 3, W - 72, y - 3)
c.setFillColorRGB(0, 0, 0)
# ===========================================================================
# 1. Employee Onboarding Form (2 pages)
# ===========================================================================
def generate_onboarding():
path = SAMPLE_DIR / "onboarding-sample.pdf"
c = canvas.Canvas(str(path), pagesize=letter)
# ---- Page 1: Employee fills ----
c.setFont("Helvetica-Bold", 18)
c.setFillColorRGB(0.1, 0.2, 0.5)
c.drawCentredString(W / 2, H - 60, "Employee Onboarding Form")
c.setFont("Helvetica", 10)
c.setFillColorRGB(0.4, 0.4, 0.4)
c.drawCentredString(W / 2, H - 78, "Please complete all required fields before your first day.")
c.setFillColorRGB(0, 0, 0)
section_header(c, "Personal Information", H - 110)
fields_page1 = json.loads((SAMPLE_DIR / "onboarding-template-formfields.json").read_text())
for f in fields_page1:
locs = f.get("locations", [])
if not locs or locs[0]["pageNumber"] != 1:
continue
rect = locs[0]["rect"]
ft = f["type"]
label = f["fieldName"].replace("Employee", "Employee ").strip()
if ft == "TEXT_FIELD":
draw_field(c, label, rect)
elif ft == "DATE":
draw_field(c, label, rect, field_hint="MM/DD/YYYY")
elif ft == "DROPDOWN":
draw_field(c, label, rect, field_hint=", ".join(f.get("items", [])))
elif ft == "CHECKBOX":
draw_checkbox(c, "Health & Dental Benefits", rect)
elif ft == "RADIO":
# Draw label above first radio
if locs:
first_rect = locs[0]["rect"]
rl_y = H - first_rect["top"] - first_rect["height"]
c.setFont("Helvetica", 9)
c.setFillColorRGB(0.3, 0.3, 0.3)
c.drawString(first_rect["left"], rl_y + first_rect["height"] + 3, "Commute Option:")
c.setFillColorRGB(0, 0, 0)
items = f.get("items", [])
for i, loc in enumerate(locs):
val = items[i] if i < len(items) else str(i)
draw_radio_option(c, val, loc["rect"])
c.setFont("Helvetica", 8)
c.setFillColorRGB(0.5, 0.5, 0.5)
c.drawCentredString(W / 2, 30, "Page 1 of 2 — Employee Onboarding Form")
c.showPage()
# ---- Page 2: HR section + signatures ----
c.setFont("Helvetica-Bold", 14)
c.setFillColorRGB(0.1, 0.2, 0.5)
c.drawCentredString(W / 2, H - 50, "Employee Onboarding Form — Signatures")
c.setFillColorRGB(0, 0, 0)
section_header(c, "HR Notes (Internal)", H - 90)
for f in fields_page1:
locs = f.get("locations", [])
if not locs or locs[0]["pageNumber"] != 2:
continue
rect = locs[0]["rect"]
ft = f["type"]
if ft == "TEXT_FIELD":
draw_field(c, f["fieldName"], rect, field_hint="HR use only")
elif ft == "SIGNATURE":
draw_signature_block(c, f["fieldName"], rect)
section_header(c, "Acknowledgement", H - 260)
c.setFont("Helvetica", 9)
c.setFillColorRGB(0.2, 0.2, 0.2)
disclaimer = (
"By signing below, the employee confirms that all information provided is accurate "
"and that they have read and understood the company policies. HR Representative "
"countersigns to approve onboarding completion."
)
text = c.beginText(72, H - 280)
text.setFont("Helvetica", 9)
text.setFillColor((0.2, 0.2, 0.2))
# Simple word wrap
words = disclaimer.split()
line, lines = [], []
for w in words:
line.append(w)
if len(" ".join(line)) > 72:
lines.append(" ".join(line[:-1]))
line = [w]
if line:
lines.append(" ".join(line))
for ln in lines:
c.drawString(72, text.getY(), ln)
text.moveCursor(0, 12)
c.setFont("Helvetica", 8)
c.setFillColorRGB(0.5, 0.5, 0.5)
c.drawCentredString(W / 2, 30, "Page 2 of 2 — Employee Onboarding Form")
c.save()
b64 = save_b64(path)
print(f"✅ Generated {path.name} + {b64.name}")
# ===========================================================================
# 2. NDA Template (2 pages)
# ===========================================================================
def generate_nda():
path = SAMPLE_DIR / "nda-sample.pdf"
c = canvas.Canvas(str(path), pagesize=letter)
# ---- Page 1: Agreement text ----
c.setFont("Helvetica-Bold", 18)
c.setFillColorRGB(0.1, 0.2, 0.5)
c.drawCentredString(W / 2, H - 60, "Non-Disclosure Agreement")
c.setFont("Helvetica", 10)
c.setFillColorRGB(0.3, 0.3, 0.3)
c.drawCentredString(W / 2, H - 80, "Confidential — Between Employee and Company")
section_header(c, "Agreement Parties", H - 110)
# EmployeeName field (page 1, top=220)
fields = json.loads((SAMPLE_DIR / "nda-template-formfields.json").read_text())
for f in fields:
locs = f.get("locations", [])
if not locs or locs[0]["pageNumber"] != 1:
continue
rect = locs[0]["rect"]
draw_field(c, "Employee Full Name", rect)
# Boilerplate body text
body = [
"This Non-Disclosure Agreement ('Agreement') is entered into as of the date signed",
"below ('Effective Date') by and between the Employee identified above and Acme Corp",
"('Company'), collectively referred to as the 'Parties'.",
"",
"1. CONFIDENTIAL INFORMATION",
" The Employee agrees not to disclose, publish, or make available any Confidential",
" Information (as defined herein) to any third party without the prior written",
" consent of the Company.",
"",
"2. OBLIGATIONS",
" The Employee shall use the Confidential Information solely for the purpose of",
" performing their duties with the Company and shall protect the information with",
" at least the same degree of care used for their own confidential information.",
"",
"3. TERM",
" This Agreement shall remain in effect for a period of two (2) years from the",
" Effective Date and shall survive termination of employment.",
"",
"4. GOVERNING LAW",
" This Agreement shall be governed by the laws of the State of California.",
]
y = H - 260
c.setFont("Helvetica", 9)
c.setFillColorRGB(0.15, 0.15, 0.15)
for line in body:
c.drawString(72, y, line)
y -= 13
c.setFont("Helvetica", 8)
c.setFillColorRGB(0.5, 0.5, 0.5)
c.drawCentredString(W / 2, 30, "Page 1 of 2 — Non-Disclosure Agreement")
c.showPage()
# ---- Page 2: Signature ----
c.setFont("Helvetica-Bold", 14)
c.setFillColorRGB(0.1, 0.2, 0.5)
c.drawCentredString(W / 2, H - 50, "Non-Disclosure Agreement — Execution Page")
section_header(c, "Signatures", H - 100)
c.setFont("Helvetica", 10)
c.setFillColorRGB(0.2, 0.2, 0.2)
c.drawString(72, H - 130, "By signing, the Employee acknowledges reading and agreeing to all terms above.")
for f in fields:
locs = f.get("locations", [])
if not locs or locs[0]["pageNumber"] != 2:
continue
draw_signature_block(c, "Employee Signature", locs[0]["rect"])
# Date line
c.setFont("Helvetica", 9)
c.setFillColorRGB(0.3, 0.3, 0.3)
c.drawString(72, H - 500, "Date: _______________________________")
c.setFont("Helvetica", 8)
c.setFillColorRGB(0.5, 0.5, 0.5)
c.drawCentredString(W / 2, 30, "Page 2 of 2 — Non-Disclosure Agreement")
c.save()
b64 = save_b64(path)
print(f"✅ Generated {path.name} + {b64.name}")
# ===========================================================================
# 3. Sales Agreement (3 pages)
# ===========================================================================
def generate_sales_contract():
path = SAMPLE_DIR / "sales-contract-sample.pdf"
c = canvas.Canvas(str(path), pagesize=letter)
fields = json.loads((SAMPLE_DIR / "sales-contract-formfields.json").read_text())
# ---- Page 1: Terms + PurchasePrice field ----
c.setFont("Helvetica-Bold", 18)
c.setFillColorRGB(0.1, 0.25, 0.1)
c.drawCentredString(W / 2, H - 60, "Sales Agreement")
c.setFont("Helvetica", 10)
c.setFillColorRGB(0.3, 0.3, 0.3)
c.drawCentredString(W / 2, H - 78, "Buyer and Seller — Binding Contract")
section_header(c, "Transaction Details", H - 110)
for f in fields:
locs = f.get("locations", [])
if not locs or locs[0]["pageNumber"] != 1:
continue
rect = locs[0]["rect"]
draw_field(c, "Purchase Price (USD)", rect, field_hint="e.g. 5000.00")
body_p1 = [
"This Sales Agreement ('Agreement') is entered into as of the date of execution",
"by and between the Buyer and the Seller identified on the signature page.",
"",
"1. SALE OF GOODS",
" Seller agrees to sell and Buyer agrees to purchase the goods described in",
" Schedule A (attached) for the Purchase Price stated above.",
"",
"2. PAYMENT TERMS",
" Payment is due in full within 30 days of signing unless otherwise agreed",
" in a separate written addendum signed by both parties.",
"",
"3. DELIVERY",
" Seller shall deliver the goods within 14 days of receipt of payment.",
" Risk of loss transfers to Buyer upon delivery.",
"",
"4. WARRANTIES",
" Seller warrants that it has clear title to the goods and that the goods",
" conform to the specifications in Schedule A.",
"",
"5. DISPUTE RESOLUTION",
" Disputes shall be resolved by binding arbitration under AAA Commercial Rules.",
]
y = H - 210
c.setFont("Helvetica", 9)
c.setFillColorRGB(0.15, 0.15, 0.15)
for line in body_p1:
c.drawString(72, y, line)
y -= 13
c.setFont("Helvetica", 8)
c.setFillColorRGB(0.5, 0.5, 0.5)
c.drawCentredString(W / 2, 30, "Page 1 of 3 — Sales Agreement")
c.showPage()
# ---- Page 2: Schedule A ----
c.setFont("Helvetica-Bold", 14)
c.setFillColorRGB(0.1, 0.25, 0.1)
c.drawCentredString(W / 2, H - 50, "Schedule A — Goods Description")
section_header(c, "Item Detail", H - 90)
body_p2 = [
"Item: [To be completed by Seller]",
"Quantity: [To be completed by Seller]",
"Description: [To be completed by Seller]",
"Unit Price: [To be completed by Seller]",
"",
"Condition: New / Used / Refurbished (circle one)",
"",
"Special Terms:",
" _________________________________________________________",
" _________________________________________________________",
" _________________________________________________________",
]
y = H - 130
c.setFont("Helvetica", 10)
c.setFillColorRGB(0.15, 0.15, 0.15)
for line in body_p2:
c.drawString(72, y, line)
y -= 18
c.setFont("Helvetica", 8)
c.setFillColorRGB(0.5, 0.5, 0.5)
c.drawCentredString(W / 2, 30, "Page 2 of 3 — Sales Agreement")
c.showPage()
# ---- Page 3: Signatures ----
c.setFont("Helvetica-Bold", 14)
c.setFillColorRGB(0.1, 0.25, 0.1)
c.drawCentredString(W / 2, H - 50, "Sales Agreement — Execution Page")
section_header(c, "Authorized Signatures", H - 90)
c.setFont("Helvetica", 9)
c.setFillColorRGB(0.2, 0.2, 0.2)
c.drawString(72, H - 120, "Each party signing below agrees to be bound by all terms and conditions of this Agreement.")
for f in fields:
locs = f.get("locations", [])
if not locs or locs[0]["pageNumber"] != 3:
continue
label = "Buyer Signature" if f["fieldName"] == "BuyerSign" else "Seller Signature"
draw_signature_block(c, label, locs[0]["rect"])
# Printed name / date lines
c.setFont("Helvetica", 9)
c.setFillColorRGB(0.3, 0.3, 0.3)
c.drawString(200, H - 440, "Printed Name: ________________________ Date: __________")
c.drawString(420, H - 440, "Printed Name: ________________________ Date: __________")
c.setFont("Helvetica", 8)
c.setFillColorRGB(0.5, 0.5, 0.5)
c.drawCentredString(W / 2, 30, "Page 3 of 3 — Sales Agreement")
c.save()
b64 = save_b64(path)
print(f"✅ Generated {path.name} + {b64.name}")
if __name__ == "__main__":
generate_onboarding()
generate_nda()
generate_sales_contract()
print("\nAll sample PDFs generated.")

172
src/migrate_template.py Normal file
View File

@ -0,0 +1,172 @@
"""
migrate_template.py
-------------------
End-to-end migration: downloads an Adobe Sign template, converts it to
DocuSign format, and uploads it to DocuSign.
Usage:
python3 src/migrate_template.py --list
Show all Adobe Sign templates available to download.
python3 src/migrate_template.py --template "Template Name"
Download, convert, and upload the named template.
If multiple templates share the same name, the most recently modified
one is used.
python3 src/migrate_template.py --template "Template Name" --skip-upload
Download and convert only writes the DocuSign JSON to
migration-output/ without uploading.
"""
import argparse
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
sys.path.insert(0, os.path.dirname(__file__))
from adobe_api import adobe_api_get, adobe_api_get_bytes
from compose_docusign_template import compose_template
from upload_docusign_template import upload_template
DOWNLOADS_DIR = Path(__file__).parent.parent / "downloads"
OUTPUT_DIR = Path(__file__).parent.parent / "migration-output"
def safe_dirname(name):
return "".join(c if c.isalnum() or c in " -_" else "_" for c in name).strip()
def save_json(path, data):
import json
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
def fetch_template_list():
result = adobe_api_get("libraryDocuments")
return result.get("libraryDocumentList", [])
def cmd_list():
print("Fetching template list from Adobe Sign...")
templates = fetch_template_list()
if not templates:
print("No templates found.")
return
print(f"\n{'Name':<45} {'Modified':<25} {'ID'}")
print("-" * 100)
for t in sorted(templates, key=lambda x: x.get("modifiedDate", ""), reverse=True):
print(f"{t['name']:<45} {t.get('modifiedDate', 'n/a'):<25} {t['id']}")
def find_template(template_name, templates):
matches = [t for t in templates if t["name"] == template_name]
if not matches:
print(f"ERROR: No template named '{template_name}' found in Adobe Sign.")
print("Run --list to see available templates.")
sys.exit(1)
if len(matches) > 1:
print(f" {len(matches)} templates named '{template_name}' — using most recently modified.")
return max(matches, key=lambda t: t.get("modifiedDate", ""))
def download_template(template) -> Path:
import json
template_id = template["id"]
template_name = template["name"]
dir_name = f"{safe_dirname(template_name)}__{template_id[:8]}"
out_dir = DOWNLOADS_DIR / dir_name
out_dir.mkdir(parents=True, exist_ok=True)
print(f"\nDownloading '{template_name}' → downloads/{dir_name}/")
metadata = adobe_api_get(f"libraryDocuments/{template_id}")
save_json(out_dir / "metadata.json", metadata)
try:
form_fields = adobe_api_get(f"libraryDocuments/{template_id}/formFields")
save_json(out_dir / "form_fields.json", form_fields)
field_count = len(form_fields.get("fields", []))
print(f" {field_count} form fields")
except Exception as e:
print(f" WARNING: Could not fetch form fields: {e}")
save_json(out_dir / "form_fields.json", {"error": str(e)})
docs = adobe_api_get(f"libraryDocuments/{template_id}/documents")
save_json(out_dir / "documents.json", docs)
for doc in docs.get("documents", []):
doc_id = doc["id"]
doc_name = doc.get("name", doc_id)
if not doc_name.lower().endswith(".pdf"):
doc_name += ".pdf"
safe_name = safe_dirname(doc_name)
try:
pdf_bytes = adobe_api_get_bytes(
f"libraryDocuments/{template_id}/documents/{doc_id}"
)
with open(out_dir / safe_name, "wb") as f:
f.write(pdf_bytes)
print(f" PDF ({len(pdf_bytes) // 1024}KB) → {safe_name}")
except Exception as e:
print(f" WARNING: Could not download PDF: {e}")
return out_dir
def convert_template(template_dir: Path) -> Path:
output_path = OUTPUT_DIR / template_dir.name / "docusign-template.json"
print(f"\nConverting to DocuSign format...")
_, warnings = compose_template(str(template_dir), str(output_path))
print(f" Written: {output_path}")
for w in warnings:
print(f" WARNING: {w}")
return output_path
def main():
parser = argparse.ArgumentParser(
description="Migrate an Adobe Sign template to DocuSign",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--template", metavar="NAME",
help="Name of the Adobe Sign template to migrate"
)
group.add_argument(
"--list", action="store_true",
help="List available Adobe Sign templates"
)
parser.add_argument(
"--skip-upload", action="store_true",
help="Convert only — do not upload to DocuSign"
)
args = parser.parse_args()
DOWNLOADS_DIR.mkdir(exist_ok=True)
OUTPUT_DIR.mkdir(exist_ok=True)
if args.list:
cmd_list()
return
templates = fetch_template_list()
template = find_template(args.template, templates)
template_dir = download_template(template)
output_path = convert_template(template_dir)
if args.skip_upload:
print(f"\nSkipped upload. DocuSign JSON: {output_path}")
else:
print()
upload_template(str(output_path))
if __name__ == "__main__":
main()