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 <noreply@anthropic.com>
This commit is contained in:
Phil Dore 2026-04-28 16:15:56 +01:00
commit 1fd3dc00ae
14 changed files with 5944 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
node_modules/
.env
.env.*
*.log
.DS_Store
.deployed
tmp_uploads/
clients/*/data.json
clients/adeo/exports/

BIN
clients/adeo/Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

47
clients/adeo/config.json Normal file
View file

@ -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"
}
}
}

View file

@ -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"
}
}

372
convert_data.py Normal file
View file

@ -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()

1004
generate_brazil_summary.py Normal file

File diff suppressed because it is too large Load diff

778
generate_summary.py Normal file
View file

@ -0,0 +1,778 @@
#!/usr/bin/env python3
"""
ADEO Content Maturity Assessment Universal Summary PDF Generator
Usage: python3 generate_summary.py <MARKET_ID>
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"<b>\1</b>", 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"<b>\1</b>", body.strip())
story.append(Paragraph(f"<b>Q{q['num']}: {q['topic']}</b>",
styles["bullet_title"]))
story.append(Paragraph(f"<b>{title}:</b> {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"<b>\1</b>", body.strip())
story.append(Paragraph(f"<b>Q{q['num']}: {q['topic']}</b>",
styles["bullet_title"]))
story.append(Paragraph(f"<b>{title}:</b> {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 <MARKET_ID>")
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()

218
import_file.py Normal file
View file

@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""
import_file.py <client_id> <entity_id> <filepath>
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 <client_id> <entity_id> <filepath>"}))
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)

472
index.html Normal file
View file

@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maturity Tool</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<style>
/* ── CSS Variables — Dark (default) ── */
:root {
--bg: #111111;
--bg-card: #1c1c1c;
--bg-modal: #1a1a1a;
--bg-input: #1c1c1c;
--bg-inset: #111111;
--border: #262626;
--border-sub: #1e1e1e;
--text: #f3f4f6;
--text-sub: #9ca3af;
--text-muted: #6b7280;
--text-faint: #4b5563;
--scrolltrack: #1a1a1a;
--scrollthumb: #3a3a3a;
--header-bg: #111111;
--header-bdr: #1e1e1e;
--shadow: none;
--accent: #78BE20;
}
/* ── CSS Variables — Light ── */
body.light {
--bg: #f4f4f5;
--bg-card: #ffffff;
--bg-modal: #ffffff;
--bg-input: #f9fafb;
--bg-inset: #f4f4f5;
--border: #e4e4e7;
--border-sub: #f0f0f0;
--text: #111111;
--text-sub: #52525b;
--text-muted: #71717a;
--text-faint: #a1a1aa;
--scrolltrack: #e4e4e7;
--scrollthumb: #d4d4d8;
--header-bg: #ffffff;
--header-bdr: #e4e4e7;
--shadow: 0 1px 3px rgba(0,0,0,0.08);
}
* { box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
transition: background 0.2s, color 0.2s;
margin: 0;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--scrolltrack); }
::-webkit-scrollbar-thumb { background: var(--scrollthumb); border-radius: 3px; }
/* ── Tabs ── */
.tab-btn {
padding: 10px 20px; font-size: 14px; font-weight: 500;
color: var(--text-muted); border-bottom: 2px solid transparent;
cursor: pointer; transition: color 0.2s, border-color 0.2s;
white-space: nowrap; background: none;
border-top: none; border-left: none; border-right: none;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
/* ── Panel ── */
.panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
box-shadow: var(--shadow);
}
/* ── Section header ── */
.section-header {
font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--accent); margin-bottom: 14px;
}
/* ── Badge ── */
.badge {
display: inline-block; font-size: 10px; font-weight: 700;
letter-spacing: 0.06em; text-transform: uppercase;
padding: 2px 8px; border-radius: 4px;
}
/* ── Score level badge colours ── */
.score-1 { background: #C62828; color: #fff; }
.score-2 { background: #E65100; color: #fff; }
.score-3 { background: #2E7D32; color: #fff; }
.score-4 { background: #1B5E20; color: #fff; }
.score-bg-1 { background: rgba(198,40,40,0.15); border: 1px solid rgba(198,40,40,0.35); }
.score-bg-2 { background: rgba(230,81,0,0.15); border: 1px solid rgba(230,81,0,0.35); }
.score-bg-3 { background: rgba(46,125,50,0.15); border: 1px solid rgba(46,125,50,0.35); }
.score-bg-4 { background: rgba(27,94,32,0.15); border: 1px solid rgba(27,94,32,0.35); }
body.light .score-bg-1 { background: #FFEBEE; border-color: #C62828; }
body.light .score-bg-2 { background: #FFF3E0; border-color: #E65100; }
body.light .score-bg-3 { background: #E8F5E9; border-color: #2E7D32; }
body.light .score-bg-4 { background: #F1F8E9; border-color: #1B5E20; }
/* ── Entity cards ── */
.entity-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 18px; cursor: pointer;
transition: border-color 0.2s, transform 0.15s, box-shadow 0.2s;
box-shadow: var(--shadow);
}
.entity-card:hover {
border-color: var(--accent); transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(0,0,0,0.18);
}
/* ── Client cards (home screen) ── */
.client-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; padding: 28px; cursor: pointer;
transition: border-color 0.2s, transform 0.15s, box-shadow 0.2s;
box-shadow: var(--shadow);
}
.client-card:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.2); }
/* ── Pillar mini-bars ── */
.pillar-bar-track { height: 4px; border-radius: 2px; background: var(--border); margin-top: 3px; }
.pillar-bar-fill { height: 4px; border-radius: 2px; background: var(--accent); transition: width 0.6s ease; }
/* ── Pillar accordion (entity detail) ── */
.pillar-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
.pillar-card-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; cursor: pointer; transition: background 0.15s; }
.pillar-card-header:hover { background: var(--bg-inset); }
.pillar-card-body { display: none; border-top: 1px solid var(--border); }
.pillar-card.open .pillar-card-body { display: block; }
.pillar-chevron { transition: transform 0.2s; color: var(--text-muted); flex-shrink: 0; }
.pillar-card.open .pillar-chevron { transform: rotate(180deg); }
/* ── Question rows ── */
.question-row {
display: flex; align-items: flex-start; gap: 12px;
padding: 10px 16px; border-bottom: 1px solid var(--border-sub);
cursor: pointer; transition: background 0.1s;
}
.question-row:last-child { border-bottom: none; }
.question-row:hover { background: var(--bg-inset); }
/* ── Modal ── */
.modal-overlay {
position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,0.65);
display: flex; align-items: center; justify-content: center;
padding: 16px; overflow-y: auto;
}
.modal-overlay.hidden { display: none; }
.modal-box {
background: var(--bg-modal); border: 1px solid var(--border);
border-radius: 12px; width: 100%; max-width: 740px;
max-height: 90vh; overflow-y: auto;
padding: 28px; position: relative;
box-shadow: 0 20px 60px rgba(0,0,0,0.45);
}
.spec-detail-row {
display: grid; grid-template-columns: 150px 1fr;
gap: 8px 16px; padding: 10px 0;
border-bottom: 1px solid var(--border-sub);
}
.spec-detail-row:last-child { border-bottom: none; }
.spec-detail-label { font-size: 11px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted); padding-top: 2px; }
.spec-detail-value { font-size: 13px; color: var(--text); line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
.spec-detail-value.empty { color: var(--text-faint); font-style: italic; }
/* ── Buttons ── */
.btn-primary {
background: var(--accent); color: #111; font-weight: 700;
padding: 9px 20px; border-radius: 6px; font-size: 13px;
border: none; cursor: pointer; transition: opacity 0.2s;
display: inline-flex; align-items: center; gap: 6px;
}
.btn-primary:hover { opacity: 0.88; }
.btn-ghost {
background: transparent; color: var(--text-sub); font-weight: 500;
padding: 8px 16px; border-radius: 6px; font-size: 13px;
border: 1px solid var(--border); cursor: pointer; transition: border-color 0.2s, color 0.2s;
display: inline-flex; align-items: center; gap: 6px;
}
.btn-ghost:hover { border-color: var(--accent); color: var(--text); }
/* ── Theme toggle ── */
.theme-toggle {
width: 36px; height: 36px; border-radius: 8px;
border: 1px solid var(--border); background: transparent;
color: var(--text-muted); display: flex; align-items: center;
justify-content: center; cursor: pointer; transition: all 0.2s;
}
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
/* ── Compare table ── */
.compare-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.compare-table th {
padding: 10px 12px; text-align: left; font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em;
color: var(--accent); border-bottom: 2px solid var(--accent);
background: var(--bg-inset);
}
.compare-table td { padding: 10px 12px; border-bottom: 1px solid var(--border-sub); vertical-align: middle; }
.compare-table tr:last-child td { border-bottom: none; }
.compare-table .row-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); width: 190px; }
.compare-table td.diff-hi { background: rgba(46,125,50,0.1); font-weight: 600; color: var(--text); }
.compare-table td.diff-lo { background: rgba(198,40,40,0.1); font-weight: 600; color: var(--text); }
/* ── Summary stat box ── */
.stat-box { text-align: center; padding: 12px 16px; }
.stat-num { font-size: 26px; font-weight: 800; color: var(--text); line-height: 1; }
.stat-lbl { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted); margin-top: 4px; }
/* ── Toast ── */
#toast {
position: fixed; bottom: 24px; right: 24px; z-index: 100;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 8px; padding: 12px 18px;
font-size: 13px; color: var(--text);
transform: translateY(80px); opacity: 0;
transition: all 0.3s ease; pointer-events: none;
}
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { border-left: 3px solid #34d399; }
#toast.error { border-left: 3px solid #f87171; }
/* ── Cards grid ── */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
}
/* ── Back button row ── */
.back-row { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
/* ── Fade-up animation ── */
@keyframes fadeUp { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
.fade-up { animation: fadeUp 0.25s ease both; }
/* ── Import modal ── */
.drop-zone {
border: 2px dashed var(--border); border-radius: 8px;
padding: 28px 16px; text-align: center; cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: var(--accent); background: rgba(120,190,32,0.04);
}
.drop-zone.has-file { border-color: var(--accent); }
.sync-log {
display: none; margin-top: 14px; padding: 12px 14px;
background: var(--bg-inset); border: 1px solid var(--border);
border-radius: 8px; font-family: ui-monospace, monospace;
font-size: 12px; color: var(--text-sub); white-space: pre-wrap;
max-height: 220px; overflow-y: auto; line-height: 1.5;
}
.field-label {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--text-muted);
display: block; margin-bottom: 6px;
}
select.field-select {
width: 100%; padding: 8px 10px; background: var(--bg-input);
border: 1px solid var(--border); border-radius: 6px;
color: var(--text); font-size: 13px; outline: none;
transition: border-color 0.2s;
}
select.field-select:focus { border-color: var(--accent); }
/* ── New client card ── */
.new-client-card {
background: transparent; border: 2px dashed var(--border);
border-radius: 12px; padding: 28px; cursor: pointer;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; transition: border-color 0.2s, background 0.2s; min-height: 180px;
}
.new-client-card:hover { border-color: var(--accent); background: var(--bg-inset); }
/* ── Wizard inputs ── */
.wizard-input {
width: 100%; padding: 9px 11px; background: var(--bg-input);
border: 1px solid var(--border); border-radius: 6px;
color: var(--text); font-size: 13px; outline: none; transition: border-color 0.2s;
}
.wizard-input:focus { border-color: var(--accent); }
.wizard-input::placeholder { color: var(--text-faint); }
</style>
</head>
<body>
<!-- ══ HEADER ══════════════════════════════════════════════════════════════ -->
<header style="background:var(--header-bg);border-bottom:1px solid var(--header-bdr);position:sticky;top:0;z-index:40;padding:16px 24px;box-shadow:var(--shadow);">
<div style="max-width:1280px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:14px;">
<!-- Back to home (shown when inside a client) -->
<button id="homeBtn" onclick="goHome()" style="display:none;background:none;border:none;cursor:pointer;color:var(--text-muted);padding:4px;border-radius:6px;transition:color 0.2s;" title="All clients">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 12L12 3l9 9"/><path d="M9 21V12h6v9"/></svg>
</button>
<div>
<h1 style="font-size:17px;font-weight:800;color:var(--text);margin:0;line-height:1.2;" id="headerTitle">Maturity Tool</h1>
<p style="font-size:12px;color:var(--text-muted);margin:2px 0 0;" id="headerSub">Loading…</p>
</div>
</div>
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle" title="Toggle theme">
<svg id="iconDark" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>
<svg id="iconLight" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
</button>
</div>
</header>
<!-- ══ TAB BAR (shown inside a client) ══════════════════════════════════════ -->
<div id="tabBar" style="display:none;background:var(--header-bg);border-bottom:1px solid var(--header-bdr);position:sticky;top:57px;z-index:30;">
<div style="max-width:1280px;margin:0 auto;padding:0 24px;display:flex;">
<button class="tab-btn active" id="tab-entities" onclick="showTab('entities')">Markets</button>
<button class="tab-btn" id="tab-compare" onclick="showTab('compare')">Compare</button>
<button class="tab-btn" id="tab-update" onclick="showTab('update')">Update Data</button>
</div>
</div>
<!-- ══ MAIN CONTENT ══════════════════════════════════════════════════════════ -->
<div style="max-width:1280px;margin:0 auto;padding:24px;">
<!-- ── Home screen: client selector ── -->
<div id="homeScreen">
<div id="clientCards" class="cards-grid"></div>
</div>
<!-- ── Client view ── -->
<div id="clientView" style="display:none;">
<!-- Tab: Entities (Markets) -->
<div id="tab-entities-content">
<!-- Summary bar -->
<div class="panel fade-up" id="summaryBar" style="margin-bottom:16px;"></div>
<!-- Export row (all entities) -->
<div id="exportRow" style="display:none;margin-bottom:16px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin-right:4px;">Export all</span>
<button class="btn-ghost" onclick="exportCsv()" title="Download flat CSV of all entities">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
CSV
</button>
<button class="btn-ghost" onclick="exportXlsx(null)" title="Download formatted Excel workbook">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
XLSX
</button>
</div>
<!-- Entity cards grid -->
<div id="entityGrid" class="cards-grid"></div>
<!-- Entity detail panel (replaces grid) -->
<div id="detailPanel" style="display:none;"></div>
</div>
<!-- Tab: Compare -->
<div id="tab-compare-content" style="display:none;">
<div class="panel fade-up" id="compareSelector" style="margin-bottom:16px;"></div>
<div class="panel fade-up" id="compareResult" style="display:none;"></div>
</div>
<!-- Tab: Update Data -->
<div id="tab-update-content" style="display:none;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start;">
<!-- Sync from Box -->
<div class="panel fade-up">
<p class="section-header">Sync from Box</p>
<p style="font-size:13px;color:var(--text-sub);margin:0 0 14px;line-height:1.65;">
Re-run the data converter against the current source files in Box.
</p>
<div style="margin-bottom:14px;">
<label class="field-label" for="syncEntitySel">Scope</label>
<select id="syncEntitySel" class="field-select">
<option value="">All entities</option>
</select>
</div>
<button class="btn-primary" id="syncRunBtn" onclick="runBoxSync()">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
Sync from Box
</button>
<div class="sync-log" id="syncLog"></div>
</div>
<!-- Upload File -->
<div class="panel fade-up" style="animation-delay:40ms;">
<p class="section-header">Upload File</p>
<div style="margin-bottom:14px;">
<label class="field-label" for="importEntitySel">Entity to update</label>
<select id="importEntitySel" class="field-select">
<option value="">-- Select entity --</option>
</select>
</div>
<div style="margin-bottom:18px;">
<label class="field-label">File (CSV or XLSX)</label>
<div class="drop-zone" id="dropZone"
onclick="document.getElementById('importFileInput').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="handleFileDrop(event)">
<input type="file" id="importFileInput" accept=".csv,.xlsx,.xls" style="display:none;" onchange="onFileSelected(this.files[0])">
<svg width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" style="color:var(--text-muted);margin-bottom:10px;display:block;margin-left:auto;margin-right:auto;"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<p id="dropZoneText" style="font-size:13px;color:var(--text-muted);margin:0;line-height:1.4;">Drop a CSV or XLSX here, or click to browse</p>
</div>
</div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<button class="btn-primary" id="importRunBtn" onclick="runFileImport()">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Import
</button>
<span style="font-size:11px;color:var(--text-muted);">Updates scores only — preserves entity name &amp; metadata</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ══ WIZARD MODAL ═════════════════════════════════════════════════════════ -->
<div id="wizardModal" class="modal-overlay hidden" onclick="handleWizardOverlayClick(event)">
<div class="modal-box" style="max-width:540px;" onclick="event.stopPropagation()">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
<div>
<h2 style="font-size:17px;font-weight:700;color:var(--text);margin:0;">New Client Setup</h2>
<p id="wizardStepLabel" style="font-size:12px;color:var(--text-muted);margin:4px 0 0;"></p>
</div>
<button onclick="closeWizard()" style="background:none;border:none;cursor:pointer;color:var(--text-muted);padding:4px;flex-shrink:0;" title="Close">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div id="wizardDots" style="display:flex;gap:4px;align-items:center;margin-bottom:24px;"></div>
<div id="wizardBody"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:24px;padding-top:16px;border-top:1px solid var(--border);">
<button id="wizardBackBtn" class="btn-ghost" onclick="wizardBack()">← Back</button>
<button id="wizardNextBtn" class="btn-primary" onclick="wizardNext()">Next →</button>
</div>
</div>
</div>
<!-- ══ QUESTION MODAL ════════════════════════════════════════════════════════ -->
<div id="questionModal" class="modal-overlay hidden" onclick="handleModalOverlayClick(event)">
<div class="modal-box" onclick="event.stopPropagation()">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:20px;">
<div style="padding-right:32px;flex:1;">
<div id="qModalBadges" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;"></div>
<h2 id="qModalTitle" style="font-size:17px;font-weight:700;color:var(--text);margin:0;line-height:1.4;"></h2>
<p id="qModalMeta" style="font-size:12px;color:var(--text-muted);margin:5px 0 0;"></p>
</div>
<button onclick="closeModal()" style="background:none;border:none;cursor:pointer;color:var(--text-muted);padding:4px;flex-shrink:0;" title="Close">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div id="qModalBody"></div>
</div>
</div>
<!-- ══ TOAST ═════════════════════════════════════════════════════════════════ -->
<div id="toast"></div>
<script src="script.js"></script>
</body>
</html>

943
package-lock.json generated Normal file
View file

@ -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"
}
}
}
}

12
package.json Normal file
View file

@ -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"
}
}

390
pdf_generator.py Normal file
View file

@ -0,0 +1,390 @@
#!/usr/bin/env python3
"""
Maturity Tool PDF Generator
Usage: python3 pdf_generator.py <client_id> <entity_id>
e.g. python3 pdf_generator.py adeo BU_LM_FRANCE
Outputs: clients/<client_id>/exports/<entity_id>_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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# ── 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"<b>Overall Score</b>", ParagraphStyle("ol", fontName="Helvetica-Bold", fontSize=9, textColor=C_MID, leading=13)),
Paragraph(
f"<b>{entity['overall_score']:.2f} / {scoring['max']}.00 — {esc(ov_label)}</b>",
ParagraphStyle("os", fontName="Helvetica-Bold", fontSize=13, textColor=C_WHITE, leading=17, alignment=TA_CENTER)
),
Paragraph(f"<b>Report Date</b><br/>{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"<b>{sec_i}. {esc(pname)}</b>",
ParagraphStyle("ph", fontName="Helvetica-Bold", fontSize=13, textColor=C_BODY, leading=18)),
Paragraph(f"<b>Avg: {p['avg']:.2f}{esc(plabel)}</b>",
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("<b>Question Rationale</b>", 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"<b>Q{q['q_num']} {q['score']}{esc(qlabel)}</b>",
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"<b>Gaps:</b> {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("<b>End of Report</b>", 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 <client_id> <entity_id>")
print(" e.g. python3 pdf_generator.py adeo BU_LM_FRANCE")
sys.exit(1)
generate(sys.argv[1], sys.argv[2])

1425
script.js Normal file

File diff suppressed because it is too large Load diff

240
server/index.js Normal file
View file

@ -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}`));