diff --git a/src/create_adobe_template.py b/src/create_adobe_template.py new file mode 100644 index 0000000..1b80693 --- /dev/null +++ b/src/create_adobe_template.py @@ -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=375–432 (between checkboxes and Initials 1), right side (left=350) +# Gap B: y=513–582 (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() diff --git a/src/generate_pdfs.py b/src/generate_pdfs.py new file mode 100644 index 0000000..85823ce --- /dev/null +++ b/src/generate_pdfs.py @@ -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.") diff --git a/src/migrate_template.py b/src/migrate_template.py new file mode 100644 index 0000000..cd57305 --- /dev/null +++ b/src/migrate_template.py @@ -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()