commit 1fd3dc00ae3059f08ee17b776bd6da237ebe5e7c Author: Phil Dore Date: Tue Apr 28 16:15:56 2026 +0100 Initial commit — ADEO Content Maturity Assessment Tool Multi-client maturity dashboard (Express/vanilla JS, port 3102). ADEO client: 8 markets, 7 pillars, 59 questions. Includes data converter, universal PDF summary generator, and new-client wizard for adding future clients. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28d8e8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.env +.env.* +*.log +.DS_Store +.deployed +tmp_uploads/ +clients/*/data.json +clients/adeo/exports/ diff --git a/clients/adeo/Logo.png b/clients/adeo/Logo.png new file mode 100644 index 0000000..b0a7d0b Binary files /dev/null and b/clients/adeo/Logo.png differ diff --git a/clients/adeo/config.json b/clients/adeo/config.json new file mode 100644 index 0000000..5307fbf --- /dev/null +++ b/clients/adeo/config.json @@ -0,0 +1,47 @@ +{ + "id": "adeo", + "name": "ADEO", + "description": "Content Maturity Assessment", + "accent_color": "#00CCDF", + "logo": "logo.png", + "scoring": { + "min": 1, + "max": 4, + "labels": { + "1": "Learner", + "2": "Intermediate", + "3": "Master", + "4": "Expert" + } + }, + "pillars": [ + "OMNICHANNEL", + "CLIENT CENTRICITY", + "MEASUREMENT", + "TECH CAPABILITIES", + "AUTOMATION & INDUSTRIALIZATION", + "INNOVATION", + "ORGANISATION" + ], + "entity_label": "Markets", + "entity_label_singular": "Market", + "about": { + "summary": "A 59-question audit across 7 pillars that assesses how sophisticated each Business Unit is at producing and managing marketing content — telling ADEO exactly where each brand stands and where to focus investment.", + "question_count": 59, + "pillar_descriptions": { + "OMNICHANNEL": "Does content work consistently across TV, social, print, digital, etc.?", + "CLIENT CENTRICITY": "Is content tailored to specific customer types and behaviours?", + "MEASUREMENT": "Do they properly track how content performs?", + "TECH CAPABILITIES": "What tools and platforms do they use (e.g. DAM, DCO, Figma)?", + "AUTOMATION & INDUSTRIALIZATION": "How automated is their content production workflow?", + "INNOVATION": "Are they using AI, testing new formats, and experimenting?", + "ORGANISATION": "How well-structured is the team, processes, and knowledge-sharing?" + }, + "scoring_descriptions": { + "1": "Nothing systematic — things happen on an ad hoc basis or not at all", + "2": "Some capability exists, but it's inconsistent or undocumented", + "3": "Systematic, consistent, and well-evidenced capability", + "4": "Best-in-class — automated, measured, and continuously improved" + } + } +} diff --git a/clients/adeo/deliverables.json b/clients/adeo/deliverables.json new file mode 100644 index 0000000..dcd5316 --- /dev/null +++ b/clients/adeo/deliverables.json @@ -0,0 +1,34 @@ +{ + "BU_LM_FRANCE": { + "pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM France /BU_LM_FRANCE_Maturity_Assessment_Summary_v3.pdf", + "xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM France /BU_LM_FRANCE_Maturity_Scores_Consolidated_v2.xlsx" + }, + "BU_LM_ITALY": { + "pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM Italy /BU_LM_ITALY_Maturity_Assessment_Summary_v2.pdf", + "xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM Italy /BU_LM_ITALY_Maturity_Scores_Consolidated_v1.csv" + }, + "BU_LM_POLAND": { + "pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM Poland /BU_LM_POLAND_Maturity_Assessment_Summary_v3.pdf", + "xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM Poland /BU_LM_POLAND_Maturity_Scores_Consolidated_v2.xlsx" + }, + "BU_LM_SPAIN": { + "pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Spain /BU_LM_SPAIN_Maturity_Assessment_Summary_V5.pdf", + "xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Spain /BU_LM_SPAIN_Maturity_Scores_Consolidated_v4.xlsx" + }, + "BU_LM_PORTUGAL": { + "pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM Portugal /BU_LM_PORTUGAL_Maturity_Assessment_Summary_v3.pdf", + "xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/LM Portugal /BU_LM_PORTUGAL_Maturity_Scores_Consolidated_v6.xlsx" + }, + "BU_M_SPAIN": { + "pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Spain /BU_M_SPAIN_Maturity_Assessment_Summary_v3.pdf", + "xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Spain /BU_M_SPAIN_Maturity_Scores_Consolidated_v2.xlsx" + }, + "BU_M_BRAZIL": { + "pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Brazil /BU_M_BRAZIL_Maturity_Assessment_Summary_R2_v5.pdf", + "xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Brazil /BU_M_BRAZIL_Maturity_Scores_Consolidated_v4.xlsx" + }, + "BU_M_ITALY": { + "pdf": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Italy /BU_M_ITALY_Maturity_Assessment_Summary_v3.pdf", + "xlsx": "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Italy /BU_M_ITALY_Maturity_Scores_Consolidated_v2.xlsx" + } +} diff --git a/convert_data.py b/convert_data.py new file mode 100644 index 0000000..d388c5f --- /dev/null +++ b/convert_data.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +""" +Maturity Tool — Data Converter +Usage: python3 convert_data.py adeo + +Reads source CSVs/XLSXs for the given client and writes: + clients/{client}/data.json +""" + +import csv +import json +import re +import sys +from collections import defaultdict +from datetime import date +from pathlib import Path + +# ── Paths ───────────────────────────────────────────────────────────────────── +SCRIPT_DIR = Path(__file__).parent +BOX_ROOT = Path("/Users/phildore/Library/CloudStorage/Box-Box/ADEO_EXTERNAL_SHARE") + +# ── Score helpers ────────────────────────────────────────────────────────────── +def parse_score(s): + """Extract numeric score from strings like '3', '3.5', '3 (Master)'. Returns None if unparseable.""" + m = re.search(r"\d+(?:\.\d+)?", str(s)) + return float(m.group()) if m else None + +def score_to_level(score, scoring): + """Map a numeric score to 1-based tier index within the configured scale.""" + if score is None: + return None + mn, mx = scoring["min"], scoring["max"] + num_levels = len(scoring["labels"]) + # Evenly divide the scale into num_levels buckets + bucket = (mx - mn) / num_levels + for i in range(1, num_levels + 1): + if score <= mn + bucket * i: + return i + return num_levels + +def level_label(level, scoring): + if level is None: + return "" + return scoring["labels"].get(str(level), "") + +# ── Column normalisation ─────────────────────────────────────────────────────── +def normalise_row(raw, schema): + """Return canonical dict regardless of CSV schema variant.""" + if schema == "A": + q_num = raw.get("Q#", "").strip() + topic = raw.get("Question", "").strip() + pillar = raw.get("Pillar", "").strip().upper() + score_s = raw.get("Score", "") + label = raw.get("Score_Label", "").strip() + rationale = raw.get("Scoring_Rationale", "").strip() + gaps = raw.get("Gaps_Identified", "").strip() + refs = raw.get("Evidence_References", "").strip() + elif schema == "B": + q_num = raw.get("Question_Number", "").strip() + topic = raw.get("Question_Topic", "").strip() + pillar = raw.get("Pillar", "").strip().upper() + score_s = raw.get("Score_Numeric", "") + label = raw.get("Score_Label", "").strip() + rationale = raw.get("Rationale", "").strip() + gaps = raw.get("Gaps", "").strip() + refs = raw.get("References_Used", "").strip() + else: + raise ValueError(f"Unknown schema: {schema}") + + score_num = parse_score(score_s) + return { + "q_num": q_num, + "topic": topic, + "pillar": pillar, + "score": score_num, + "label": label, + "rationale": rationale, + "gaps": gaps, + "refs": refs, + } + +def normalise_xlsx_row(row_dict, schema="B"): + """Same as normalise_row but input already keyed by column header (from openpyxl).""" + return normalise_row(row_dict, schema) + +# ── ADEO source definitions ──────────────────────────────────────────────────── +ADEO_SOURCES = [ + { + "id": "BU_LM_FRANCE", + "label": "Leroy Merlin France", + "short": "LM France", + "group": "Leroy Merlin", + "file": BOX_ROOT / "BU_LM_FRANCE/05_MATURITY/01_OUTPUTS/CSV/BU_LM_FRANCE_Maturity_Consolidated.csv", + "schema": "A", + "xlsx": False, + }, + { + "id": "BU_LM_ITALY", + "label": "Leroy Merlin Italy", + "short": "LM Italy", + "group": "Leroy Merlin", + "file": BOX_ROOT / "BU_LM_ITALY/06_MATURITY_GRID/02_OUTPUTS/V3/CSV/BU_LM_ITALY_Maturity_Scores_Consolidated_v1.csv", + "schema": "B", + "xlsx": False, + }, + { + "id": "BU_LM_POLAND", + "label": "Leroy Merlin Poland", + "short": "LM Poland", + "group": "Leroy Merlin", + "file": BOX_ROOT / "BU_LM_POLAND/05_MATURITY/01_OUTPUTS/CSV/BU_LM_POLAND_Maturity_Consolidated.csv", + "schema": "A", + "xlsx": False, + }, + { + "id": "BU_LM_SPAIN", + "label": "Leroy Merlin Spain", + "short": "LM Spain", + "group": "Leroy Merlin", + "file": BOX_ROOT / "BU_LM_SPAIN/06_MATURITY/01_OUTPUT/CSV/V4/BU_LM_SPAIN_Maturity_Scores_Consolidated_v4.xlsx", + "schema": "B", + "xlsx": True, + }, + { + "id": "BU_LM_PORTUGAL", + "label": "Leroy Merlin Portugal", + "short": "LM Portugal", + "group": "Leroy Merlin", + "file": BOX_ROOT / "BU_LM_PORTUGAL copy/05_MATURITY/02_OUTPUTS/CSV/BU_LM_PORTUGAL_Maturity_Scores_Consolidated_v5.csv", + "schema": "B", + "xlsx": False, + }, + { + "id": "BU_M_SPAIN", + "label": "Obramat Spain", + "short": "Obramat Spain", + "group": "Obramat", + "file": BOX_ROOT / "BU_M_SPAIN/08_ASSESSMENT/02_Round_2/CSV/BU_M_SPAIN_Maturity_Scores_Consolidated_v1.csv", + "schema": "B", + "xlsx": False, + }, + { + "id": "BU_M_BRAZIL", + "label": "Obramax Brazil", + "short": "Obramax Brazil", + "group": "Obramax", + "file": BOX_ROOT / "BU_M_BRAZIL/06_ASSESSMENT/02_Round_2/CSV/BU_M_BRAZIL_Maturity_Scores_Consolidated_v1.csv", + "schema": "B", + "xlsx": False, + }, + { + "id": "BU_M_ITALY", + "label": "Tecnomat Italy", + "short": "Tecnomat Italy", + "group": "Tecnomat", + "file": BOX_ROOT / "BU_M_ITALY/06_ASSESSMENT/02_Round_2/CSV/BU_M_ITALY_Maturity_Scores_Consolidated_v1.csv", + "schema": "B", + "xlsx": False, + }, +] + +# ── Parsers ──────────────────────────────────────────────────────────────────── +def read_csv_rows(path, schema): + rows = [] + with open(path, encoding="utf-8-sig", newline="") as f: + for raw in csv.DictReader(f): + row = normalise_row(raw, schema) + if row["score"] is None: + continue + rows.append(row) + return rows + +def detect_xlsx_schema(headers): + """Pick the right schema based on whichever Q# column name is present.""" + h = [h.lower() for h in headers] + if "question_number" in h: + return "B" + if "q#" in h: + # Spain V4 XLSX — Q#, Question Topic, Score, Level, Rationale, Gaps, References Used + return "XLSX_A" + return "B" + +def normalise_xlsx_a_row(raw): + """Spain V4 XLSX variant: Q#, Question Topic, Score, Level, Rationale, Gaps, References Used.""" + score_s = raw.get("Score", "") + score_num = parse_score(score_s) + return { + "q_num": str(raw.get("Q#", "")).strip(), + "topic": raw.get("Question Topic", "").strip(), + "pillar": raw.get("Pillar", "").strip().upper(), + "score": score_num, + "label": raw.get("Level", "").strip(), + "rationale": raw.get("Rationale", "").strip(), + "gaps": raw.get("Gaps", "").strip(), + "refs": raw.get("References Used", "").strip(), + } + +def read_xlsx_rows(path, schema): + try: + import openpyxl + except ImportError: + print("ERROR: openpyxl required for XLSX files. Run: pip install openpyxl") + sys.exit(1) + + wb = openpyxl.load_workbook(path, read_only=True, data_only=True) + ws = wb.active + headers = None + detected_schema = schema + rows = [] + for excel_row in ws.iter_rows(values_only=True): + if headers is None: + headers = [str(c).strip() if c else "" for c in excel_row] + detected_schema = detect_xlsx_schema(headers) + continue + raw = dict(zip(headers, [str(c).strip() if c is not None else "" for c in excel_row])) + if detected_schema == "XLSX_A": + row = normalise_xlsx_a_row(raw) + else: + row = normalise_xlsx_row(raw, detected_schema) + if row["score"] is None: + continue + rows.append(row) + wb.close() + return rows + +def parse_entity(source, scoring, pillar_order): + path = source["file"] + if not path.exists(): + print(f" WARNING: file not found — {path}") + return None + + rows = read_xlsx_rows(path, source["schema"]) if source["xlsx"] else read_csv_rows(path, source["schema"]) + + by_pillar = defaultdict(list) + for row in rows: + by_pillar[row["pillar"]].append(row) + + pillars_out = [] + all_scores = [] + for pname in pillar_order: + qs = by_pillar.get(pname, []) + if not qs: + continue + scores = [q["score"] for q in qs] + avg = round(sum(scores) / len(scores), 2) + all_scores.extend(scores) + level = score_to_level(avg, scoring) + pillars_out.append({ + "name": pname, + "avg": avg, + "level": level, + "label": level_label(level, scoring), + "questions": [ + { + "q_num": q["q_num"], + "topic": q["topic"], + "score": int(q["score"]) if q["score"] == int(q["score"]) else q["score"], + "level": score_to_level(q["score"], scoring), + "label": q["label"] or level_label(score_to_level(q["score"], scoring), scoring), + "rationale": q["rationale"], + "gaps": q["gaps"], + "refs": q["refs"], + } + for q in qs + ], + }) + + if not all_scores: + print(f" WARNING: no valid scores found for {source['id']}") + return None + + overall = round(sum(all_scores) / len(all_scores), 2) + overall_level = score_to_level(overall, scoring) + return { + "id": source["id"], + "label": source["label"], + "short": source["short"], + "group": source["group"], + "overall_score": overall, + "overall_level": overall_level, + "overall_label": level_label(overall_level, scoring), + "pillars": pillars_out, + } + +# ── Client handlers ──────────────────────────────────────────────────────────── +def build_adeo(config, entity_filter=None): + scoring = config["scoring"] + pillar_order = config["pillars"] + sources = ADEO_SOURCES + if entity_filter: + sources = [s for s in ADEO_SOURCES if s["id"] == entity_filter] + if not sources: + print(f"ERROR: entity '{entity_filter}' not found in ADEO sources") + sys.exit(1) + entities = [] + for source in sources: + print(f" Reading {source['short']}…") + entity = parse_entity(source, scoring, pillar_order) + if entity: + entities.append(entity) + print(f" → {entity['overall_score']} ({entity['overall_label']})") + entities.sort(key=lambda e: e["overall_score"], reverse=True) + return entities + +CLIENT_BUILDERS = { + "adeo": build_adeo, +} + +# ── Main ─────────────────────────────────────────────────────────────────────── +def main(): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("client_id", nargs="?", default="adeo") + parser.add_argument("--entity", default=None, help="Sync only this entity ID") + args = parser.parse_args() + client_id = args.client_id + entity_filter = args.entity + + config_path = SCRIPT_DIR / "clients" / client_id / "config.json" + if not config_path.exists(): + print(f"ERROR: config not found at {config_path}") + sys.exit(1) + + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + + if client_id not in CLIENT_BUILDERS: + print(f"ERROR: no builder registered for client '{client_id}'") + print(f" Available: {list(CLIENT_BUILDERS.keys())}") + sys.exit(1) + + print(f"Building data for client: {config['name']}" + (f" [entity: {entity_filter}]" if entity_filter else "")) + entities = CLIENT_BUILDERS[client_id](config, entity_filter=entity_filter) + + out_path = SCRIPT_DIR / "clients" / client_id / "data.json" + + if entity_filter: + # Merge updated entity into existing data, preserve all others + existing_entities = [] + if out_path.exists(): + with open(out_path, encoding="utf-8") as f: + existing = json.load(f) + existing_entities = existing.get("entities", []) + updated = {e["id"]: e for e in entities} + merged = [updated.get(e["id"], e) for e in existing_entities] + # Add if not already present + existing_ids = {e["id"] for e in existing_entities} + for e in entities: + if e["id"] not in existing_ids: + merged.append(e) + merged.sort(key=lambda e: e["overall_score"], reverse=True) + output = { + "generated": date.today().isoformat(), + "client_id": client_id, + "entities": merged, + } + else: + output = { + "generated": date.today().isoformat(), + "client_id": client_id, + "entities": entities, + } + + with open(out_path, "w", encoding="utf-8") as f: + json.dump(output, f, indent=2, ensure_ascii=False) + + print(f"\n✓ Written {len(output['entities'])} entities → {out_path}") + scores_str = " · ".join(f"{e['short']} {e['overall_score']}" for e in output["entities"]) + print(f" Scores: {scores_str}") + +if __name__ == "__main__": + main() diff --git a/generate_brazil_summary.py b/generate_brazil_summary.py new file mode 100644 index 0000000..1f1abd9 --- /dev/null +++ b/generate_brazil_summary.py @@ -0,0 +1,1004 @@ +#!/usr/bin/env python3 +""" +BU_M_BRAZIL Content Maturity Assessment — Summary PDF Generator +Generates BU_M_BRAZIL_Maturity_Assessment_Summary_R2_v5.pdf +""" + +import re +import os +import shutil +from pathlib import Path + +# ───────────────────────────────────────────── +# PATHS +# ───────────────────────────────────────────── +Q_DIR = Path( + "/Users/phildore/Library/CloudStorage/Box-Box/ADEO_EXTERNAL_SHARE/" + "BU_M_BRAZIL/05_MATURITY/Round_2/00_MASTER" +) +OUTPUT_DIR = Path( + "/Users/phildore/Library/CloudStorage/Box-Box/ADEO_EXTERNAL_SHARE/" + "BU_M_BRAZIL/06_ASSESSMENT/02_Round_2/PDF" +) +DESKTOP_DIR = Path( + "/Users/phildore/Desktop/Adeo _Asset_Production_Summary/M Brazil " +) +OUTPUT_FILENAME = "BU_M_BRAZIL_Maturity_Assessment_Summary_R2_v5.pdf" + +# ───────────────────────────────────────────── +# CONSTANTS +# ───────────────────────────────────────────── +PILLAR_ORDER = [ + "OMNICHANNEL", + "CLIENT CENTRICITY", + "MEASUREMENT", + "TECH CAPABILITIES", + "AUTOMATION & INDUSTRIALIZATION", + "INNOVATION", + "ORGANISATION", +] + +SCORE_LEVEL = {1: "Learner", 2: "Intermediate", 3: "Master", 4: "Expert"} +SCORE_COLOR = { + 1: "#C62828", + 2: "#E65100", + 3: "#2E7D32", + 4: "#1B5E20", +} + +ACCENT = "#00CCDF" # client accent (teal) +NAVY = "#1A2B3C" # header bg +WHITE = "#FFFFFF" +BLACK = "#000000" +BODY_GRAY = "#222222" + +# Q11–Q15 use Main Analysis instead of Strengths/Gaps +MAIN_ANALYSIS_QS = {11, 12, 13, 14, 15} + + +# ───────────────────────────────────────────── +# PARSING +# ───────────────────────────────────────────── +def parse_qfile(path: Path) -> dict: + """Parse a single Question_NN_Analysis.md file.""" + text = path.read_text(encoding="utf-8") + + # ── title / question number ── + m = re.search(r"^#\s+Question\s+(\d+)\s+Analysis:\s*(.+)$", text, re.MULTILINE) + if not m: + raise ValueError(f"Cannot parse title in {path.name}") + q_num = int(m.group(1)) + topic = m.group(2).strip() + + # ── pillar ── + m = re.search(r"\*\*Pillar:\*\*\s*(.+)", text) + pillar = m.group(1).strip() if m else "UNKNOWN" + + # ── score (first occurrence of **Score: N) ── + m = re.search(r"\*\*Score:\s*(\d)", text) + score = int(m.group(1)) if m else 0 + + # ── executive summary ── + m = re.search( + r"##\s+Executive Summary\s*\n(.*?)(?=\n##\s|\Z)", + text, + re.DOTALL, + ) + exec_summary = m.group(1).strip() if m else "" + + # ── strengths / gaps / main-analysis ── + strengths = [] + gaps = [] + + if q_num in MAIN_ANALYSIS_QS: + # Extract numbered bullets from ## Main Analysis + m = re.search( + r"##\s+Main Analysis\s*\n(.*?)(?=\n##\s|\Z)", + text, + re.DOTALL, + ) + if m: + block = m.group(1) + bullets = re.findall( + r"^\d+\.\s+\*\*(.+?):\*\*\s*(.+?)(?=\n\d+\.|\Z)", + block, + re.DOTALL | re.MULTILINE, + ) + # If no numbered bullets, grab h3 section titles + text + if not bullets: + # grab ### subheadings as bullet items + subs = re.findall( + r"###\s+(.+?)\n(.*?)(?=\n###\s|\n##\s|\Z)", + block, + re.DOTALL, + ) + for title, body in subs: + strengths.append((title.strip(), body.strip())) + else: + for title, body in bullets: + strengths.append((title.strip(), body.strip())) + # gaps stay empty for Q11-Q15 + else: + # ── Strengths ── + m = re.search( + r"##\s+Strengths\s*\n(.*?)(?=\n##\s|\Z)", + text, + re.DOTALL, + ) + if m: + block = m.group(1) + bullets = re.findall( + r"^\d+\.\s+\*\*(.+?):\*\*\s*(.+?)(?=\n\d+\.|\Z)", + block, + re.DOTALL | re.MULTILINE, + ) + for title, body in bullets: + strengths.append((title.strip(), body.strip())) + + # ── Gaps (match both "## Gaps" and "## Gaps or Minor Gaps") ── + m = re.search( + r"##\s+Gaps(?:\s+or\s+Minor\s+Gaps)?\s*\n(.*?)(?=\n##\s|\Z)", + text, + re.DOTALL, + ) + if m: + block = m.group(1) + bullets = re.findall( + r"^\d+\.\s+\*\*(.+?):\*\*\s*(.+?)(?=\n\d+\.|\Z)", + block, + re.DOTALL | re.MULTILINE, + ) + for title, body in bullets: + gaps.append((title.strip(), body.strip())) + + return { + "num": q_num, + "topic": topic, + "pillar": pillar, + "score": score, + "level": SCORE_LEVEL.get(score, "Unknown"), + "exec_summary": exec_summary, + "strengths": strengths, + "gaps": gaps, + } + + +def load_all_questions() -> list[dict]: + """Load all 59 Q files (skip *_original* variants).""" + files = sorted( + [ + f for f in Q_DIR.glob("Question_*_Analysis.md") + if "original" not in f.name.lower() + ], + key=lambda f: int(re.search(r"Question_(\d+)_Analysis", f.name).group(1)), + ) + questions = [] + for f in files: + try: + q = parse_qfile(f) + questions.append(q) + except Exception as e: + print(f" WARNING: could not parse {f.name}: {e}") + print(f" Loaded {len(questions)} questions.") + return questions + + +# ───────────────────────────────────────────── +# PDF HELPERS +# ───────────────────────────────────────────── +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import mm +from reportlab.lib import colors +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY, TA_RIGHT +from reportlab.platypus import ( + BaseDocTemplate, + PageTemplate, + Frame, + Paragraph, + Spacer, + PageBreak, + Table, + TableStyle, + KeepTogether, +) +from reportlab.platypus.flowables import HRFlowable +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont + +PAGE_W, PAGE_H = A4 +ML = MR = 22 * mm +MT = MB = 26 * mm +CONTENT_W = PAGE_W - ML - MR + +FOOTER_TEXT = "Obramax Brazil · Content Maturity Assessment · April 2026 · Confidential" + + +def hex_color(h: str): + h = h.lstrip("#") + return colors.HexColor(f"#{h}") + + +def _draw_header_footer(canvas, doc): + """Draw page footer (and page number) on every page.""" + canvas.saveState() + page = canvas.getPageNumber() + + # Footer bar + canvas.setFillColor(hex_color(NAVY)) + canvas.rect(0, 0, PAGE_W, MB * 0.7, fill=1, stroke=0) + + canvas.setFont("Helvetica", 7.5) + canvas.setFillColor(colors.white) + canvas.drawCentredString(PAGE_W / 2, MB * 0.25, FOOTER_TEXT) + + # Page number (top-right, pages 2+) + if page >= 2: + canvas.setFillColor(hex_color(NAVY)) + canvas.setFont("Helvetica-Bold", 8) + canvas.drawRightString(PAGE_W - MR, PAGE_H - MT * 0.6, str(page)) + + canvas.restoreState() + + +# ───────────────────────────────────────────── +# STYLES +# ───────────────────────────────────────────── +def make_styles(): + s = {} + + s["body"] = ParagraphStyle( + "body", + fontName="Helvetica", + fontSize=9, + leading=14, + textColor=hex_color(BODY_GRAY), + alignment=TA_JUSTIFY, + ) + s["body_left"] = ParagraphStyle( + "body_left", + parent=s["body"], + alignment=TA_LEFT, + ) + s["bold"] = ParagraphStyle( + "bold", + parent=s["body"], + fontName="Helvetica-Bold", + ) + s["small"] = ParagraphStyle( + "small", + fontName="Helvetica", + fontSize=7.5, + leading=11, + textColor=hex_color(BODY_GRAY), + alignment=TA_LEFT, + ) + s["cover_title"] = ParagraphStyle( + "cover_title", + fontName="Helvetica-Bold", + fontSize=32, + leading=40, + textColor=hex_color(WHITE), + alignment=TA_LEFT, + spaceAfter=6, + ) + s["cover_subtitle"] = ParagraphStyle( + "cover_subtitle", + fontName="Helvetica", + fontSize=13, + leading=18, + textColor=hex_color(ACCENT), + alignment=TA_LEFT, + ) + s["pillar_heading"] = ParagraphStyle( + "pillar_heading", + fontName="Helvetica-Bold", + fontSize=14, + leading=18, + textColor=hex_color(WHITE), + alignment=TA_LEFT, + ) + s["section_h1"] = ParagraphStyle( + "section_h1", + fontName="Helvetica-Bold", + fontSize=16, + leading=22, + textColor=hex_color(NAVY), + spaceBefore=8, + spaceAfter=6, + alignment=TA_LEFT, + ) + s["q_topic"] = ParagraphStyle( + "q_topic", + fontName="Helvetica-Bold", + fontSize=10, + leading=14, + textColor=hex_color(NAVY), + spaceBefore=10, + spaceAfter=2, + ) + s["exec_summary"] = ParagraphStyle( + "exec_summary", + parent=s["body"], + leftIndent=4, + rightIndent=4, + spaceBefore=2, + spaceAfter=6, + ) + s["bullet_title"] = ParagraphStyle( + "bullet_title", + fontName="Helvetica-Bold", + fontSize=9, + leading=13, + textColor=hex_color(NAVY), + leftIndent=10, + firstLineIndent=-10, + ) + s["bullet_body"] = ParagraphStyle( + "bullet_body", + parent=s["body"], + leftIndent=10, + firstLineIndent=0, + spaceBefore=1, + spaceAfter=4, + ) + s["pillar_label"] = ParagraphStyle( + "pillar_label", + fontName="Helvetica-Bold", + fontSize=8, + leading=12, + textColor=hex_color(ACCENT), + spaceBefore=8, + spaceAfter=2, + ) + s["center"] = ParagraphStyle( + "center", + fontName="Helvetica", + fontSize=9, + leading=13, + textColor=hex_color(BODY_GRAY), + alignment=TA_CENTER, + ) + return s + + +# ───────────────────────────────────────────── +# FLOWABLE HELPERS +# ───────────────────────────────────────────── +def score_badge_table(score: float, label: str, styles, width: float = 90 * mm): + """Return a small inline score badge as a Table.""" + level_int = round(score) + level_int = max(1, min(4, level_int)) + clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2])) + lvl_name = SCORE_LEVEL.get(level_int, "") + + badge_style = ParagraphStyle( + "badge", + fontName="Helvetica-Bold", + fontSize=11, + leading=14, + textColor=colors.white, + alignment=TA_CENTER, + ) + label_style = ParagraphStyle( + "badge_lbl", + fontName="Helvetica", + fontSize=8, + leading=12, + textColor=colors.white, + alignment=TA_CENTER, + ) + + data = [ + [Paragraph(f"{score:.2f}", badge_style)], + [Paragraph(f"{lvl_name}", label_style)], + ] + t = Table(data, colWidths=[28 * mm]) + t.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, -1), clr), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("ROUNDEDCORNERS", [4]), + ] + ) + ) + return t + + +def pillar_header(pillar: str, avg: float, styles) -> list: + """Return flowables for a pillar header bar.""" + level_int = max(1, min(4, round(avg))) + clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2])) + lvl_name = SCORE_LEVEL.get(level_int, "") + + heading_style = ParagraphStyle( + "ph", + fontName="Helvetica-Bold", + fontSize=13, + leading=16, + textColor=colors.white, + ) + score_style = ParagraphStyle( + "ps", + fontName="Helvetica-Bold", + fontSize=13, + leading=16, + textColor=colors.white, + alignment=TA_RIGHT, + ) + + data = [[ + Paragraph(pillar, heading_style), + Paragraph(f"{avg:.2f} — {lvl_name}", score_style), + ]] + col_w = [CONTENT_W * 0.7, CONTENT_W * 0.3] + t = Table(data, colWidths=col_w) + t.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, -1), hex_color(ACCENT)), + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ("LEFTPADDING", (0, 0), (0, -1), 10), + ("RIGHTPADDING", (-1, 0), (-1, -1), 10), + ] + ) + ) + return [t, Spacer(1, 4)] + + +def question_score_table(questions: list[dict], styles) -> Table: + """Build the Q# | Topic | Score | Level table for a pillar.""" + header_style = ParagraphStyle( + "th", + fontName="Helvetica-Bold", + fontSize=8, + leading=11, + textColor=colors.white, + alignment=TA_CENTER, + ) + cell_style = ParagraphStyle( + "td", + fontName="Helvetica", + fontSize=8, + leading=11, + textColor=hex_color(BODY_GRAY), + alignment=TA_LEFT, + ) + cell_center = ParagraphStyle( + "td_c", + parent=cell_style, + alignment=TA_CENTER, + ) + + rows = [[ + Paragraph("Q#", header_style), + Paragraph("Topic", header_style), + Paragraph("Score", header_style), + Paragraph("Level", header_style), + ]] + + for q in questions: + level_int = max(1, min(4, round(q["score"]))) + clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2])) + + score_para = ParagraphStyle( + "sc", + fontName="Helvetica-Bold", + fontSize=8, + leading=11, + textColor=colors.white, + alignment=TA_CENTER, + ) + level_para = ParagraphStyle( + "lv", + fontName="Helvetica-Bold", + fontSize=8, + leading=11, + textColor=colors.white, + alignment=TA_CENTER, + ) + + rows.append([ + Paragraph(str(q["num"]), cell_center), + Paragraph(q["topic"], cell_style), + Paragraph(str(q["score"]), score_para), + Paragraph(q["level"], level_para), + ]) + + col_w = [12 * mm, CONTENT_W - 12 * mm - 20 * mm - 28 * mm, 20 * mm, 28 * mm] + t = Table(rows, colWidths=col_w, repeatRows=1) + + ts = [ + # header row + ("BACKGROUND", (0, 0), (-1, 0), hex_color(NAVY)), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("ALIGN", (0, 0), (-1, 0), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 5), + ("LEFTPADDING", (0, 0), (-1, -1), 5), + ("RIGHTPADDING", (0, 0), (-1, -1), 5), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, hex_color("#F5F5F5")]), + ("GRID", (0, 0), (-1, -1), 0.3, hex_color("#DDDDDD")), + ] + # colour Score and Level cells per question + for i, q in enumerate(questions, start=1): + level_int = max(1, min(4, round(q["score"]))) + clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2])) + ts.append(("BACKGROUND", (2, i), (3, i), clr)) + ts.append(("TEXTCOLOR", (2, i), (3, i), colors.white)) + + t.setStyle(TableStyle(ts)) + return t + + +def cover_pillar_table(pillars_data: list[dict], styles) -> Table: + """Pillar summary table on cover page.""" + header_style = ParagraphStyle( + "th", + fontName="Helvetica-Bold", + fontSize=9, + leading=12, + textColor=colors.white, + ) + cell_style = ParagraphStyle( + "td", + fontName="Helvetica", + fontSize=9, + leading=12, + textColor=hex_color(BODY_GRAY), + ) + cell_center = ParagraphStyle( + "td_c", + parent=cell_style, + alignment=TA_CENTER, + ) + cell_bold = ParagraphStyle( + "td_b", + parent=cell_style, + fontName="Helvetica-Bold", + ) + + rows = [[ + Paragraph("Pillar", header_style), + Paragraph("Qs", header_style), + Paragraph("Avg Score", header_style), + Paragraph("Level", header_style), + Paragraph("Min", header_style), + Paragraph("Max", header_style), + ]] + + for p in pillars_data: + level_int = max(1, min(4, round(p["avg"]))) + clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2])) + score_style = ParagraphStyle( + "sc", + fontName="Helvetica-Bold", + fontSize=9, + leading=12, + textColor=colors.white, + alignment=TA_CENTER, + ) + lvl_style = ParagraphStyle( + "lv", + fontName="Helvetica-Bold", + fontSize=9, + leading=12, + textColor=colors.white, + alignment=TA_CENTER, + ) + rows.append([ + Paragraph(p["name"], cell_bold), + Paragraph(str(p["count"]), cell_center), + Paragraph(f"{p['avg']:.2f}", score_style), + Paragraph(p["level"], lvl_style), + Paragraph(str(p["min"]), cell_center), + Paragraph(str(p["max"]), cell_center), + ]) + + col_w = [ + CONTENT_W * 0.32, + CONTENT_W * 0.08, + CONTENT_W * 0.18, + CONTENT_W * 0.18, + CONTENT_W * 0.12, + CONTENT_W * 0.12, + ] + t = Table(rows, colWidths=col_w, repeatRows=1) + + ts = [ + ("BACKGROUND", (0, 0), (-1, 0), hex_color(NAVY)), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, hex_color("#F0FAFB")]), + ("GRID", (0, 0), (-1, -1), 0.3, hex_color("#CCCCCC")), + ] + for i, p in enumerate(pillars_data, start=1): + level_int = max(1, min(4, round(p["avg"]))) + clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2])) + ts.append(("BACKGROUND", (2, i), (3, i), clr)) + ts.append(("TEXTCOLOR", (2, i), (3, i), colors.white)) + + t.setStyle(TableStyle(ts)) + return t + + +# ───────────────────────────────────────────── +# MAIN BUILD +# ───────────────────────────────────────────── +def build_pdf(questions: list[dict], output_path: Path): + styles = make_styles() + + # ── group questions by pillar ── + pillar_map: dict[str, list[dict]] = {p: [] for p in PILLAR_ORDER} + unrecognised = [] + for q in questions: + matched = False + for p in PILLAR_ORDER: + if q["pillar"].upper().strip() == p: + pillar_map[p].append(q) + matched = True + break + if not matched: + # fuzzy fallback + for p in PILLAR_ORDER: + if p in q["pillar"].upper(): + pillar_map[p].append(q) + matched = True + break + if not matched: + unrecognised.append(q) + print(f" WARNING: Q{q['num']} pillar '{q['pillar']}' not recognised") + + # ── calculate pillar stats ── + def pillar_stats(qs): + if not qs: + return {"avg": 0, "count": 0, "min": 0, "max": 0, "level": "N/A"} + scores = [q["score"] for q in qs] + avg = sum(scores) / len(scores) + level_int = max(1, min(4, round(avg))) + return { + "avg": avg, + "count": len(qs), + "min": min(scores), + "max": max(scores), + "level": SCORE_LEVEL.get(level_int, "Unknown"), + } + + pillars_data = [] + for p in PILLAR_ORDER: + stats = pillar_stats(pillar_map[p]) + stats["name"] = p + stats["questions"] = pillar_map[p] + pillars_data.append(stats) + + # ── overall score ── + all_scores = [q["score"] for q in questions] + overall = sum(all_scores) / len(all_scores) if all_scores else 0 + overall_level_int = max(1, min(4, round(overall))) + overall_level = SCORE_LEVEL.get(overall_level_int, "Unknown") + + print(f" Overall score: {overall:.2f} ({overall_level})") + for p in pillars_data: + print(f" {p['name']}: {p['avg']:.2f} ({p['level']}) — {p['count']} Qs") + + # ── document setup ── + doc = BaseDocTemplate( + str(output_path), + pagesize=A4, + leftMargin=ML, + rightMargin=MR, + topMargin=MT, + bottomMargin=MB, + title="Obramax Brazil — Content Maturity Assessment", + author="Oliver Agency", + ) + + frame = Frame(ML, MB, CONTENT_W, PAGE_H - MT - MB, id="normal") + template = PageTemplate(id="main", frames=[frame], onPage=_draw_header_footer) + doc.addPageTemplates([template]) + + story = [] + + # ══════════════════════════════════════════ + # PAGE 1 — COVER + # ══════════════════════════════════════════ + from reportlab.platypus.flowables import Flowable + + class CoverBanner(Flowable): + def __init__(self, label: str): + super().__init__() + self.label = label + self._v_pad = 18 * mm + self._title_style = ParagraphStyle("ct", fontName="Helvetica-Bold", fontSize=32, + leading=40, textColor=colors.white) + self._sub_style = ParagraphStyle("cs", fontName="Helvetica", fontSize=13, + leading=18, textColor=hex_color(ACCENT)) + + def wrap(self, availWidth, availHeight): + self.width = availWidth + self._tp = Paragraph(self.label, self._title_style) + self._sp = Paragraph( + "Content Maturity Assessment · April 2026 · Confidential", + self._sub_style, + ) + _, self._th = self._tp.wrap(availWidth, 1000) + _, self._sh = self._sp.wrap(availWidth, 1000) + self.banner_h = self._v_pad + self._th + 6 + self._sh + self._v_pad + return (availWidth, self.banner_h) + + def draw(self): + c = self.canv + c.saveState() + c.setFillColor(hex_color(NAVY)) + c.rect(-ML, 0, PAGE_W, self.banner_h, fill=1, stroke=0) + c.restoreState() + sub_y = self._v_pad + title_y = sub_y + self._sh + 6 + self._sp.drawOn(self.canv, 0, sub_y) + self._tp.drawOn(self.canv, 0, title_y) + + class AccentLine(Flowable): + def __init__(self, width=None, thickness=3): + super().__init__() + self.line_width = width or CONTENT_W + self.thickness = thickness + + def draw(self): + self.canv.saveState() + self.canv.setStrokeColor(hex_color(ACCENT)) + self.canv.setLineWidth(self.thickness) + self.canv.line(0, 0, self.line_width, 0) + self.canv.restoreState() + + def wrap(self, *args): + return (self.line_width, self.thickness + 2) + + # Cover title block + story.append(CoverBanner("Obramax Brazil")) + story.append(Spacer(1, 8)) + story.append(AccentLine(CONTENT_W, 3)) + story.append(Spacer(1, 10)) + + # Overall score badge + badge_label_style = ParagraphStyle( + "bl", + fontName="Helvetica-Bold", + fontSize=10, + leading=14, + textColor=hex_color(NAVY), + ) + overall_clr = hex_color(SCORE_COLOR.get(overall_level_int, SCORE_COLOR[2])) + badge_score_style = ParagraphStyle( + "bs", + fontName="Helvetica-Bold", + fontSize=26, + leading=32, + textColor=colors.white, + alignment=TA_CENTER, + ) + badge_level_style = ParagraphStyle( + "bls", + fontName="Helvetica-Bold", + fontSize=11, + leading=14, + textColor=colors.white, + alignment=TA_CENTER, + ) + badge_data = [ + [Paragraph(f"{overall:.2f}", badge_score_style)], + [Paragraph(f"Overall — {overall_level}", badge_level_style)], + [Paragraph("59 questions across 7 pillars", ParagraphStyle( + "bsub", fontName="Helvetica", fontSize=8, leading=11, + textColor=colors.white, alignment=TA_CENTER))], + ] + badge_t = Table(badge_data, colWidths=[55 * mm]) + badge_t.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), overall_clr), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("ROUNDEDCORNERS", [5]), + ])) + + cover_label = Paragraph("Overall Maturity Score", badge_label_style) + badge_row = Table([[cover_label, badge_t]], colWidths=[CONTENT_W - 65 * mm, 65 * mm]) + badge_row.setStyle(TableStyle([ + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("TOPPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 0), + ])) + story.append(badge_row) + story.append(Spacer(1, 12)) + + # Pillar summary table + story.append( + Paragraph("Pillar Summary", ParagraphStyle( + "pt", fontName="Helvetica-Bold", fontSize=12, leading=16, + textColor=hex_color(NAVY), spaceAfter=6)) + ) + story.append(cover_pillar_table(pillars_data, styles)) + story.append(PageBreak()) + + # ══════════════════════════════════════════ + # PAGES 2–N — PER-PILLAR SECTIONS + # ══════════════════════════════════════════ + for pillar in pillars_data: + p_name = pillar["name"] + p_qs = pillar["questions"] + if not p_qs: + continue + + # Pillar header + story.extend(pillar_header(p_name, pillar["avg"], styles)) + + # Q score table + story.append(question_score_table(p_qs, styles)) + story.append(Spacer(1, 10)) + + # Per-question detail + for q in p_qs: + level_int = max(1, min(4, round(q["score"]))) + clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2])) + + # Question header line: chip + topic + chip_style = ParagraphStyle( + "chip", + fontName="Helvetica-Bold", + fontSize=8, + leading=11, + textColor=colors.white, + alignment=TA_CENTER, + ) + topic_style = ParagraphStyle( + "qt", + fontName="Helvetica-Bold", + fontSize=10, + leading=14, + textColor=hex_color(NAVY), + ) + chip_data = [[Paragraph(f"Q{q['num']} {q['score']} {q['level']}", chip_style)]] + chip_t = Table(chip_data, colWidths=[38 * mm]) + chip_t.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), clr), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("ROUNDEDCORNERS", [3]), + ])) + topic_para = Paragraph(q["topic"], topic_style) + q_header = Table( + [[chip_t, topic_para]], + colWidths=[40 * mm, CONTENT_W - 40 * mm], + ) + q_header.setStyle(TableStyle([ + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("TOPPADDING", (0, 0), (-1, -1), 3), + ("BOTTOMPADDING", (0, 0), (-1, -1), 3), + ])) + + # Executive summary — full text, NO truncation + exec_text = q["exec_summary"].replace("**Score:", "Score:").replace("**", "") + # clean up markdown bold markers to XML + exec_text = re.sub(r"\*\*(.+?)\*\*", r"\1", q["exec_summary"]) + exec_text = exec_text.replace("&", "&").replace("", "\x00BOLDOPEN\x00").replace("", "\x00BOLDCLOSE\x00") + exec_text = exec_text.replace("\x00BOLDOPEN\x00", "").replace("\x00BOLDCLOSE\x00", "") + + exec_para = Paragraph(exec_text, styles["exec_summary"]) + + block = KeepTogether([ + Spacer(1, 6), + q_header, + Spacer(1, 3), + exec_para, + HRFlowable(width=CONTENT_W, thickness=0.5, color=hex_color("#DDDDDD"), spaceAfter=4), + ]) + story.append(block) + + story.append(PageBreak()) + + # ══════════════════════════════════════════ + # FINAL SECTION — STRENGTHS & GAPS + # ══════════════════════════════════════════ + story.append(Paragraph("Strengths", styles["section_h1"])) + story.append(AccentLine(CONTENT_W * 0.4, 2)) + story.append(Spacer(1, 8)) + + for pillar in pillars_data: + p_qs_with_strengths = [q for q in pillar["questions"] if q["strengths"]] + if not p_qs_with_strengths: + continue + story.append(Paragraph(pillar["name"], styles["pillar_label"])) + for q in p_qs_with_strengths: + for title, body in q["strengths"]: + # clean body text + body_clean = re.sub(r"\*\*(.+?)\*\*", r"\1", body.strip()) + bullet_text = f"Q{q['num']}: {q['topic']}" + story.append(Paragraph(bullet_text, styles["bullet_title"])) + story.append( + Paragraph(f"{title}: {body_clean}", styles["bullet_body"]) + ) + + story.append(PageBreak()) + story.append(Paragraph("Gaps", styles["section_h1"])) + story.append(AccentLine(CONTENT_W * 0.4, 2)) + story.append(Spacer(1, 8)) + + for pillar in pillars_data: + p_qs_with_gaps = [q for q in pillar["questions"] if q["gaps"]] + if not p_qs_with_gaps: + continue + story.append(Paragraph(pillar["name"], styles["pillar_label"])) + for q in p_qs_with_gaps: + for title, body in q["gaps"]: + body_clean = re.sub(r"\*\*(.+?)\*\*", r"\1", body.strip()) + bullet_text = f"Q{q['num']}: {q['topic']}" + story.append(Paragraph(bullet_text, styles["bullet_title"])) + story.append( + Paragraph(f"{title}: {body_clean}", styles["bullet_body"]) + ) + + # ── build ── + doc.build(story) + + +# ───────────────────────────────────────────── +# ENTRY POINT +# ───────────────────────────────────────────── +if __name__ == "__main__": + print("BU_M_BRAZIL — Maturity Assessment PDF Generator v5") + print("=" * 55) + + # ensure output dirs exist + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + DESKTOP_DIR.mkdir(parents=True, exist_ok=True) + + output_path = OUTPUT_DIR / OUTPUT_FILENAME + + print("\nParsing question files…") + questions = load_all_questions() + + if len(questions) == 0: + print("ERROR: No questions loaded. Check Q_DIR path.") + raise SystemExit(1) + + print(f"\nBuilding PDF → {output_path}") + build_pdf(questions, output_path) + + size_kb = output_path.stat().st_size / 1024 + print(f"\nOutput PDF: {output_path}") + print(f"File size: {size_kb:.1f} KB") + + # copy to Desktop + dest = DESKTOP_DIR / OUTPUT_FILENAME + shutil.copy2(output_path, dest) + print(f"Copied to: {dest}") + print("\nDone.") diff --git a/generate_summary.py b/generate_summary.py new file mode 100644 index 0000000..7f95f96 --- /dev/null +++ b/generate_summary.py @@ -0,0 +1,778 @@ +#!/usr/bin/env python3 +""" +ADEO Content Maturity Assessment — Universal Summary PDF Generator +Usage: python3 generate_summary.py + +Available markets: + BU_LM_FRANCE BU_LM_ITALY BU_LM_POLAND BU_LM_SPAIN + BU_LM_PORTUGAL BU_M_SPAIN BU_M_ITALY +""" + +import re +import sys +import shutil +from pathlib import Path + +# ───────────────────────────────────────────── +# PATHS & CONSTANTS +# ───────────────────────────────────────────── +BOX = Path("/Users/phildore/Library/CloudStorage/Box-Box/ADEO_EXTERNAL_SHARE") +DESKTOP = Path("/Users/phildore/Desktop/Adeo _Asset_Production_Summary") + +PILLAR_ORDER = [ + "OMNICHANNEL", + "CLIENT CENTRICITY", + "MEASUREMENT", + "TECH CAPABILITIES", + "AUTOMATION & INDUSTRIALIZATION", + "INNOVATION", + "ORGANISATION", +] + +SCORE_LEVEL = {1: "Learner", 2: "Intermediate", 3: "Master", 4: "Expert"} +SCORE_COLOR = {1: "#C62828", 2: "#E65100", 3: "#2E7D32", 4: "#1B5E20"} + +ACCENT = "#00CCDF" +NAVY = "#1A2B3C" +WHITE = "#FFFFFF" +BODY_GRAY = "#222222" + +# ───────────────────────────────────────────── +# MARKET CONFIGURATIONS +# ───────────────────────────────────────────── +MARKETS = { + "BU_LM_FRANCE": { + "label": "Leroy Merlin France", + "short": "LM France", + "q_dir": BOX / "BU_LM_FRANCE/05_MATURITY/00_MASTER", + "out_dir": BOX / "BU_LM_FRANCE/05_MATURITY/01_OUTPUTS/PDF", + "desktop_dir": DESKTOP / "LM France ", + "filename": "BU_LM_FRANCE_Maturity_Assessment_Summary_v3.pdf", + "footer": "Leroy Merlin France · Content Maturity Assessment · April 2026 · Confidential", + }, + "BU_LM_ITALY": { + "label": "Leroy Merlin Italy", + "short": "LM Italy", + "q_dir": BOX / "BU_LM_ITALY/06_MATURITY_GRID/00_MASTER", + "out_dir": BOX / "BU_LM_ITALY/06_MATURITY_GRID/02_OUTPUTS/PDF", + "desktop_dir": DESKTOP / "LM Italy ", + "filename": "BU_LM_ITALY_Maturity_Assessment_Summary_v2.pdf", + "footer": "Leroy Merlin Italy · Content Maturity Assessment · April 2026 · Confidential", + }, + "BU_LM_POLAND": { + "label": "Leroy Merlin Poland", + "short": "LM Poland", + "q_dir": BOX / "BU_LM_POLAND/05_MATURITY/00_MASTER", + "out_dir": BOX / "BU_LM_POLAND/05_MATURITY/01_OUTPUTS/PDF", + "desktop_dir": DESKTOP / "LM Poland ", + "filename": "BU_LM_POLAND_Maturity_Assessment_Summary_v3.pdf", + "footer": "Leroy Merlin Poland · Content Maturity Assessment · April 2026 · Confidential", + }, + "BU_LM_SPAIN": { + "label": "Leroy Merlin Spain", + "short": "LM Spain", + "q_dir": BOX / "BU_LM_SPAIN/06_MATURITY/02_VERSION/V4", + "out_dir": BOX / "BU_LM_SPAIN/06_MATURITY/01_OUTPUT/PDF/V4", + "desktop_dir": DESKTOP / "M Spain ", + "filename": "BU_LM_SPAIN_Maturity_Assessment_Summary_V5.pdf", + "footer": "Leroy Merlin Spain · Content Maturity Assessment · April 2026 · Confidential", + }, + "BU_LM_PORTUGAL": { + "label": "Leroy Merlin Portugal", + "short": "LM Portugal", + "q_dir": BOX / "BU_LM_PORTUGAL copy/05_MATURITY/00_MASTER", + "out_dir": BOX / "BU_LM_PORTUGAL copy/05_MATURITY/02_OUTPUTS/PDF/Round_3", + "desktop_dir": DESKTOP / "LM Portugal ", + "filename": "BU_LM_PORTUGAL_Maturity_Assessment_Summary_v3.pdf", + "footer": "Leroy Merlin Portugal · Content Maturity Assessment · April 2026 · Confidential", + }, + "BU_M_SPAIN": { + "label": "Obramat Spain", + "short": "Obramat Spain", + "q_dir": BOX / "BU_M_SPAIN/06_QUESTIONS/MASTER", + "out_dir": BOX / "BU_M_SPAIN/08_ASSESSMENT/02_Round_2/PDF", + "desktop_dir": DESKTOP / "M Spain ", + "filename": "BU_M_SPAIN_Maturity_Assessment_Summary_v3.pdf", + "footer": "Obramat Spain · Content Maturity Assessment · April 2026 · Confidential", + }, + "BU_M_ITALY": { + "label": "Tecnomat Italy", + "short": "Tecnomat Italy", + "q_dir": BOX / "BU_M_ITALY", + "out_dir": BOX / "BU_M_ITALY/06_ASSESSMENT/02_Round_2/PDF", + "desktop_dir": DESKTOP / "M Italy ", + "filename": "BU_M_ITALY_Maturity_Assessment_Summary_v3.pdf", + "footer": "Tecnomat Italy · Content Maturity Assessment · April 2026 · Confidential", + }, +} + + +# ───────────────────────────────────────────── +# FLEXIBLE SECTION PARSERS +# ───────────────────────────────────────────── +def parse_numbered_bullets(block: str) -> list[tuple[str, str]]: + """Extract `1. **Title:** body` style bullets from a text block.""" + bullets = re.findall( + r"^\d+\.\s+\*\*(.+?):\*\*\s*(.+?)(?=\n\d+\.|\Z)", + block, + re.DOTALL | re.MULTILINE, + ) + return [(t.strip(), b.strip()) for t, b in bullets] + + +def extract_strengths(text: str) -> list[tuple[str, str]]: + """Find Strengths bullets from any known section format.""" + # Top-level ## Strengths + m = re.search(r"##\s+Strengths\s*\n(.*?)(?=\n##\s|\Z)", text, re.DOTALL) + if m: + bullets = parse_numbered_bullets(m.group(1)) + if bullets: + return bullets + + # Nested ### Strengths (under a custom analysis section) + m = re.search(r"###\s+Strengths\s*\n(.*?)(?=\n##\s|\n###\s|\Z)", text, re.DOTALL) + if m: + bullets = parse_numbered_bullets(m.group(1)) + if bullets: + return bullets + + # ## Main Analysis used in place of Strengths (LM Spain V4 style) + m = re.search(r"##\s+Main Analysis\s*\n(.*?)(?=\n##\s|\Z)", text, re.DOTALL) + if m: + bullets = parse_numbered_bullets(m.group(1)) + if bullets: + return bullets + # If no numbered bullets, grab ### subheadings as bullet items + subs = re.findall( + r"###\s+(.+?)\n(.*?)(?=\n###\s|\n##\s|\Z)", + m.group(1), + re.DOTALL, + ) + result = [] + for title, body in subs: + body_clean = body.strip() + if body_clean: + result.append((title.strip(), body_clean)) + if result: + return result + + return [] + + +def extract_gaps(text: str) -> list[tuple[str, str]]: + """Find Gaps bullets from any known section format.""" + patterns = [ + r"##\s+Gaps\s+or\s+Minor\s+Gaps\s*\n(.*?)(?=\n##\s|\Z)", + r"##\s+Minor\s+Gaps\s*\n(.*?)(?=\n##\s|\Z)", + r"##\s+Gaps\s*\n(.*?)(?=\n##\s|\Z)", + r"##\s+Gaps\s+Identified\s*\n(.*?)(?=\n##\s|\Z)", + r"###\s+Gaps(?:\s+or\s+Minor\s+Gaps)?\s*\n(.*?)(?=\n##\s|\n###\s|\Z)", + r"###\s+Gaps\s+Identified\s*\n(.*?)(?=\n##\s|\n###\s|\Z)", + ] + for pat in patterns: + m = re.search(pat, text, re.DOTALL) + if m: + bullets = parse_numbered_bullets(m.group(1)) + if bullets: + return bullets + return [] + + +# ───────────────────────────────────────────── +# Q FILE PARSING +# ───────────────────────────────────────────── +def parse_qfile(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + + m = re.search(r"^#\s+Question\s+(\d+)\s+Analysis:\s*(.+)$", text, re.MULTILINE) + if not m: + raise ValueError(f"Cannot parse title in {path.name}") + q_num = int(m.group(1)) + topic = m.group(2).strip() + + m = re.search(r"\*\*Pillar:\*\*\s*(.+)", text) + pillar = m.group(1).strip() if m else "UNKNOWN" + + m = re.search(r"\*\*Score:\s*(\d(?:\.\d+)?)", text) + score = float(m.group(1)) if m else 0.0 + + m = re.search( + r"##\s+Executive Summary\s*\n(.*?)(?=\n##\s|\Z)", + text, + re.DOTALL, + ) + exec_summary = m.group(1).strip() if m else "" + + return { + "num": q_num, + "topic": topic, + "pillar": pillar, + "score": score, + "level": SCORE_LEVEL.get(max(1, min(4, round(score))), "Unknown"), + "exec_summary": exec_summary, + "strengths": extract_strengths(text), + "gaps": extract_gaps(text), + } + + +def load_questions(q_dir: Path) -> list[dict]: + """Load all Q files, deduplicating by question number (keep highest-numbered filename).""" + files = sorted( + [ + f for f in q_dir.glob("Question_*_Analysis.md") + if not any(x in f.name.lower() for x in ("original", "test")) + ], + key=lambda f: int(re.search(r"Question_(\d+)_Analysis", f.name).group(1)), + ) + + # Deduplicate: if two files have the same q_num, last one wins + seen: dict[int, Path] = {} + for f in files: + n = int(re.search(r"Question_(\d+)_Analysis", f.name).group(1)) + seen[n] = f + deduped = sorted(seen.values(), key=lambda f: int(re.search(r"Question_(\d+)_Analysis", f.name).group(1))) + + questions = [] + for f in deduped: + try: + q = parse_qfile(f) + questions.append(q) + except Exception as e: + print(f" WARNING: could not parse {f.name}: {e}") + print(f" Loaded {len(questions)} questions from {q_dir}") + return questions + + +# ───────────────────────────────────────────── +# PDF IMPORTS +# ───────────────────────────────────────────── +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import mm +from reportlab.lib import colors +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY, TA_RIGHT +from reportlab.platypus import ( + BaseDocTemplate, PageTemplate, Frame, Paragraph, Spacer, + PageBreak, Table, TableStyle, KeepTogether, +) +from reportlab.platypus.flowables import HRFlowable, Flowable + +PAGE_W, PAGE_H = A4 +ML = MR = 22 * mm +MT = MB = 26 * mm +CONTENT_W = PAGE_W - ML - MR + + +def hex_color(h: str): + return colors.HexColor(h.lstrip("#") and f"#{h.lstrip('#')}") + + +# ───────────────────────────────────────────── +# PAGE DECORATORS +# ───────────────────────────────────────────── +def make_footer_fn(footer_text: str): + def _draw(canvas, doc): + canvas.saveState() + page = canvas.getPageNumber() + canvas.setFillColor(hex_color(NAVY)) + canvas.rect(0, 0, PAGE_W, MB * 0.7, fill=1, stroke=0) + canvas.setFont("Helvetica", 7.5) + canvas.setFillColor(colors.white) + canvas.drawCentredString(PAGE_W / 2, MB * 0.25, footer_text) + if page >= 2: + canvas.setFillColor(hex_color(NAVY)) + canvas.setFont("Helvetica-Bold", 8) + canvas.drawRightString(PAGE_W - MR, PAGE_H - MT * 0.6, str(page)) + canvas.restoreState() + return _draw + + +# ───────────────────────────────────────────── +# STYLES +# ───────────────────────────────────────────── +def make_styles(): + s = {} + s["body"] = ParagraphStyle("body", fontName="Helvetica", fontSize=9, leading=14, + textColor=hex_color(BODY_GRAY), alignment=TA_JUSTIFY) + s["body_left"] = ParagraphStyle("body_left", parent=s["body"], alignment=TA_LEFT) + s["bold"] = ParagraphStyle("bold", parent=s["body"], fontName="Helvetica-Bold") + s["small"] = ParagraphStyle("small", fontName="Helvetica", fontSize=7.5, leading=11, + textColor=hex_color(BODY_GRAY), alignment=TA_LEFT) + s["cover_title"] = ParagraphStyle("cover_title", fontName="Helvetica-Bold", fontSize=32, + leading=40, textColor=hex_color(WHITE), alignment=TA_LEFT, spaceAfter=6) + s["cover_subtitle"] = ParagraphStyle("cover_subtitle", fontName="Helvetica", fontSize=13, + leading=18, textColor=hex_color(ACCENT), alignment=TA_LEFT) + s["section_h1"] = ParagraphStyle("section_h1", fontName="Helvetica-Bold", fontSize=16, + leading=22, textColor=hex_color(NAVY), spaceBefore=8, spaceAfter=6, alignment=TA_LEFT) + s["exec_summary"] = ParagraphStyle("exec_summary", parent=s["body"], + leftIndent=4, rightIndent=4, spaceBefore=2, spaceAfter=6) + s["bullet_title"] = ParagraphStyle("bullet_title", fontName="Helvetica-Bold", fontSize=9, + leading=13, textColor=hex_color(NAVY), leftIndent=10, firstLineIndent=-10) + s["bullet_body"] = ParagraphStyle("bullet_body", parent=s["body"], + leftIndent=10, firstLineIndent=0, spaceBefore=1, spaceAfter=4) + s["pillar_label"] = ParagraphStyle("pillar_label", fontName="Helvetica-Bold", fontSize=8, + leading=12, textColor=hex_color(ACCENT), spaceBefore=8, spaceAfter=2) + s["center"] = ParagraphStyle("center", fontName="Helvetica", fontSize=9, leading=13, + textColor=hex_color(BODY_GRAY), alignment=TA_CENTER) + return s + + +# ───────────────────────────────────────────── +# FLOWABLE HELPERS +# ───────────────────────────────────────────── +class CoverBanner(Flowable): + """Navy banner that contains the market title and subtitle — fully self-contained.""" + def __init__(self, label: str): + super().__init__() + self.label = label + self._v_pad = 18 * mm + self._title_style = ParagraphStyle("ct", fontName="Helvetica-Bold", fontSize=32, + leading=40, textColor=colors.white) + self._sub_style = ParagraphStyle("cs", fontName="Helvetica", fontSize=13, + leading=18, textColor=hex_color(ACCENT)) + + def wrap(self, availWidth, availHeight): + self.width = availWidth + self._tp = Paragraph(self.label, self._title_style) + self._sp = Paragraph( + "Content Maturity Assessment · April 2026 · Confidential", + self._sub_style, + ) + _, self._th = self._tp.wrap(availWidth, 1000) + _, self._sh = self._sp.wrap(availWidth, 1000) + self.banner_h = self._v_pad + self._th + 6 + self._sh + self._v_pad + return (availWidth, self.banner_h) + + def draw(self): + c = self.canv + c.saveState() + c.setFillColor(hex_color(NAVY)) + c.rect(-ML, 0, PAGE_W, self.banner_h, fill=1, stroke=0) + c.restoreState() + sub_y = self._v_pad + title_y = sub_y + self._sh + 6 + self._sp.drawOn(self.canv, 0, sub_y) + self._tp.drawOn(self.canv, 0, title_y) + + +class AccentLine(Flowable): + def __init__(self, width=None, thickness=3): + super().__init__() + self.line_width = width or CONTENT_W + self.thickness = thickness + + def draw(self): + self.canv.saveState() + self.canv.setStrokeColor(hex_color(ACCENT)) + self.canv.setLineWidth(self.thickness) + self.canv.line(0, 0, self.line_width, 0) + self.canv.restoreState() + + def wrap(self, *args): + return (self.line_width, self.thickness + 2) + + +def pillar_header(pillar: str, avg: float, styles) -> list: + level_int = max(1, min(4, round(avg))) + heading_style = ParagraphStyle("ph", fontName="Helvetica-Bold", fontSize=13, + leading=16, textColor=colors.white) + score_style = ParagraphStyle("ps", fontName="Helvetica-Bold", fontSize=13, + leading=16, textColor=colors.white, alignment=TA_RIGHT) + lvl_name = SCORE_LEVEL.get(level_int, "") + data = [[Paragraph(pillar, heading_style), + Paragraph(f"{avg:.2f} — {lvl_name}", score_style)]] + t = Table(data, colWidths=[CONTENT_W * 0.7, CONTENT_W * 0.3]) + t.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), hex_color(ACCENT)), + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ("LEFTPADDING", (0, 0), (0, -1), 10), + ("RIGHTPADDING", (-1, 0), (-1, -1), 10), + ])) + return [t, Spacer(1, 4)] + + +def question_score_table(questions: list[dict], styles) -> Table: + header_style = ParagraphStyle("th", fontName="Helvetica-Bold", fontSize=8, + leading=11, textColor=colors.white, alignment=TA_CENTER) + cell_style = ParagraphStyle("td", fontName="Helvetica", fontSize=8, + leading=11, textColor=hex_color(BODY_GRAY), alignment=TA_LEFT) + cell_center = ParagraphStyle("td_c", parent=cell_style, alignment=TA_CENTER) + + rows = [[Paragraph("Q#", header_style), Paragraph("Topic", header_style), + Paragraph("Score", header_style), Paragraph("Level", header_style)]] + for q in questions: + level_int = max(1, min(4, round(q["score"]))) + clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2])) + sc_style = ParagraphStyle("sc", fontName="Helvetica-Bold", fontSize=8, + leading=11, textColor=colors.white, alignment=TA_CENTER) + lv_style = ParagraphStyle("lv", fontName="Helvetica-Bold", fontSize=8, + leading=11, textColor=colors.white, alignment=TA_CENTER) + rows.append([ + Paragraph(str(q["num"]), cell_center), + Paragraph(q["topic"], cell_style), + Paragraph(str(int(q["score"]) if q["score"] == int(q["score"]) else q["score"]), sc_style), + Paragraph(q["level"], lv_style), + ]) + col_w = [12 * mm, CONTENT_W - 12 * mm - 20 * mm - 28 * mm, 20 * mm, 28 * mm] + t = Table(rows, colWidths=col_w, repeatRows=1) + ts = [ + ("BACKGROUND", (0, 0), (-1, 0), hex_color(NAVY)), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("ALIGN", (0, 0), (-1, 0), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 5), + ("LEFTPADDING", (0, 0), (-1, -1), 5), + ("RIGHTPADDING", (0, 0), (-1, -1), 5), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, hex_color("#F5F5F5")]), + ("GRID", (0, 0), (-1, -1), 0.3, hex_color("#DDDDDD")), + ] + for i, q in enumerate(questions, start=1): + level_int = max(1, min(4, round(q["score"]))) + clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2])) + ts.append(("BACKGROUND", (2, i), (3, i), clr)) + ts.append(("TEXTCOLOR", (2, i), (3, i), colors.white)) + t.setStyle(TableStyle(ts)) + return t + + +def cover_pillar_table(pillars_data: list[dict], styles) -> Table: + header_style = ParagraphStyle("th", fontName="Helvetica-Bold", fontSize=9, + leading=12, textColor=colors.white) + cell_style = ParagraphStyle("td", fontName="Helvetica", fontSize=9, leading=12, + textColor=hex_color(BODY_GRAY)) + cell_center = ParagraphStyle("td_c", parent=cell_style, alignment=TA_CENTER) + cell_bold = ParagraphStyle("td_b", parent=cell_style, fontName="Helvetica-Bold") + + rows = [[Paragraph("Pillar", header_style), Paragraph("Qs", header_style), + Paragraph("Avg Score", header_style), Paragraph("Level", header_style), + Paragraph("Min", header_style), Paragraph("Max", header_style)]] + for p in pillars_data: + level_int = max(1, min(4, round(p["avg"]))) + sc_style = ParagraphStyle("sc", fontName="Helvetica-Bold", fontSize=9, + leading=12, textColor=colors.white, alignment=TA_CENTER) + lv_style = ParagraphStyle("lv", fontName="Helvetica-Bold", fontSize=9, + leading=12, textColor=colors.white, alignment=TA_CENTER) + rows.append([ + Paragraph(p["name"], cell_bold), + Paragraph(str(p["count"]), cell_center), + Paragraph(f"{p['avg']:.2f}", sc_style), + Paragraph(p["level"], lv_style), + Paragraph(str(int(p["min"]) if p["min"] == int(p["min"]) else p["min"]), cell_center), + Paragraph(str(int(p["max"]) if p["max"] == int(p["max"]) else p["max"]), cell_center), + ]) + col_w = [CONTENT_W * 0.32, CONTENT_W * 0.08, CONTENT_W * 0.18, + CONTENT_W * 0.18, CONTENT_W * 0.12, CONTENT_W * 0.12] + t = Table(rows, colWidths=col_w, repeatRows=1) + ts = [ + ("BACKGROUND", (0, 0), (-1, 0), hex_color(NAVY)), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, hex_color("#F0FAFB")]), + ("GRID", (0, 0), (-1, -1), 0.3, hex_color("#CCCCCC")), + ] + for i, p in enumerate(pillars_data, start=1): + level_int = max(1, min(4, round(p["avg"]))) + clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2])) + ts.append(("BACKGROUND", (2, i), (3, i), clr)) + ts.append(("TEXTCOLOR", (2, i), (3, i), colors.white)) + t.setStyle(TableStyle(ts)) + return t + + +# ───────────────────────────────────────────── +# MAIN PDF BUILD +# ───────────────────────────────────────────── +def build_pdf(questions: list[dict], output_path: Path, market_cfg: dict): + styles = make_styles() + label = market_cfg["label"] + footer_fn = make_footer_fn(market_cfg["footer"]) + + # Group by pillar + pillar_map: dict[str, list[dict]] = {p: [] for p in PILLAR_ORDER} + for q in questions: + matched = False + p_upper = q["pillar"].upper().strip() + for p in PILLAR_ORDER: + if p_upper == p: + pillar_map[p].append(q) + matched = True + break + if not matched: + for p in PILLAR_ORDER: + if p in p_upper: + pillar_map[p].append(q) + matched = True + break + if not matched: + print(f" WARNING: Q{q['num']} pillar '{q['pillar']}' not recognised") + + def pillar_stats(qs): + if not qs: + return {"avg": 0, "count": 0, "min": 0, "max": 0, "level": "N/A"} + scores = [q["score"] for q in qs] + avg = sum(scores) / len(scores) + return { + "avg": avg, + "count": len(qs), + "min": min(scores), + "max": max(scores), + "level": SCORE_LEVEL.get(max(1, min(4, round(avg))), "Unknown"), + } + + pillars_data = [] + for p in PILLAR_ORDER: + stats = pillar_stats(pillar_map[p]) + stats["name"] = p + stats["questions"] = pillar_map[p] + pillars_data.append(stats) + + all_scores = [q["score"] for q in questions] + overall = sum(all_scores) / len(all_scores) if all_scores else 0 + overall_level_int = max(1, min(4, round(overall))) + overall_level = SCORE_LEVEL.get(overall_level_int, "Unknown") + + print(f" Overall score: {overall:.2f} ({overall_level})") + for p in pillars_data: + if p["count"] > 0: + print(f" {p['name']}: {p['avg']:.2f} ({p['level']}) — {p['count']} Qs") + + # Document setup + doc = BaseDocTemplate( + str(output_path), + pagesize=A4, + leftMargin=ML, rightMargin=MR, topMargin=MT, bottomMargin=MB, + title=f"{label} — Content Maturity Assessment", + author="Oliver Agency", + ) + frame = Frame(ML, MB, CONTENT_W, PAGE_H - MT - MB, id="normal") + template = PageTemplate(id="main", frames=[frame], onPage=footer_fn) + doc.addPageTemplates([template]) + + story = [] + + # ── COVER ── + story.append(CoverBanner(label)) + story.append(Spacer(1, 8)) + story.append(AccentLine(CONTENT_W, 3)) + story.append(Spacer(1, 10)) + + # Overall score badge + overall_clr = hex_color(SCORE_COLOR.get(overall_level_int, SCORE_COLOR[2])) + badge_label_style = ParagraphStyle("bl", fontName="Helvetica-Bold", fontSize=10, + leading=14, textColor=hex_color(NAVY)) + badge_score_style = ParagraphStyle("bs", fontName="Helvetica-Bold", fontSize=26, + leading=32, textColor=colors.white, alignment=TA_CENTER) + badge_level_style = ParagraphStyle("bls", fontName="Helvetica-Bold", fontSize=11, + leading=14, textColor=colors.white, alignment=TA_CENTER) + badge_sub_style = ParagraphStyle("bsub", fontName="Helvetica", fontSize=8, + leading=11, textColor=colors.white, alignment=TA_CENTER) + badge_data = [ + [Paragraph(f"{overall:.2f}", badge_score_style)], + [Paragraph(f"Overall — {overall_level}", badge_level_style)], + [Paragraph("59 questions across 7 pillars", badge_sub_style)], + ] + badge_t = Table(badge_data, colWidths=[55 * mm]) + badge_t.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), overall_clr), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("ROUNDEDCORNERS", [5]), + ])) + cover_label = Paragraph("Overall Maturity Score", badge_label_style) + badge_row = Table([[cover_label, badge_t]], colWidths=[CONTENT_W - 65 * mm, 65 * mm]) + badge_row.setStyle(TableStyle([ + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("TOPPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 0), + ])) + story.append(badge_row) + story.append(Spacer(1, 12)) + + story.append(Paragraph("Pillar Summary", ParagraphStyle( + "pt", fontName="Helvetica-Bold", fontSize=12, leading=16, + textColor=hex_color(NAVY), spaceAfter=6))) + story.append(cover_pillar_table(pillars_data, styles)) + story.append(PageBreak()) + + # ── PER-PILLAR SECTIONS ── + for pillar in pillars_data: + p_qs = pillar["questions"] + if not p_qs: + continue + + story.extend(pillar_header(pillar["name"], pillar["avg"], styles)) + story.append(question_score_table(p_qs, styles)) + story.append(Spacer(1, 10)) + + for q in p_qs: + level_int = max(1, min(4, round(q["score"]))) + clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2])) + + chip_style = ParagraphStyle("chip", fontName="Helvetica-Bold", fontSize=8, + leading=11, textColor=colors.white, alignment=TA_CENTER) + topic_style = ParagraphStyle("qt", fontName="Helvetica-Bold", fontSize=10, + leading=14, textColor=hex_color(NAVY)) + + score_disp = int(q["score"]) if q["score"] == int(q["score"]) else q["score"] + chip_data = [[Paragraph(f"Q{q['num']} {score_disp} {q['level']}", chip_style)]] + chip_t = Table(chip_data, colWidths=[38 * mm]) + chip_t.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), clr), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("ROUNDEDCORNERS", [3]), + ])) + topic_para = Paragraph(q["topic"], topic_style) + q_header = Table([[chip_t, topic_para]], colWidths=[40 * mm, CONTENT_W - 40 * mm]) + q_header.setStyle(TableStyle([ + ("ALIGN", (0, 0), (0, 0), "LEFT"), + ("ALIGN", (1, 0), (1, 0), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("TOPPADDING", (0, 0), (-1, -1), 3), + ("BOTTOMPADDING", (0, 0), (-1, -1), 3), + ])) + + exec_text = re.sub(r"\*\*(.+?)\*\*", r"\1", q["exec_summary"]) + exec_para = Paragraph(exec_text, styles["exec_summary"]) + + block = KeepTogether([ + Spacer(1, 6), + q_header, + Spacer(1, 3), + exec_para, + HRFlowable(width=CONTENT_W, thickness=0.5, + color=hex_color("#DDDDDD"), spaceAfter=4), + ]) + story.append(block) + + story.append(PageBreak()) + + # ── STRENGTHS SECTION ── + story.append(Paragraph("Strengths", styles["section_h1"])) + story.append(AccentLine(CONTENT_W * 0.4, 2)) + story.append(Spacer(1, 8)) + + strengths_found = False + for pillar in pillars_data: + p_qs_with_strengths = [q for q in pillar["questions"] if q["strengths"]] + if not p_qs_with_strengths: + continue + strengths_found = True + story.append(Paragraph(pillar["name"], styles["pillar_label"])) + for q in p_qs_with_strengths: + for title, body in q["strengths"]: + body_clean = re.sub(r"\*\*(.+?)\*\*", r"\1", body.strip()) + story.append(Paragraph(f"Q{q['num']}: {q['topic']}", + styles["bullet_title"])) + story.append(Paragraph(f"{title}: {body_clean}", + styles["bullet_body"])) + + if not strengths_found: + story.append(Paragraph("No structured strengths identified in Q files.", + styles["body_left"])) + + story.append(PageBreak()) + + # ── GAPS SECTION ── + story.append(Paragraph("Gaps", styles["section_h1"])) + story.append(AccentLine(CONTENT_W * 0.4, 2)) + story.append(Spacer(1, 8)) + + gaps_found = False + for pillar in pillars_data: + p_qs_with_gaps = [q for q in pillar["questions"] if q["gaps"]] + if not p_qs_with_gaps: + continue + gaps_found = True + story.append(Paragraph(pillar["name"], styles["pillar_label"])) + for q in p_qs_with_gaps: + for title, body in q["gaps"]: + body_clean = re.sub(r"\*\*(.+?)\*\*", r"\1", body.strip()) + story.append(Paragraph(f"Q{q['num']}: {q['topic']}", + styles["bullet_title"])) + story.append(Paragraph(f"{title}: {body_clean}", + styles["bullet_body"])) + + if not gaps_found: + story.append(Paragraph("No structured gaps identified in Q files.", + styles["body_left"])) + + doc.build(story) + + +# ───────────────────────────────────────────── +# ENTRY POINT +# ───────────────────────────────────────────── +def main(): + if len(sys.argv) < 2 or sys.argv[1] not in MARKETS: + print("Usage: python3 generate_summary.py ") + print(f"Markets: {', '.join(MARKETS.keys())}") + sys.exit(1) + + market_id = sys.argv[1] + cfg = MARKETS[market_id] + + print(f"\n{market_id} — Maturity Assessment PDF Generator") + print("=" * 55) + + # Validate Q dir + if not cfg["q_dir"].exists(): + print(f"ERROR: Q directory not found:\n {cfg['q_dir']}") + sys.exit(1) + + # Create output dirs + cfg["out_dir"].mkdir(parents=True, exist_ok=True) + cfg["desktop_dir"].mkdir(parents=True, exist_ok=True) + + output_path = cfg["out_dir"] / cfg["filename"] + + print(f"\nParsing question files from:\n {cfg['q_dir']}") + questions = load_questions(cfg["q_dir"]) + if not questions: + print("ERROR: No questions loaded.") + sys.exit(1) + + # Strengths/gaps summary + s_count = sum(1 for q in questions if q["strengths"]) + g_count = sum(1 for q in questions if q["gaps"]) + print(f" Questions with strengths: {s_count}/{len(questions)}") + print(f" Questions with gaps: {g_count}/{len(questions)}") + + print(f"\nBuilding PDF → {output_path}") + build_pdf(questions, output_path, cfg) + + size_kb = output_path.stat().st_size / 1024 + print(f"\nOutput PDF: {output_path}") + print(f"File size: {size_kb:.1f} KB") + + dest = cfg["desktop_dir"] / cfg["filename"] + shutil.copy2(output_path, dest) + print(f"Copied to: {dest}") + print("\nDone.") + + +if __name__ == "__main__": + main() diff --git a/import_file.py b/import_file.py new file mode 100644 index 0000000..f69e6f6 --- /dev/null +++ b/import_file.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +""" +import_file.py + +Parses a single CSV or XLSX maturity file and outputs JSON: + { overall_score, overall_level, overall_label, pillars: [...] } + +Called by the server's POST /api/clients/:id/import/file endpoint. +Supports Schema A (France/Poland), Schema B (Italy/Portugal/M-markets), +and XLSX_A (Spain V4). +""" + +import csv +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).parent + + +# ── Score helpers ───────────────────────────────────────────────────────────── + +def parse_score(s): + if s is None: + return None + m = re.search(r"\d+(?:\.\d+)?", str(s)) + return float(m.group()) if m else None + + +def score_to_level(score, scoring): + if score is None: + return None + mn, mx = scoring["min"], scoring["max"] + num_levels = len(scoring["labels"]) + bucket = (mx - mn) / num_levels + for i in range(1, num_levels + 1): + if score <= mn + bucket * i: + return i + return num_levels + + +def level_label(level, scoring): + return scoring["labels"].get(str(level), "") if level is not None else "" + + +# ── Schema detection ────────────────────────────────────────────────────────── + +def detect_schema(headers): + h = [x.lower().strip() for x in headers] + if "question_number" in h: + return "B" + if "q#" in h: + # Could be Schema A (CSV) or XLSX_A — disambiguate by looking for 'question topic' + if "question topic" in h: + return "XLSX_A" + return "A" + # Fallback: try B + return "B" + + +# ── Row normalisation ───────────────────────────────────────────────────────── + +def normalise_row(raw, schema): + if schema == "A": + return { + "q_num": str(raw.get("Q#", raw.get("Q #", ""))).strip(), + "topic": str(raw.get("Question", "")).strip(), + "pillar": str(raw.get("Pillar", "")).strip().upper(), + "score": parse_score(raw.get("Score", "")), + "label": str(raw.get("Score_Label", "")).strip(), + "rationale": str(raw.get("Scoring_Rationale", "")).strip(), + "gaps": str(raw.get("Gaps_Identified", "")).strip(), + "refs": str(raw.get("Evidence_References", "")).strip(), + } + if schema == "XLSX_A": + return { + "q_num": str(raw.get("Q#", "")).strip(), + "topic": str(raw.get("Question Topic", "")).strip(), + "pillar": str(raw.get("Pillar", "")).strip().upper(), + "score": parse_score(raw.get("Score", "")), + "label": str(raw.get("Level", "")).strip(), + "rationale": str(raw.get("Rationale", "")).strip(), + "gaps": str(raw.get("Gaps", "")).strip(), + "refs": str(raw.get("References Used", "")).strip(), + } + # Schema B (default) + return { + "q_num": str(raw.get("Question_Number", "")).strip(), + "topic": str(raw.get("Question_Topic", "")).strip(), + "pillar": str(raw.get("Pillar", "")).strip().upper(), + "score": parse_score(raw.get("Score_Numeric", raw.get("Score", ""))), + "label": str(raw.get("Score_Label", "")).strip(), + "rationale": str(raw.get("Rationale", "")).strip(), + "gaps": str(raw.get("Gaps", "")).strip(), + "refs": str(raw.get("References_Used", raw.get("References", ""))).strip(), + } + + +# ── File readers ────────────────────────────────────────────────────────────── + +def read_csv(filepath): + with open(filepath, encoding="utf-8-sig", newline="") as f: + reader = csv.DictReader(f) + headers = reader.fieldnames or [] + rows = list(reader) + return headers, rows + + +def read_xlsx(filepath): + try: + import openpyxl + except ImportError: + raise RuntimeError("openpyxl required for XLSX files. Run: pip install openpyxl") + wb = openpyxl.load_workbook(filepath, read_only=True, data_only=True) + ws = wb.active + headers = None + rows = [] + for excel_row in ws.iter_rows(values_only=True): + if headers is None: + headers = [str(c).strip() if c is not None else "" for c in excel_row] + continue + rows.append(dict(zip(headers, [str(v).strip() if v is not None else "" for v in excel_row]))) + wb.close() + return headers or [], rows + + +def read_file(filepath): + ext = Path(filepath).suffix.lower() + if ext in (".xlsx", ".xls"): + return read_xlsx(filepath) + return read_csv(filepath) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def build_result(client_id, entity_id, filepath): + cfg_path = ROOT / "clients" / client_id / "config.json" + with open(cfg_path) as f: + config = json.load(f) + + scoring = config["scoring"] + pillar_order = config["pillars"] + + headers, raw_rows = read_file(filepath) + schema = detect_schema(headers) + + # Normalise and filter invalid scores + questions = [] + for raw in raw_rows: + row = normalise_row(raw, schema) + if row["score"] is None: + continue + score = int(round(row["score"])) + level = score_to_level(score, scoring) + questions.append({ + "q_num": row["q_num"], + "topic": row["topic"], + "pillar": row["pillar"], + "score": score, + "level": level, + "label": level_label(level, scoring), + "rationale": row["rationale"], + "gaps": row["gaps"], + "refs": row["refs"], + }) + + # Group by pillar in config order + by_pillar = {} + for q in questions: + by_pillar.setdefault(q["pillar"], []).append(q) + + pillars_out = [] + all_scores = [] + for pname in pillar_order: + qs = by_pillar.get(pname, []) + if not qs: + continue + scores = [q["score"] for q in qs] + avg = round(sum(scores) / len(scores), 2) + level = score_to_level(avg, scoring) + all_scores.extend(scores) + pillars_out.append({ + "name": pname, + "avg": avg, + "level": level, + "label": level_label(level, scoring), + "questions": [{k: v for k, v in q.items() if k != "pillar"} for q in qs], + }) + + if not all_scores: + raise ValueError("No valid scores found in the uploaded file. Check the file format and column names.") + + overall = round(sum(all_scores) / len(all_scores), 2) + overall_level = score_to_level(overall, scoring) + return { + "overall_score": overall, + "overall_level": overall_level, + "overall_label": level_label(overall_level, scoring), + "pillars": pillars_out, + } + + +if __name__ == "__main__": + if len(sys.argv) < 4: + print(json.dumps({"error": "Usage: import_file.py "})) + sys.exit(1) + + client_id = sys.argv[1] + entity_id = sys.argv[2] + filepath = sys.argv[3] + + try: + result = build_result(client_id, entity_id, filepath) + print(json.dumps(result)) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/index.html b/index.html new file mode 100644 index 0000000..67b0a79 --- /dev/null +++ b/index.html @@ -0,0 +1,472 @@ + + + + + + Maturity Tool + + + + + + + +
+
+
+ + +
+

Maturity Tool

+

Loading…

+
+
+ +
+
+ + + + + +
+ + +
+
+
+ + + + +
+ + + + + + + + +
+ + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e51a8d4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,943 @@ +{ + "name": "maturity-tool", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "maturity-tool", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2", + "multer": "^2.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fb3f440 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "maturity-tool", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "node server/index.js" + }, + "dependencies": { + "express": "^4.18.2", + "multer": "^2.0.0" + } +} diff --git a/pdf_generator.py b/pdf_generator.py new file mode 100644 index 0000000..a2c4a28 --- /dev/null +++ b/pdf_generator.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +""" +Maturity Tool — PDF Generator +Usage: python3 pdf_generator.py + e.g. python3 pdf_generator.py adeo BU_LM_FRANCE + +Outputs: clients//exports/_Maturity_Report.pdf +""" + +import json +import sys +from datetime import date +from pathlib import Path +from collections import defaultdict + +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import mm +from reportlab.lib import colors +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY +from reportlab.platypus import ( + SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, + HRFlowable, PageBreak, KeepTogether +) + +# ── Paths ────────────────────────────────────────────────────────────────────── +SCRIPT_DIR = Path(__file__).parent + +# ── Score level colours ──────────────────────────────────────────────────────── +LEVEL_COLORS = { + 1: colors.HexColor("#C62828"), + 2: colors.HexColor("#E65100"), + 3: colors.HexColor("#2E7D32"), + 4: colors.HexColor("#1B5E20"), +} + +def level_color(level): + return LEVEL_COLORS.get(level, colors.HexColor("#666666")) + +# ── Page geometry ────────────────────────────────────────────────────────────── +PAGE_W, PAGE_H = A4 +ML = 22*mm; MR = 22*mm; MT = 26*mm; MB = 26*mm +BODY_W = PAGE_W - ML - MR + +# ── Build styles from config ─────────────────────────────────────────────────── +def make_styles(cfg): + C_NAVY = colors.HexColor("#1A2B3C") + accent = cfg.get("accent_color", "#78BE20") + C_ACCENT = colors.HexColor(accent) + C_BODY = colors.HexColor("#222222") + C_MID = colors.HexColor("#666666") + C_WHITE = colors.white + + def S(name, **kw): + d = dict(fontName="Helvetica", fontSize=9, textColor=C_BODY, leading=14, spaceAfter=4) + d.update(kw) + return ParagraphStyle(name, **d) + + ST = { + "title": S("title", fontName="Helvetica-Bold", fontSize=22, leading=28, spaceAfter=6, textColor=C_NAVY), + "sub": S("sub", fontSize=11, textColor=C_MID, leading=16, spaceAfter=2), + "h1": S("h1", fontName="Helvetica-Bold", fontSize=13, leading=17, spaceBefore=14, spaceAfter=6, textColor=C_NAVY), + "h2": S("h2", fontName="Helvetica-Bold", fontSize=10.5, leading=15, spaceBefore=10, spaceAfter=4), + "body": S("body", alignment=TA_JUSTIFY, leading=14), + "body_sm": S("body_sm",fontSize=8.5, leading=13), + "bullet": S("bullet", leftIndent=12, spaceAfter=3, leading=13), + "footer": S("footer", fontSize=7, textColor=C_MID, alignment=TA_CENTER, leading=10), + "center": S("center", alignment=TA_CENTER), + } + return ST, C_NAVY, C_ACCENT, C_BODY, C_MID, C_WHITE + +def esc(t): + return str(t or "").replace("&", "&").replace("<", "<").replace(">", ">") + +# ── Helpers ──────────────────────────────────────────────────────────────────── +def rule(story, sb=6, sa=6, color=None): + story.append(HRFlowable( + width="100%", thickness=0.5, + color=color or colors.HexColor("#DDDDDD"), + spaceBefore=sb, spaceAfter=sa + )) + +def dark_table(data, col_widths, C_NAVY, C_WHITE, C_BODY, + C_LGREY=colors.HexColor("#F7F7F7"), + C_MGREY=colors.HexColor("#CCCCCC")): + ts = TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), C_NAVY), + ("TEXTCOLOR", (0, 0), (-1, 0), C_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), 8.5), + ("ROWBACKGROUND", (0, 1), (-1, -1), [C_WHITE, C_LGREY]), + ("FONTNAME", (0, 1), (-1, -1), "Helvetica"), + ("FONTSIZE", (0, 1), (-1, -1), 8.5), + ("TEXTCOLOR", (0, 1), (-1, -1), C_BODY), + ("GRID", (0, 0), (-1, -1), 0.4, C_MGREY), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ]) + return Table(data, colWidths=col_widths, style=ts, repeatRows=1) + +# ── Page callbacks ───────────────────────────────────────────────────────────── +def make_callbacks(entity, cfg, report_date): + title_str = f"{entity['label']} · {cfg['description']} · Confidential" + C_MID = colors.HexColor("#666666") + C_RULE = colors.HexColor("#DDDDDD") + + def first_page(c, doc): + _footer(c, doc, title_str, C_MID, C_RULE) + + def later_pages(c, doc): + c.saveState() + c.setFont("Helvetica", 7.5) + c.setFillColor(C_MID) + c.drawString(ML, PAGE_H - 14*mm, title_str) + c.drawRightString(PAGE_W - MR, PAGE_H - 14*mm, f"Page {doc.page}") + c.setStrokeColor(C_RULE); c.setLineWidth(0.4) + c.line(ML, PAGE_H - 16*mm, PAGE_W - MR, PAGE_H - 16*mm) + c.restoreState() + _footer(c, doc, title_str, C_MID, C_RULE) + + return first_page, later_pages + +def _footer(c, doc, title_str, C_MID, C_RULE): + c.saveState() + c.setStrokeColor(C_RULE); c.setLineWidth(0.4) + c.line(ML, 14*mm, PAGE_W - MR, 14*mm) + c.setFont("Helvetica", 7); c.setFillColor(C_MID) + c.drawCentredString(PAGE_W / 2, 9*mm, title_str) + c.restoreState() + +# ── Level label from scoring config ─────────────────────────────────────────── +def get_level_label(level, scoring): + return scoring["labels"].get(str(level), str(level)) + +# ── Main generator ───────────────────────────────────────────────────────────── +def generate(client_id, entity_id): + cfg_path = SCRIPT_DIR / "clients" / client_id / "config.json" + data_path = SCRIPT_DIR / "clients" / client_id / "data.json" + out_dir = SCRIPT_DIR / "clients" / client_id / "exports" + out_dir.mkdir(parents=True, exist_ok=True) + + with open(cfg_path, encoding="utf-8") as f: cfg = json.load(f) + with open(data_path, encoding="utf-8") as f: data = json.load(f) + + entity = next((e for e in data["entities"] if e["id"] == entity_id), None) + if not entity: + print(f"ERROR: entity '{entity_id}' not found in {data_path}") + sys.exit(1) + + scoring = cfg["scoring"] + pillar_order = cfg["pillars"] + report_date = date.today().strftime("%B %Y") + out_pdf = out_dir / f"{entity_id}_Maturity_Report.pdf" + + ST, C_NAVY, C_ACCENT, C_BODY, C_MID, C_WHITE = make_styles(cfg) + C_LGREY = colors.HexColor("#F7F7F7") + C_MGREY = colors.HexColor("#CCCCCC") + C_RULE = colors.HexColor("#DDDDDD") + + first_page_cb, later_pages_cb = make_callbacks(entity, cfg, report_date) + + doc = SimpleDocTemplate( + str(out_pdf), pagesize=A4, + topMargin=MT, bottomMargin=MB, leftMargin=ML, rightMargin=MR + ) + + story = [] + + # ── Cover ────────────────────────────────────────────────────────────────── + story.append(Spacer(1, 10*mm)) + story.append(Paragraph(esc(entity["label"]), ST["title"])) + story.append(Paragraph(esc(cfg["description"]), ST["sub"])) + rule(story, 4, 10, C_RULE) + + # Overall score badge table + ov_level = entity.get("overall_level", 1) + ov_label = entity.get("overall_label", "") + ov_color = level_color(ov_level) + score_badge = [[ + Paragraph(f"Overall Score", ParagraphStyle("ol", fontName="Helvetica-Bold", fontSize=9, textColor=C_MID, leading=13)), + Paragraph( + f"{entity['overall_score']:.2f} / {scoring['max']}.00 — {esc(ov_label)}", + ParagraphStyle("os", fontName="Helvetica-Bold", fontSize=13, textColor=C_WHITE, leading=17, alignment=TA_CENTER) + ), + Paragraph(f"Report Date
{esc(report_date)}", ParagraphStyle("rd", fontName="Helvetica", fontSize=9, textColor=C_MID, leading=13, alignment=TA_CENTER)), + ]] + score_ts = TableStyle([ + ("BACKGROUND", (1, 0), (1, 0), ov_color), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING",(0, 0), (-1, -1), 8), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("ALIGN", (1, 0), (1, 0), "CENTER"), + ("ALIGN", (2, 0), (2, 0), "CENTER"), + ]) + story.append(Table(score_badge, colWidths=[50*mm, 80*mm, 36*mm], style=score_ts)) + story.append(Spacer(1, 10*mm)) + + # ── Section 1: Score overview ────────────────────────────────────────────── + rule(story, 4, 0, C_RULE) + story.append(Paragraph("1. Maturity Score Overview", ST["h1"])) + + pillar_summary = [["Pillar", "Questions", "Avg Score", "Level", "Min", "Max"]] + by_pillar = {p["name"]: p for p in entity["pillars"]} + all_scores = [] + + for pname in pillar_order: + p = by_pillar.get(pname) + if not p: + continue + qs = p["questions"] + scores = [q["score"] for q in qs] + all_scores.extend(scores) + pillar_summary.append([ + pname, + str(len(qs)), + f"{p['avg']:.2f}", + get_level_label(p["level"], scoring), + str(min(scores)), + str(max(scores)), + ]) + pillar_summary.append(["OVERALL", str(len(all_scores)), f"{entity['overall_score']:.2f}", ov_label, str(min(all_scores)), str(max(all_scores))]) + + col_w = [70*mm, 22*mm, 24*mm, 28*mm, 14*mm, 14*mm] + ts_p = TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), C_NAVY), + ("TEXTCOLOR", (0, 0), (-1, 0), C_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), 8.5), + ("ROWBACKGROUND", (0, 1), (-1, -2), [colors.white, C_LGREY]), + ("FONTNAME", (0, 1), (-1, -2), "Helvetica"), + ("FONTSIZE", (0, 1), (-1, -2), 8.5), + ("BACKGROUND", (0, -1), (-1, -1), C_NAVY), + ("TEXTCOLOR", (0, -1), (-1, -1), C_WHITE), + ("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"), + ("FONTSIZE", (0, -1), (-1, -1), 8.5), + ("GRID", (0, 0), (-1, -1), 0.4, C_MGREY), + ("ALIGN", (1, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ]) + # Colour each pillar avg cell by level + for i, pname in enumerate(pillar_order, 1): + p = by_pillar.get(pname) + if p: + ts_p.add("BACKGROUND", (2, i), (2, i), level_color(p["level"])) + ts_p.add("TEXTCOLOR", (2, i), (2, i), C_WHITE) + ts_p.add("FONTNAME", (2, i), (2, i), "Helvetica-Bold") + # Overall avg + ts_p.add("BACKGROUND", (2, -1), (2, -1), ov_color) + ts_p.add("TEXTCOLOR", (2, -1), (2, -1), C_WHITE) + + story.append(Table(pillar_summary, colWidths=col_w, style=ts_p, repeatRows=1)) + story.append(Spacer(1, 8*mm)) + + # ── Sections 2+: Per-pillar question tables ──────────────────────────────── + for sec_i, pname in enumerate(pillar_order, 2): + p = by_pillar.get(pname) + if not p: + continue + story.append(PageBreak()) + + # Pillar header with avg badge + plvl = p.get("level", 1) + p_color = level_color(plvl) + plabel = get_level_label(plvl, scoring) + rule(story, 4, 0, C_RULE) + ph = [[ + Paragraph(f"{sec_i}. {esc(pname)}", + ParagraphStyle("ph", fontName="Helvetica-Bold", fontSize=13, textColor=C_BODY, leading=18)), + Paragraph(f"Avg: {p['avg']:.2f} — {esc(plabel)}", + ParagraphStyle("pb", fontName="Helvetica-Bold", fontSize=10, + textColor=C_WHITE, alignment=TA_CENTER, leading=14)), + ]] + ph_ts = TableStyle([ + ("BACKGROUND", (1, 0), (1, 0), p_color), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 5), + ("LEFTPADDING", (0, 0), (0, 0), 0), + ("LEFTPADDING", (1, 0), (1, 0), 6), + ("RIGHTPADDING", (1, 0), (1, 0), 6), + ]) + story.append(Table(ph, colWidths=[BODY_W - 44*mm, 44*mm], style=ph_ts)) + story.append(Spacer(1, 6*mm)) + + # Question score table + q_data = [["Q#", "Question Topic", "Score", "Level"]] + for q in p["questions"]: + q_data.append([ + f"Q{q['q_num']}", + q["topic"][:70] + ("…" if len(q["topic"]) > 70 else ""), + str(q["score"]), + q.get("label", ""), + ]) + q_data.append(["", f"PILLAR AVERAGE — {esc(pname)}", f"{p['avg']:.2f}", ""]) + + q_col = [14*mm, 108*mm, 16*mm, 28*mm] + q_ts = TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), C_NAVY), + ("TEXTCOLOR", (0, 0), (-1, 0), C_WHITE), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), 8.5), + ("ROWBACKGROUND", (0, 1), (-1, -2), [colors.white, C_LGREY]), + ("FONTNAME", (0, 1), (-1, -2), "Helvetica"), + ("FONTSIZE", (0, 1), (-1, -2), 8.5), + ("BACKGROUND", (0, -1), (-1, -1), C_NAVY), + ("TEXTCOLOR", (0, -1), (-1, -1), C_WHITE), + ("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"), + ("FONTSIZE", (0, -1), (-1, -1), 8.5), + ("GRID", (0, 0), (-1, -1), 0.4, C_MGREY), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("ALIGN", (2, 0), (2, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ]) + for i, q in enumerate(p["questions"], 1): + qlvl = q.get("level", 1) + q_ts.add("BACKGROUND", (2, i), (2, i), level_color(qlvl)) + q_ts.add("TEXTCOLOR", (2, i), (2, i), C_WHITE) + q_ts.add("FONTNAME", (2, i), (2, i), "Helvetica-Bold") + q_ts.add("BACKGROUND", (2, -1), (2, -1), p_color) + q_ts.add("TEXTCOLOR", (2, -1), (2, -1), C_WHITE) + + story.append(Table(q_data, colWidths=q_col, style=q_ts, repeatRows=1)) + story.append(Spacer(1, 8*mm)) + + # Rationale excerpts + story.append(Paragraph("Question Rationale", ST["h2"])) + for q in p["questions"]: + if not q.get("rationale"): + continue + qlvl = q.get("level", 1) + qlabel = q.get("label", "") + q_color = level_color(qlvl) + # Score chip + question number + chip = [[ + Paragraph(f"Q{q['q_num']} {q['score']} — {esc(qlabel)}", + ParagraphStyle("qchip", fontName="Helvetica-Bold", fontSize=8.5, + textColor=C_WHITE, leading=12)), + Paragraph(esc(q["topic"]), + ParagraphStyle("qtop", fontName="Helvetica-Bold", fontSize=8.5, + textColor=C_BODY, leading=12)), + ]] + chip_ts = TableStyle([ + ("BACKGROUND", (0, 0), (0, 0), q_color), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("LEFTPADDING", (0, 0), (0, 0), 6), + ("RIGHTPADDING", (0, 0), (0, 0), 6), + ("LEFTPADDING", (1, 0), (1, 0), 8), + ]) + rationale_text = q["rationale"][:500] + ("…" if len(q["rationale"]) > 500 else "") + gaps_text = q.get("gaps", "") + + block = [ + Table(chip, colWidths=[36*mm, BODY_W - 36*mm], style=chip_ts), + Spacer(1, 3), + Paragraph(esc(rationale_text), ST["body_sm"]), + ] + if gaps_text and gaps_text.strip(): + block.append(Paragraph(f"Gaps: {esc(gaps_text[:300])}{'…' if len(gaps_text) > 300 else ''}", ST["body_sm"])) + block.append(Spacer(1, 6)) + story.append(KeepTogether(block)) + + # ── End ──────────────────────────────────────────────────────────────────── + rule(story, 16, 4, C_RULE) + story.append(Paragraph("End of Report", ST["body"])) + + doc.build(story, onFirstPage=first_page_cb, onLaterPages=later_pages_cb) + print(f"✓ {out_pdf} ({out_pdf.stat().st_size / 1024:.0f} KB)") + return str(out_pdf) + + +if __name__ == "__main__": + if len(sys.argv) < 3: + print("Usage: python3 pdf_generator.py ") + print(" e.g. python3 pdf_generator.py adeo BU_LM_FRANCE") + sys.exit(1) + generate(sys.argv[1], sys.argv[2]) diff --git a/script.js b/script.js new file mode 100644 index 0000000..e96d61f --- /dev/null +++ b/script.js @@ -0,0 +1,1425 @@ +'use strict'; + +// ── State ───────────────────────────────────────────────────────────────────── +let allClients = []; +let activeClient = null; // { config, data } +let activeTab = 'entities'; +let detailEntity = null; +const selectedForCompare = new Set(); + +// ── Pillar short labels ─────────────────────────────────────────────────────── +const PILLAR_SHORT = { + 'OMNICHANNEL': 'OMNI', + 'CLIENT CENTRICITY': 'CLIENT', + 'MEASUREMENT': 'MEASURE', + 'TECH CAPABILITIES': 'TECH', + 'AUTOMATION & INDUSTRIALIZATION': 'AUTO', + 'INNOVATION': 'INNOV', + 'ORGANISATION': 'ORG', +}; + +function pillarShort(name) { + return PILLAR_SHORT[name] || name.slice(0, 6).toUpperCase(); +} + +// ── Boot ────────────────────────────────────────────────────────────────────── +async function init() { + applyStoredTheme(); + try { + const res = await fetch('/api/clients'); + allClients = await res.json(); + renderHome(); + } catch (e) { + document.getElementById('headerSub').textContent = 'Could not connect to server'; + showToast('Failed to load data', 'error'); + } +} + +// ── Home screen ─────────────────────────────────────────────────────────────── +function renderHome() { + document.getElementById('homeBtn').style.display = 'none'; + document.getElementById('tabBar').style.display = 'none'; + document.getElementById('homeScreen').style.display = ''; + document.getElementById('clientView').style.display = 'none'; + document.getElementById('headerTitle').textContent = 'Maturity Tool'; + document.getElementById('headerSub').textContent = `${allClients.length} client${allClients.length !== 1 ? 's' : ''}`; + + if (allClients.length === 1) { + const c = allClients[0]; + applyAccent(c.accent_color); + document.getElementById('clientCards').innerHTML = ` +
+
+ ${c.logo + ? `${escHtml(c.name)}` + : `
${escHtml(c.name.slice(0,2).toUpperCase())}
`} +
+

${escHtml(c.name)}

+

${escHtml(c.description)}

+
+
+

+ Click below to explore the maturity scores, pillar breakdowns, and deliverables for all ${c.entity_count} ${escHtml(c.entity_label || 'markets')}. +

+ +

Last updated ${escHtml(c.generated)}

+
+
+
+ +
+

New Client

+

Set up a new maturity assessment client

+
`; + } else { + document.getElementById('clientCards').innerHTML = allClients.map((c, i) => ` +
+
+ ${c.logo + ? `${escHtml(c.name)}` + : `
${escHtml(c.name.slice(0,2).toUpperCase())}
`} + ${c.entity_count} ${escHtml(c.entity_label || 'entities')} +
+

${escHtml(c.name)}

+

${escHtml(c.description)}

+
+ Generated ${escHtml(c.generated)} + Open → +
+
+ `).join('') + ` +
+
+ +
+

New Client

+

Set up a new maturity assessment client

+
`; + } +} + +async function renderSingleClientHome(id) { + try { + const [cfgRes, datRes] = await Promise.all([ + fetch(`/api/clients/${id}/config`), + fetch(`/api/clients/${id}/data`), + ]); + const config = await cfgRes.json(); + const data = await datRes.json(); + + // Cache so entering client view doesn't re-fetch unnecessarily + if (!activeClient || activeClient.config.id !== id) { + const delRes = await fetch(`/api/clients/${id}/deliverables`); + activeClient = { config, data, deliverables: delRes.ok ? await delRes.json() : {} }; + } + + applyAccent(config.accent_color); + + const entities = data.entities; + const scoring = config.scoring; + const scores = entities.map(e => e.overall_score); + const avg = (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2); + const best = entities[0]; + const worst = entities[entities.length - 1]; + const avgLevel = scoreToLevel(Math.round(parseFloat(avg)), scoring); + + const marketRows = entities.map((e, i) => { + const pct = pillarBarPct(e.overall_score); + const lvl = scoreClass(e.overall_level); + return ` +
+ ${i + 1} + ${escHtml(e.short)} +
+
+
+
+ ${e.overall_score.toFixed(2)} +
+ ${escHtml(e.overall_label)} +
`; + }).join(''); + + document.getElementById('clientCards').innerHTML = ` +
+ + +
+
+
+
+
+ ${escHtml(config.name.slice(0,2).toUpperCase())} +
+
+

${escHtml(config.name)}

+

${escHtml(config.description)}

+
+
+
+
+
+
${entities.length}
+
${escHtml(config.entity_label || 'Entities')}
+
+
+
${avg}
+
Avg Score
+
+
+
Highest
+
${escHtml(best.short)}
+
${best.overall_score.toFixed(2)}
+
+
+
Lowest
+
${escHtml(worst.short)}
+
${worst.overall_score.toFixed(2)}
+
+
+
+
+ + +
+

${escHtml(config.entity_label || 'Entities')} Overview

+
+ ${marketRows} +
+

Data generated ${escHtml(data.generated)} · Click any row to view details

+
+ + +
+ + +
+ + ${config.about ? renderAboutSection(config) : ''} + +
+ `; + + // Hover effect on market rows + document.querySelectorAll('.home-market-row').forEach(row => { + row.addEventListener('mouseenter', () => row.style.background = 'var(--bg-inset)'); + row.addEventListener('mouseleave', () => row.style.background = ''); + }); + + } catch (e) { + showToast('Failed to load overview', 'error'); + } +} + +function renderAboutSection(config) { + const about = config.about; + const scoring = config.scoring; + + const pillarGrid = config.pillars.map(pname => { + const desc = (about.pillar_descriptions || {})[pname] || ''; + return ` +
+

${escHtml(pname)}

+

${escHtml(desc)}

+
`; + }).join(''); + + const scoringRows = Object.keys(scoring.labels).sort((a,b) => a-b).map(n => { + const lvl = scoreClass(parseInt(n)); + const desc = (about.scoring_descriptions || {})[n] || ''; + return ` +
+ ${n} — ${escHtml(scoring.labels[n])} + ${escHtml(desc)} +
`; + }).join(''); + + return ` +
+

About the Assessment

+ ${about.summary ? `

${escHtml(about.summary)}

` : ''} + +

+ ${about.question_count ? `${about.question_count} Questions across ` : ''}${config.pillars.length} Pillars +

+
+ ${pillarGrid} +
+ +

Scoring Scale

+
${scoringRows}
+
`; +} + +function goHome() { + activeClient = null; + detailEntity = null; + selectedForCompare.clear(); + renderHome(); +} + +// ── New Client Wizard ───────────────────────────────────────────────────────── +const WIZARD_STEPS = ['Client Info', 'Entity Labels', 'Scoring', 'Pillars', 'Review']; +const ENTITY_PRESETS = [ + { label: 'Markets', singular: 'Market' }, + { label: 'Regions', singular: 'Region' }, + { label: 'Business Units', singular: 'Business Unit' }, + { label: 'Countries', singular: 'Country' }, + { label: 'Brands', singular: 'Brand' }, + { label: 'Teams', singular: 'Team' }, + { label: 'Divisions', singular: 'Division' }, +]; + +let wizStep = 0; +let wizData = {}; + +function wizardReset() { + wizStep = 0; + wizData = { + name: '', id: '', description: '', accent_color: '#6366f1', + entity_label: 'Markets', entity_label_singular: 'Market', + scoring_levels: 4, + scoring_labels: ['Learner', 'Intermediate', 'Master', 'Expert'], + pillars: ['OMNICHANNEL', 'CLIENT CENTRICITY', 'MEASUREMENT', 'TECH CAPABILITIES', + 'AUTOMATION & INDUSTRIALIZATION', 'INNOVATION', 'ORGANISATION'], + }; +} + +function openWizard() { + wizardReset(); + document.getElementById('wizardModal').classList.remove('hidden'); + renderWizardStep(); +} + +function closeWizard() { + document.getElementById('wizardModal').classList.add('hidden'); +} + +function handleWizardOverlayClick(e) { + if (e.target === document.getElementById('wizardModal')) closeWizard(); +} + +function renderWizardDots() { + document.getElementById('wizardDots').innerHTML = WIZARD_STEPS.map((_, i) => ` +
+
+ ${i < WIZARD_STEPS.length - 1 ? `
` : ''} +
+ `).join(''); +} + +function renderWizardStep() { + renderWizardDots(); + document.getElementById('wizardStepLabel').textContent = + `Step ${wizStep + 1} of ${WIZARD_STEPS.length} — ${WIZARD_STEPS[wizStep]}`; + document.getElementById('wizardBackBtn').style.display = + wizStep === 0 ? 'none' : ''; + document.getElementById('wizardNextBtn').textContent = + wizStep === WIZARD_STEPS.length - 1 ? '✓ Create Client' : 'Next →'; + const body = document.getElementById('wizardBody'); + [renderWizStep0, renderWizStep1, renderWizStep2, renderWizStep3, renderWizStep4][wizStep](body); +} + +function wizField(id, label, value, placeholder, extra = '') { + return `
+ + +
`; +} + +function renderWizStep0(body) { + body.innerHTML = ` + ${wizField('wiz-name', 'Client Name *', wizData.name, 'e.g. ACME Corp', 'oninput="wizAutoId()"')} + ${wizField('wiz-id', 'Client ID (auto-generated)', wizData.id, 'e.g. acme-corp')} + ${wizField('wiz-desc', 'Description', wizData.description, 'e.g. Content Maturity Assessment')} +
+ +
+ + + Brand accent colour +
+
`; +} + +function wizAutoId() { + wizData.name = document.getElementById('wiz-name').value; + wizData.id = wizData.name.toLowerCase() + .replace(/[^a-z0-9\s-]/g, '').trim() + .replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); + const el = document.getElementById('wiz-id'); + if (el) el.value = wizData.id; +} + +function renderWizStep1(body) { + const opts = ENTITY_PRESETS.map(p => + `` + ).join(''); + body.innerHTML = ` +
+ + +
+ ${wizField('wiz-entity-label', 'Entity label (plural) *', wizData.entity_label, 'e.g. Markets')} + ${wizField('wiz-entity-singular', 'Entity label (singular)', wizData.entity_label_singular, 'e.g. Market')} +

Used throughout the tool — tab label, summary bar, compare selector.

`; +} + +function applyEntityPreset(label) { + const p = ENTITY_PRESETS.find(x => x.label === label); + if (!p) return; + wizData.entity_label = p.label; + wizData.entity_label_singular = p.singular; + const el = document.getElementById('wiz-entity-label'); + const es = document.getElementById('wiz-entity-singular'); + if (el) el.value = p.label; + if (es) es.value = p.singular; +} + +function renderWizStep2(body) { + const n = wizData.scoring_levels; + const inputs = Array.from({ length: n }, (_, i) => ` +
+ ${i + 1} + +
`).join(''); + body.innerHTML = ` +
+ +
+ ${[2,3,4,5,6].map(v => ` + `).join('')} +
+
+

Level names (lowest → highest)

+
${inputs}
`; +} + +function setWizardLevels(n) { + const defaults = ['Beginner', 'Developing', 'Proficient', 'Expert', 'Advanced', 'World Class']; + while (wizData.scoring_labels.length < n) + wizData.scoring_labels.push(defaults[wizData.scoring_labels.length] || `Level ${wizData.scoring_labels.length + 1}`); + wizData.scoring_labels = wizData.scoring_labels.slice(0, n); + wizData.scoring_levels = n; + renderWizardStep(); +} + +function renderWizStep3(body) { + const rows = wizData.pillars.map((p, i) => ` +
+ + +
`).join(''); + body.innerHTML = ` +
${rows}
+ +

Pre-filled with ADEO's 7 pillars as a starting point — modify or replace as needed.

`; +} + +function addWizPillar() { wizData.pillars.push(''); renderWizardStep(); } +function removeWizPillar(i) { wizData.pillars.splice(i, 1); renderWizardStep(); } + +function renderWizStep4(body) { + body.innerHTML = ` +

Review your configuration. Go back to edit any step.

+
${escHtml(JSON.stringify(buildWizardConfig(), null, 2))}
`; +} + +function buildWizardConfig() { + const n = wizData.scoring_levels; + const labels = {}; + for (let i = 0; i < n; i++) labels[String(i + 1)] = wizData.scoring_labels[i] || `Level ${i + 1}`; + return { + id: wizData.id, + name: wizData.name, + description: wizData.description, + accent_color: wizData.accent_color, + scoring: { min: 1, max: n, labels }, + pillars: wizData.pillars.filter(p => p.trim()), + entity_label: wizData.entity_label, + entity_label_singular: wizData.entity_label_singular, + }; +} + +function wizardSaveStep() { + if (wizStep === 0) { + wizData.name = document.getElementById('wiz-name')?.value?.trim() || wizData.name; + wizData.id = document.getElementById('wiz-id')?.value?.trim() || wizData.id; + wizData.description = document.getElementById('wiz-desc')?.value?.trim() || wizData.description; + wizData.accent_color = document.getElementById('wiz-color')?.value || wizData.accent_color; + } else if (wizStep === 1) { + wizData.entity_label = document.getElementById('wiz-entity-label')?.value?.trim() || wizData.entity_label; + wizData.entity_label_singular = document.getElementById('wiz-entity-singular')?.value?.trim() || wizData.entity_label_singular; + } else if (wizStep === 3) { + document.querySelectorAll('#wizPillarList input').forEach((inp, i) => { + wizData.pillars[i] = inp.value.toUpperCase(); + }); + wizData.pillars = wizData.pillars.filter(p => p.trim()); + } +} + +function wizardValidate() { + if (wizStep === 0 && !wizData.name) { showToast('Client name is required', 'error'); return false; } + if (wizStep === 0 && !wizData.id) { showToast('Could not derive a client ID from that name', 'error'); return false; } + if (wizStep === 3 && !wizData.pillars.filter(p => p.trim()).length) { + showToast('Add at least one pillar', 'error'); return false; + } + return true; +} + +function wizardNext() { + wizardSaveStep(); + if (!wizardValidate()) return; + if (wizStep === WIZARD_STEPS.length - 1) { createClient(); return; } + wizStep++; + renderWizardStep(); +} + +function wizardBack() { + wizardSaveStep(); + if (wizStep > 0) { wizStep--; renderWizardStep(); } +} + +async function createClient() { + const cfg = buildWizardConfig(); + const btn = document.getElementById('wizardNextBtn'); + btn.disabled = true; btn.textContent = 'Creating…'; + try { + const res = await fetch('/api/clients', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cfg), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || res.statusText); + closeWizard(); + showToast(`"${cfg.name}" created! Set up data in the Update tab.`, 'success'); + const r2 = await fetch('/api/clients'); + allClients = await r2.json(); + await loadClient(data.id); + showTab('update'); + } catch (e) { + showToast('Failed to create: ' + e.message, 'error'); + btn.disabled = false; btn.textContent = '✓ Create Client'; + } +} + +// ── Load client ─────────────────────────────────────────────────────────────── +async function loadClient(id) { + try { + const [cfgRes, datRes, delRes] = await Promise.all([ + fetch(`/api/clients/${id}/config`), + fetch(`/api/clients/${id}/data`), + fetch(`/api/clients/${id}/deliverables`), + ]); + const config = await cfgRes.json(); + const data = await datRes.json(); + const deliverables = delRes.ok ? await delRes.json() : {}; + activeClient = { config, data, deliverables }; + + applyAccent(config.accent_color); + enterClientView(config); + renderSummaryBar(); + renderEntityCards(); + renderCompareSelector(); + document.getElementById('exportRow').style.display = 'flex'; + } catch (e) { + showToast('Failed to load client data', 'error'); + } +} + +function applyAccent(color) { + document.documentElement.style.setProperty('--accent', color); +} + +function enterClientView(config) { + if (config.logo) { + document.getElementById('headerTitle').innerHTML = `${escHtml(config.name)}`; + } else { + document.getElementById('headerTitle').textContent = config.name; + } + document.getElementById('headerSub').textContent = config.description; + document.getElementById('homeBtn').style.display = ''; + document.getElementById('tabBar').style.display = ''; + document.getElementById('homeScreen').style.display = 'none'; + document.getElementById('clientView').style.display = ''; + + // Update tab label to match entity type + const entityLabel = config.entity_label || 'Entities'; + document.getElementById('tab-entities').textContent = entityLabel; + + showTab('entities'); +} + +// ── Tab navigation ──────────────────────────────────────────────────────────── +function showTab(name) { + ['entities', 'compare', 'update'].forEach(t => { + document.getElementById(`tab-${t}-content`).style.display = t === name ? '' : 'none'; + document.getElementById(`tab-${t}`).classList.toggle('active', t === name); + }); + activeTab = name; + if (name === 'update') populateImportEntitySelect(); +} + +// ── Summary bar ─────────────────────────────────────────────────────────────── +function renderSummaryBar() { + const entities = activeClient.data.entities; + const scores = entities.map(e => e.overall_score); + const avg = (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2); + const best = entities[0]; + const worst = entities[entities.length - 1]; + const entityLabel = activeClient.config.entity_label || 'Entities'; + + document.getElementById('summaryBar').innerHTML = ` +
+
+
${entities.length}
+
${escHtml(entityLabel)}
+
+
+
${avg}
+
Avg Score
+
+
+
Highest
+
${escHtml(best.short)}
+
${best.overall_score.toFixed(2)}
+
+
+
Lowest
+
${escHtml(worst.short)}
+
${worst.overall_score.toFixed(2)}
+
+
+ Data: ${escHtml(activeClient.data.generated)} +
+
+ `; +} + +// ── Score helpers ───────────────────────────────────────────────────────────── +function scoreClass(level) { + return `score-${Math.max(1, Math.min(4, level || 1))}`; +} + +function scoreBgClass(level) { + return `score-bg-${Math.max(1, Math.min(4, level || 1))}`; +} + +function pillarBarPct(avg) { + if (!activeClient) return 0; + const { min, max } = activeClient.config.scoring; + return Math.max(0, Math.min(100, ((avg - min) / (max - min)) * 100)); +} + +// ── Entity cards ────────────────────────────────────────────────────────────── +function renderEntityCards() { + document.getElementById('entityGrid').style.display = ''; + document.getElementById('detailPanel').style.display = 'none'; + + const entities = activeClient.data.entities; + const pillars = activeClient.config.pillars; + + document.getElementById('entityGrid').innerHTML = entities.map((entity, i) => { + const lvlCls = scoreClass(entity.overall_level); + const bgCls = scoreBgClass(entity.overall_level); + + const pillarBars = pillars.map(pname => { + const p = entity.pillars.find(x => x.name === pname); + if (!p) return ''; + const pct = pillarBarPct(p.avg).toFixed(1); + const short = pillarShort(pname); + return ` +
+ ${escHtml(short)} +
+ ${p.avg.toFixed(1)} +
`; + }).join(''); + + return ` +
+
+ ${escHtml(entity.group)} + #${i + 1} +
+
+

${escHtml(entity.label)}

+
+ ${entity.overall_score.toFixed(2)} + ${escHtml(entity.overall_label)} +
+
+
${pillarBars}
+
+ ${entity.pillars.reduce((s, p) => s + p.questions.length, 0)} questions + View detail → +
+
`; + }).join(''); + + document.querySelectorAll('.entity-card').forEach(card => { + card.addEventListener('click', () => { + const entity = activeClient.data.entities.find(e => e.id === card.dataset.id); + if (entity) openEntityDetail(entity); + }); + }); +} + +// ── Entity detail ───────────────────────────────────────────────────────────── +function openEntityDetail(entity) { + detailEntity = entity; + document.getElementById('entityGrid').style.display = 'none'; + const panel = document.getElementById('detailPanel'); + panel.style.display = ''; + + const pillars = activeClient.config.pillars; + const lvlCls = scoreClass(entity.overall_level); + const bgCls = scoreBgClass(entity.overall_level); + + const pillarAccordions = pillars.map(pname => { + const p = entity.pillars.find(x => x.name === pname); + if (!p) return ''; + const plvl = scoreClass(p.level); + const pbg = scoreBgClass(p.level); + const pct = pillarBarPct(p.avg).toFixed(1); + + const qRows = p.questions.map((q, qi) => { + const qlvl = scoreClass(q.level); + return ` +
+ ${q.score} + ${escHtml(q.topic)} + +
`; + }).join(''); + + return ` +
+
+
+ ${escHtml(pname)} + +
+
+
+ ${p.avg.toFixed(2)} + ${escHtml(p.label)} +
+ +
+
+
${qRows}
+
`; + }).join(''); + + // Deliverable buttons (if configured for this entity) + const del = (activeClient.deliverables || {})[entity.id] || {}; + const deliverablesBtns = (del.pdf || del.xlsx) ? ` + Deliverables + ${del.pdf ? ` + ` : ''} + ${del.xlsx ? ` + ` : ''} + + ` : ''; + + panel.innerHTML = ` +
+ +
+ ${deliverablesBtns} + Export + + +
+
+
+
+
+ ${escHtml(entity.group)} +

${escHtml(entity.label)}

+
+
+ ${entity.overall_score.toFixed(2)} + ${escHtml(entity.overall_label)} +
+
+
+

Pillar Breakdown

+
${pillarAccordions}
+ `; + + // Attach question row click handlers + panel.querySelectorAll('.question-row').forEach(row => { + row.addEventListener('click', () => { + const p = entity.pillars.find(x => x.name === row.dataset.pillar); + if (p) openQuestionModal(entity, p, p.questions[parseInt(row.dataset.qi)]); + }); + }); +} + +function closeEntityDetail() { + detailEntity = null; + document.getElementById('detailPanel').style.display = 'none'; + document.getElementById('entityGrid').style.display = ''; +} + +function togglePillar(header) { + header.closest('.pillar-card').classList.toggle('open'); +} + +// ── Question modal ──────────────────────────────────────────────────────────── +function openQuestionModal(entity, pillar, question) { + const qlvl = scoreClass(question.level); + const elvl = scoreClass(entity.overall_level); + + document.getElementById('qModalBadges').innerHTML = ` + ${escHtml(entity.short)} + ${escHtml(pillar.name)} + `; + document.getElementById('qModalTitle').textContent = question.topic; + document.getElementById('qModalMeta').textContent = `Q${question.q_num} · ${pillar.name} · ${entity.label}`; + + const fields = [ + { label: 'Rationale', value: question.rationale }, + { label: 'Gaps Identified', value: question.gaps || '—' }, + { label: 'References', value: question.refs || '—' }, + ]; + + const scoring = activeClient.config.scoring; + const scoreNums = Object.keys(scoring.labels).map(Number).sort((a, b) => a - b); + const scoreBtns = scoreNums.map(n => { + const lvl = scoreToLevel(n, scoring); + const isCurrent = n === question.score; + return ` + `; + }).join(''); + + document.getElementById('qModalBody').innerHTML = ` +
+ Score +
+
${scoreBtns}
+

Click a score to update it

+
+
+ ${fields.map(f => { + const empty = !f.value || f.value === '—'; + return ` +
+ ${escHtml(f.label)} + ${escHtml(f.value)} +
`; + }).join('')} + `; + + document.getElementById('questionModal').classList.remove('hidden'); +} + +// ── Score editing ───────────────────────────────────────────────────────────── +function scoreToLevel(score, scoring) { + const levels = Object.keys(scoring.labels).map(Number).sort((a, b) => a - b); + const idx = levels.indexOf(score); + return idx >= 0 ? idx + 1 : Math.round(((score - scoring.min) / (scoring.max - scoring.min)) * (levels.length - 1)) + 1; +} + +async function updateScore(entityId, pillarName, qNum, newScore) { + const scoring = activeClient.config.scoring; + const entity = activeClient.data.entities.find(e => e.id === entityId); + const pillar = entity.pillars.find(p => p.name === pillarName); + const question = pillar.questions.find(q => String(q.q_num) === String(qNum)); + + if (question.score === newScore) return; + + const oldScore = question.score; + const lvl = scoreToLevel(newScore, scoring); + + // Update question + question.score = newScore; + question.level = lvl; + question.label = scoring.labels[newScore] || ''; + + // Recalculate pillar avg + const pillarScores = pillar.questions.map(q => q.score); + pillar.avg = Math.round((pillarScores.reduce((a, b) => a + b, 0) / pillarScores.length) * 100) / 100; + pillar.level = scoreToLevel(Math.round(pillar.avg), scoring); + pillar.label = scoring.labels[Math.round(pillar.avg)] || scoring.labels[Math.ceil(pillar.avg)] || ''; + + // Recalculate entity overall + const allScores = entity.pillars.flatMap(p => p.questions.map(q => q.score)); + entity.overall_score = Math.round((allScores.reduce((a, b) => a + b, 0) / allScores.length) * 100) / 100; + entity.overall_level = scoreToLevel(Math.round(entity.overall_score), scoring); + entity.overall_label = scoring.labels[Math.round(entity.overall_score)] || scoring.labels[Math.ceil(entity.overall_score)] || ''; + + // Update score button highlight in modal + const scoreNums = Object.keys(scoring.labels).map(Number).sort((a, b) => a - b); + scoreNums.forEach(n => { + const btn = document.getElementById(`score-btn-${n}`); + if (!btn) return; + const isCurrent = n === newScore; + btn.style.borderColor = isCurrent ? 'var(--accent)' : 'var(--border)'; + btn.style.background = isCurrent ? 'rgba(120,190,32,0.08)' : 'var(--bg-inset)'; + }); + const hint = document.getElementById('scoreEditHint'); + if (hint) hint.innerHTML = `✓ Score updated ${oldScore} → ${newScore}`; + + // Re-render cards/detail if visible + if (detailEntity && detailEntity.id === entityId) { + openEntityDetail(entity); // refreshes detail panel + document.getElementById('questionModal').classList.remove('hidden'); // keep modal open + } else { + renderEntityCards(); + } + if (activeTab === 'compare') renderCompareTable(); + + // Persist to server + try { + const res = await fetch(`/api/clients/${activeClient.config.id}/data`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(activeClient.data), + }); + if (!res.ok) throw new Error(await res.text()); + showToast(`Q${qNum} score saved: ${newScore} (${question.label})`, 'success'); + } catch (e) { + showToast('Save failed — changes kept in memory only', 'error'); + } +} + +function closeModal() { + document.getElementById('questionModal').classList.add('hidden'); +} + +function handleModalOverlayClick(e) { + if (e.target === document.getElementById('questionModal')) closeModal(); +} + +// ── Compare tab ─────────────────────────────────────────────────────────────── +function renderCompareSelector() { + const entities = activeClient.data.entities; + const entityLabel = activeClient.config.entity_label || 'Entities'; + + document.getElementById('compareSelector').innerHTML = ` +

Select ${escHtml(entityLabel)} to Compare

+
+ ${entities.map(e => { + const lvl = scoreClass(e.overall_level); + return ` + `; + }).join('')} +
+
+ + +
+ `; +} + +function onCompareSelect(cb) { + cb.checked ? selectedForCompare.add(cb.value) : selectedForCompare.delete(cb.value); +} + +function clearCompare() { + selectedForCompare.clear(); + document.querySelectorAll('#compareSelector input[type=checkbox]').forEach(cb => cb.checked = false); + document.getElementById('compareResult').style.display = 'none'; +} + +function renderCompareTable() { + if (selectedForCompare.size < 2) { + showToast('Select at least 2 to compare', 'error'); + return; + } + + const markets = [...selectedForCompare] + .map(id => activeClient.data.entities.find(e => e.id === id)) + .filter(Boolean); + const pillars = activeClient.config.pillars; + const scoring = activeClient.config.scoring; + const wrap = document.getElementById('compareResult'); + wrap.style.display = ''; + + // Header + const headerCols = markets.map(m => ` + +
${escHtml(m.short)}
+
+ ${m.overall_score.toFixed(2)} +
+ `).join(''); + + // Build rows: overall + per pillar + const buildRow = (label, values) => { + const defined = values.filter(v => v !== null); + const maxVal = defined.length ? Math.max(...defined) : null; + const minVal = defined.length ? Math.min(...defined) : null; + const allSame = defined.every(v => v === defined[0]); + + const cells = values.map((v, i) => { + if (v === null) return '—'; + const cls = !allSame && v === maxVal ? 'diff-hi' : !allSame && v === minVal ? 'diff-lo' : ''; + const level = Math.round(((v - scoring.min) / (scoring.max - scoring.min)) * (Object.keys(scoring.labels).length - 1)) + 1; + const pct = ((v - scoring.min) / (scoring.max - scoring.min) * 100).toFixed(0); + return ` + +
+
+
+
+ ${v.toFixed(2)} +
+ `; + }).join(''); + + return `${escHtml(label)}${cells}`; + }; + + // Overall row + const overallVals = markets.map(m => m.overall_score); + const overallDef = overallVals.filter(v => v !== null); + const maxO = Math.max(...overallDef), minO = Math.min(...overallDef); + const allSameO = overallDef.every(v => v === overallDef[0]); + const overallRow = ` + OVERALL + ${markets.map(m => { + const cls = !allSameO && m.overall_score === maxO ? 'diff-hi' : !allSameO && m.overall_score === minO ? 'diff-lo' : ''; + return ` + ${m.overall_score.toFixed(2)} + `; + }).join('')} + `; + + const pillarRows = pillars.map(pname => { + const vals = markets.map(m => { + const p = m.pillars.find(x => x.name === pname); + return p ? p.avg : null; + }); + return buildRow(pname, vals); + }).join(''); + + wrap.innerHTML = ` +

${escHtml(markets.map(m => m.short).join(' vs '))}

+
+ + ${headerCols} + ${overallRow}${pillarRows} +
+
+
+ Highest in row + Lowest in row +
+ `; +} + +// ── Deliverable downloads (pre-existing client PDFs / XLSXs) ───────────────── + +async function downloadDeliverable(entityId, type) { + const btnId = type === 'pdf' ? `delPdfBtn-${entityId}` : `delXlsxBtn-${entityId}`; + const btn = document.getElementById(btnId); + const label = type === 'pdf' ? 'Summary PDF' : 'Scores XLSX'; + if (btn) { btn.disabled = true; btn.textContent = '⏳…'; } + try { + const res = await fetch(`/api/clients/${activeClient.config.id}/deliverables/${entityId}/${type}`); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + const blob = await res.blob(); + const disposition = res.headers.get('Content-Disposition') || ''; + const nameMatch = disposition.match(/filename="([^"]+)"/); + const filename = nameMatch ? nameMatch[1] : `${entityId}_${label.replace(' ', '_')}.${type}`; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = filename; a.click(); + URL.revokeObjectURL(url); + showToast(`${label} downloaded`); + } catch (e) { + showToast(`${label} unavailable: ${e.message}`, 'error'); + } finally { + if (btn) { + btn.disabled = false; + const icon = ''; + btn.innerHTML = `${icon} ${label}`; + } + } +} + +// ── Exports ─────────────────────────────────────────────────────────────────── + +// CSV — flat, all entities +function exportCsv() { + const { config, data } = activeClient; + const rows = [['Client', 'Entity', 'Entity Short', 'Group', 'Overall Score', 'Overall Level', 'Pillar', 'Q#', 'Question Topic', 'Score', 'Level', 'Rationale', 'Gaps', 'References']]; + for (const entity of data.entities) { + for (const pillar of entity.pillars) { + for (const q of pillar.questions) { + rows.push([ + config.name, + entity.label, + entity.short, + entity.group, + entity.overall_score, + entity.overall_label, + pillar.name, + q.q_num, + q.topic, + q.score, + q.label, + q.rationale || '', + q.gaps || '', + q.refs || '', + ]); + } + } + } + const csv = rows.map(r => + r.map(v => `"${String(v ?? '').replace(/"/g, '""')}"`).join(',') + ).join('\r\n'); + triggerDownload(`${config.id}_maturity_all_entities.csv`, 'text/csv;charset=utf-8;', csv); + showToast('CSV downloaded'); +} + +// XLSX — multi-sheet workbook +function exportXlsx(entityId) { + const { config, data } = activeClient; + const wb = XLSX.utils.book_new(); + const entities = entityId + ? data.entities.filter(e => e.id === entityId) + : data.entities; + + const SCORE_COLORS = { 1: 'C62828', 2: 'E65100', 3: '2E7D32', 4: '1B5E20' }; + + // Summary sheet (always included) + const summaryRows = [['Entity', 'Group', 'Overall Score', 'Overall Level', ...config.pillars]]; + for (const e of entities) { + const row = [e.label, e.group, e.overall_score, e.overall_label]; + for (const pname of config.pillars) { + const p = e.pillars.find(x => x.name === pname); + row.push(p ? p.avg : ''); + } + summaryRows.push(row); + } + const summaryWs = XLSX.utils.aoa_to_sheet(summaryRows); + // Style header row + const summaryRange = XLSX.utils.decode_range(summaryWs['!ref']); + for (let C = summaryRange.s.c; C <= summaryRange.e.c; C++) { + const cell = summaryWs[XLSX.utils.encode_cell({ r: 0, c: C })]; + if (cell) { + cell.s = { font: { bold: true, color: { rgb: 'FFFFFF' } }, fill: { fgColor: { rgb: '1A2B3C' } }, alignment: { wrapText: true } }; + } + } + // Column widths + summaryWs['!cols'] = [{ wch: 28 }, { wch: 16 }, { wch: 14 }, { wch: 14 }, + ...config.pillars.map(() => ({ wch: 12 }))]; + XLSX.utils.book_append_sheet(wb, summaryWs, 'Summary'); + + // One sheet per entity + for (const entity of entities) { + const sheetRows = [['Q#', 'Pillar', 'Question Topic', 'Score', 'Level', 'Rationale', 'Gaps', 'References']]; + for (const pillar of entity.pillars) { + for (const q of pillar.questions) { + sheetRows.push([q.q_num, pillar.name, q.topic, q.score, q.label, q.rationale || '', q.gaps || '', q.refs || '']); + } + // Pillar average row + sheetRows.push(['', pillar.name + ' — AVG', '', pillar.avg, pillar.label, '', '', '']); + } + + const ws = XLSX.utils.aoa_to_sheet(sheetRows); + const range = XLSX.utils.decode_range(ws['!ref']); + + for (let R = 0; R <= range.e.r; R++) { + for (let C = 0; C <= range.e.c; C++) { + const cellRef = XLSX.utils.encode_cell({ r: R, c: C }); + const cell = ws[cellRef]; + if (!cell) continue; + if (R === 0) { + cell.s = { font: { bold: true, color: { rgb: 'FFFFFF' } }, fill: { fgColor: { rgb: '1A2B3C' } }, alignment: { wrapText: true, vertical: 'top' } }; + } else { + cell.s = cell.s || {}; + cell.s.alignment = { wrapText: true, vertical: 'top' }; + // Colour score cells (col 3) + if (C === 3 && typeof cell.v === 'number') { + const lvl = entity.pillars + .flatMap(p => p.questions) + .find(q => String(q.q_num) === String(sheetRows[R][0]))?.level || 0; + const hex = SCORE_COLORS[lvl]; + if (hex) { + cell.s.fill = { fgColor: { rgb: hex } }; + cell.s.font = { bold: true, color: { rgb: 'FFFFFF' } }; + } + } + // Pillar avg rows + if (String(sheetRows[R][1] || '').includes('— AVG')) { + cell.s.fill = { fgColor: { rgb: 'F7F7F7' } }; + cell.s.font = { bold: true }; + } + } + } + } + + ws['!cols'] = [{ wch: 6 }, { wch: 22 }, { wch: 40 }, { wch: 8 }, { wch: 14 }, { wch: 55 }, { wch: 40 }, { wch: 30 }]; + ws['!rows'] = sheetRows.map(() => ({ hpt: 40 })); + + const sheetName = entity.short.replace(/[\\\/\*\?\[\]]/g, '').slice(0, 31); + XLSX.utils.book_append_sheet(wb, ws, sheetName); + } + + const filename = entityId + ? `${entityId}_Maturity_Report.xlsx` + : `${config.id}_Maturity_All_Entities.xlsx`; + + XLSX.writeFile(wb, filename); + showToast('XLSX downloaded'); +} + +// PDF — server-generated via Python/ReportLab +async function downloadPdf(entityId) { + const btn = document.getElementById(`pdfBtn-${entityId}`); + if (btn) { btn.textContent = '⏳ Generating…'; btn.disabled = true; } + try { + const res = await fetch(`/api/clients/${activeClient.config.id}/export/pdf/${entityId}`); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${entityId}_Maturity_Report.pdf`; + a.click(); + URL.revokeObjectURL(url); + showToast('PDF downloaded'); + } catch (e) { + showToast('PDF failed: ' + e.message, 'error'); + } finally { + if (btn) { btn.innerHTML = ' PDF'; btn.disabled = false; } + } +} + +function triggerDownload(filename, mime, content) { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = filename; a.click(); + URL.revokeObjectURL(url); +} + +// ── Import / Update Data tab ────────────────────────────────────────────────── + +let importFile = null; + +function populateImportEntitySelect() { + if (!activeClient) return; + const options = activeClient.data.entities.map(e => + `` + ).join(''); + document.getElementById('importEntitySel').innerHTML = + '' + options; + document.getElementById('syncEntitySel').innerHTML = + '' + options; +} + +function onFileSelected(file) { + if (!file) return; + importFile = file; + const dz = document.getElementById('dropZone'); + dz.classList.add('has-file'); + dz.classList.remove('drag-over'); + document.getElementById('dropZoneText').textContent = + `${file.name} (${(file.size / 1024).toFixed(0)} KB)`; +} + +function handleFileDrop(e) { + e.preventDefault(); + document.getElementById('dropZone').classList.remove('drag-over'); + const file = e.dataTransfer.files[0]; + if (file) onFileSelected(file); +} + +async function runBoxSync() { + const btn = document.getElementById('syncRunBtn'); + const log = document.getElementById('syncLog'); + const entityId = document.getElementById('syncEntitySel').value; + btn.disabled = true; + btn.innerHTML = 'Syncing…'; + log.style.display = ''; + log.textContent = entityId + ? `Syncing ${activeClient.data.entities.find(e => e.id === entityId)?.label || entityId}…\n` + : 'Running converter (all entities)…\n'; + + try { + const body = entityId ? JSON.stringify({ entity_id: entityId }) : undefined; + const res = await fetch(`/api/clients/${activeClient.config.id}/sync`, { + method: 'POST', + headers: body ? { 'Content-Type': 'application/json' } : {}, + body, + }); + const data = await res.json(); + if (!res.ok) { + log.textContent = `Error:\n${data.detail || data.error}`; + showToast('Sync failed', 'error'); + } else { + log.textContent = (data.log || 'Done.').trim(); + showToast('Sync complete — reloading…', 'success'); + setTimeout(async () => { + await loadClient(activeClient.config.id); + showTab('entities'); + }, 1000); + } + } catch (e) { + log.textContent = 'Network error: ' + e.message; + showToast('Sync failed: ' + e.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = ` + + + + Sync from Box`; + } +} + +async function runFileImport() { + const entityId = document.getElementById('importEntitySel').value; + if (!entityId) { showToast('Select an entity first', 'error'); return; } + if (!importFile) { showToast('Select a file to upload', 'error'); return; } + + const btn = document.getElementById('importRunBtn'); + btn.disabled = true; + btn.textContent = 'Importing…'; + + const formData = new FormData(); + formData.append('file', importFile); + formData.append('entity_id', entityId); + + try { + const res = await fetch(`/api/clients/${activeClient.config.id}/import/file`, { + method: 'POST', + body: formData, + }); + const data = await res.json(); + if (!res.ok) { + showToast('Import failed: ' + (data.error || res.statusText), 'error'); + } else { + const e = data.entity; + showToast(`Imported ${escHtml(e.label)}: ${e.overall_score.toFixed(2)} (${e.overall_label})`, 'success'); + await loadClient(activeClient.config.id); + showTab('entities'); + } + } catch (e) { + showToast('Import failed: ' + e.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = ` + + + + Import`; + } +} + +// ── Theme ───────────────────────────────────────────────────────────────────── +function toggleTheme() { + const isLight = document.body.classList.toggle('light'); + localStorage.setItem('maturityTheme', isLight ? 'light' : 'dark'); + document.getElementById('iconDark').style.display = isLight ? 'none' : ''; + document.getElementById('iconLight').style.display = isLight ? '' : 'none'; +} + +function applyStoredTheme() { + if (localStorage.getItem('maturityTheme') === 'light') { + document.body.classList.add('light'); + document.getElementById('iconDark').style.display = 'none'; + document.getElementById('iconLight').style.display = ''; + } +} + +// ── Utilities ───────────────────────────────────────────────────────────────── +function escHtml(s) { + return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function showToast(msg, type = 'success') { + const t = document.getElementById('toast'); + t.textContent = msg; + t.className = `show ${type}`; + clearTimeout(t._timer); + t._timer = setTimeout(() => { t.className = ''; }, 3000); +} + +// ── Keyboard shortcuts ──────────────────────────────────────────────────────── +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { closeModal(); closeWizard(); } +}); + +// ── Start ───────────────────────────────────────────────────────────────────── +init(); diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..f79439f --- /dev/null +++ b/server/index.js @@ -0,0 +1,240 @@ +'use strict'; +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const { execFile } = require('child_process'); +const multer = require('multer'); + +const app = express(); +const PORT = process.env.PORT || 3102; +const ROOT = path.join(__dirname, '..'); +const CLIENT_DIR = path.join(ROOT, 'clients'); +const TMP_DIR = path.join(ROOT, 'tmp_uploads'); + +// Ensure tmp upload dir exists +if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true }); + +// Multer — disk storage preserving original extension +const upload = multer({ + storage: multer.diskStorage({ + destination: TMP_DIR, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() || '.csv'; + cb(null, `upload_${Date.now()}_${Math.random().toString(36).slice(2)}${ext}`); + }, + }), + limits: { fileSize: 30 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const ok = ['.csv', '.xlsx', '.xls'].includes(path.extname(file.originalname).toLowerCase()); + cb(ok ? null : new Error('Only CSV and XLSX files are supported'), ok); + }, +}); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function listClients() { + if (!fs.existsSync(CLIENT_DIR)) return []; + return fs.readdirSync(CLIENT_DIR).filter(id => { + const cfg = path.join(CLIENT_DIR, id, 'config.json'); + const dat = path.join(CLIENT_DIR, id, 'data.json'); + return fs.existsSync(cfg) && fs.existsSync(dat); + }); +} + +// Health +app.get('/api/health', (_req, res) => { + const clients = listClients(); + res.json({ status: 'ok', clients }); +}); + +// List clients +app.get('/api/clients', (_req, res) => { + const clients = listClients().map(id => { + const cfg = readJson(path.join(CLIENT_DIR, id, 'config.json')); + const dat = readJson(path.join(CLIENT_DIR, id, 'data.json')); + return { + id: cfg.id, + name: cfg.name, + description: cfg.description, + accent_color: cfg.accent_color, + entity_label: cfg.entity_label, + logo: cfg.logo || null, + entity_count: dat.entities ? dat.entities.length : 0, + generated: dat.generated, + }; + }); + res.json(clients); +}); + +// Client config +app.get('/api/clients/:id/config', (req, res) => { + const cfgPath = path.join(CLIENT_DIR, req.params.id, 'config.json'); + if (!fs.existsSync(cfgPath)) return res.status(404).json({ error: 'Client not found' }); + res.json(readJson(cfgPath)); +}); + +// Client data +app.get('/api/clients/:id/data', (req, res) => { + const datPath = path.join(CLIENT_DIR, req.params.id, 'data.json'); + if (!fs.existsSync(datPath)) return res.status(404).json({ error: 'Data not found — run: python3 convert_data.py ' + req.params.id }); + res.json(readJson(datPath)); +}); + +// Save updated client data (score edits) +app.use(express.json({ limit: '10mb' })); +app.post('/api/clients/:id/data', (req, res) => { + const datPath = path.join(CLIENT_DIR, req.params.id, 'data.json'); + if (!fs.existsSync(datPath)) return res.status(404).json({ error: 'Client not found' }); + try { + const payload = req.body; + if (!payload || !Array.isArray(payload.entities)) { + return res.status(400).json({ error: 'Invalid payload — expected { entities: [...] }' }); + } + fs.writeFileSync(datPath, JSON.stringify(payload, null, 2), 'utf8'); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: 'Failed to save: ' + e.message }); + } +}); + +// Create a new client (writes config.json + empty data.json) +app.post('/api/clients', (req, res) => { + const config = req.body; + if (!config || !config.name) return res.status(400).json({ error: 'name is required' }); + const id = String(config.id || config.name) + .toLowerCase().replace(/[^a-z0-9\s-]/g, '').trim() + .replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); + if (!id) return res.status(400).json({ error: 'Could not derive a valid client ID' }); + const clientDir = path.join(CLIENT_DIR, id); + if (fs.existsSync(clientDir)) return res.status(409).json({ error: `Client "${id}" already exists` }); + fs.mkdirSync(clientDir, { recursive: true }); + const finalConfig = { ...config, id }; + fs.writeFileSync(path.join(clientDir, 'config.json'), JSON.stringify(finalConfig, null, 2), 'utf8'); + const emptyData = { generated: new Date().toISOString().slice(0, 10), client_id: id, entities: [] }; + fs.writeFileSync(path.join(clientDir, 'data.json'), JSON.stringify(emptyData, null, 2), 'utf8'); + res.json({ ok: true, id }); +}); + +// Deliverables manifest (deliverables.json — optional per client) +app.get('/api/clients/:id/deliverables', (req, res) => { + const delPath = path.join(CLIENT_DIR, req.params.id, 'deliverables.json'); + if (!fs.existsSync(delPath)) return res.json({}); + res.json(readJson(delPath)); +}); + +// Stream a single deliverable file (pdf or xlsx) +app.get('/api/clients/:id/deliverables/:entityId/:type', (req, res) => { + const { id, entityId, type } = req.params; + if (!['pdf', 'xlsx'].includes(type)) return res.status(400).json({ error: 'type must be pdf or xlsx' }); + const delPath = path.join(CLIENT_DIR, id, 'deliverables.json'); + if (!fs.existsSync(delPath)) return res.status(404).json({ error: 'No deliverables configured for this client' }); + const map = readJson(delPath); + const entry = map[entityId]; + if (!entry || !entry[type]) return res.status(404).json({ error: `No ${type} deliverable configured for ${entityId}` }); + const filePath = entry[type]; + if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found on disk: ' + path.basename(filePath) }); + const ext = path.extname(filePath).toLowerCase(); + const mime = ext === '.pdf' ? 'application/pdf' + : ext === '.xlsx' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + : 'text/csv'; + res.setHeader('Content-Type', mime); + res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`); + fs.createReadStream(filePath).pipe(res); +}); + +// Box sync — re-runs convert_data.py for this client (all entities or a single one) +app.post('/api/clients/:id/sync', (req, res) => { + const id = req.params.id.replace(/[^a-zA-Z0-9_-]/g, ''); + const cfgPath = path.join(CLIENT_DIR, id, 'config.json'); + if (!fs.existsSync(cfgPath)) return res.status(404).json({ error: 'Client not found' }); + const script = path.join(ROOT, 'convert_data.py'); + const entityId = (req.body && req.body.entity_id) ? String(req.body.entity_id).replace(/[^a-zA-Z0-9_-]/g, '') : null; + const args = entityId ? [script, id, '--entity', entityId] : [script, id]; + execFile('python3', args, { cwd: ROOT, timeout: 120000 }, (err, stdout, stderr) => { + if (err) { + console.error('Sync failed:', stderr); + return res.status(500).json({ error: 'Sync failed', detail: stderr, log: stdout }); + } + res.json({ ok: true, log: stdout }); + }); +}); + +// File import — parse an uploaded CSV/XLSX and merge into an entity +app.post('/api/clients/:id/import/file', upload.single('file'), (req, res) => { + const id = req.params.id.replace(/[^a-zA-Z0-9_-]/g, ''); + const entityId = (req.body.entity_id || '').replace(/[^a-zA-Z0-9_-]/g, ''); + const datPath = path.join(CLIENT_DIR, id, 'data.json'); + + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + if (!entityId) { fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'entity_id required' }); } + if (!fs.existsSync(datPath)) { fs.unlinkSync(req.file.path); return res.status(404).json({ error: 'Client not found' }); } + + const script = path.join(ROOT, 'import_file.py'); + execFile('python3', [script, id, entityId, req.file.path], { cwd: ROOT, timeout: 30000 }, (err, stdout, stderr) => { + try { fs.unlinkSync(req.file.path); } catch (_) {} + if (err) { + console.error('Import failed:', stderr); + return res.status(500).json({ error: 'Import failed', detail: stderr }); + } + let parsed; + try { + parsed = JSON.parse(stdout.trim()); + } catch (e) { + return res.status(500).json({ error: 'Could not parse import output', detail: stdout }); + } + if (parsed.error) return res.status(400).json(parsed); + + const data = readJson(datPath); + const idx = data.entities.findIndex(e => e.id === entityId); + if (idx < 0) return res.status(404).json({ error: `Entity '${entityId}' not found in data.json` }); + + // Preserve identity fields, replace scores + const existing = data.entities[idx]; + data.entities[idx] = { + id: existing.id, + label: existing.label, + short: existing.short, + group: existing.group, + overall_score: parsed.overall_score, + overall_level: parsed.overall_level, + overall_label: parsed.overall_label, + pillars: parsed.pillars, + }; + data.generated = new Date().toISOString().slice(0, 10); + data.entities.sort((a, b) => b.overall_score - a.overall_score); + fs.writeFileSync(datPath, JSON.stringify(data, null, 2), 'utf8'); + res.json({ ok: true, entity: data.entities.find(e => e.id === entityId) }); + }); +}); + +// PDF export — generates via Python/ReportLab and streams back +app.get('/api/clients/:id/export/pdf/:entityId', (req, res) => { + const { id, entityId } = req.params; + const datPath = path.join(CLIENT_DIR, id, 'data.json'); + const cfgPath = path.join(CLIENT_DIR, id, 'config.json'); + if (!fs.existsSync(datPath) || !fs.existsSync(cfgPath)) { + return res.status(404).json({ error: 'Client not found' }); + } + const script = path.join(ROOT, 'pdf_generator.py'); + execFile('python3', [script, id, entityId], { cwd: ROOT }, (err, stdout, stderr) => { + if (err) { + console.error('PDF generation failed:', stderr); + return res.status(500).json({ error: 'PDF generation failed', detail: stderr }); + } + const pdfPath = path.join(CLIENT_DIR, id, 'exports', `${entityId}_Maturity_Report.pdf`); + if (!fs.existsSync(pdfPath)) { + return res.status(500).json({ error: 'PDF not found after generation' }); + } + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${entityId}_Maturity_Report.pdf"`); + fs.createReadStream(pdfPath).pipe(res); + }); +}); + +// Static files +app.use(express.static(ROOT, { index: 'index.html' })); +app.get('*', (_req, res) => res.sendFile(path.join(ROOT, 'index.html'))); + +app.listen(PORT, () => console.log(`Maturity Tool running on http://localhost:${PORT}`));