Skill/scripts/fill_templates.py

132 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Fill the master multi-type template from parts.json (or feed the merge/dedup path).
Per component type present, writes one <Type>.xlsx containing the filled type sheet plus
a Meta sheet (template version, date). Column A ("MPN_make_type") = <MPN>_<make>_<typeid>,
where make = first word of the manufacturer and typeid comes from the taxonomy.
The row/style helpers (part_tag, part_to_row, read_type_rows, write_type_workbook,
template_headers, resolve_version) are imported by gitea_reconcile.py so the merge path
produces byte-for-byte the same styling as a fresh generate - no duplicated logic.
Fresh generate:
python fill_templates.py parts.json --template <template.xlsx> --dest <dir> [--version v1]
"""
import argparse, json, os, re, datetime
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
GREEN="B6D7A8"; GRAY="BFBFBF"
def norm(s): return re.sub(r'[^a-z0-9]', '', str(s).lower())
ALIAS={"induactanceuh":"inductanceuh"} # template header typo -> value key
def make_tag(manufacturer):
w = re.split(r'\s+', str(manufacturer).strip())
first = w[0] if w and w[0] else "NA"
return re.sub(r'[^A-Za-z0-9]', '', first) or "NA"
def part_tag(part):
"""The identity of a part: <MPN>_<make>_<typeid>. Same MPN tag = same part."""
make = part.get("make") or make_tag(part.get("manufacturer",""))
return f'{part.get("mpn","")}_{make}_{part.get("typeid","NA")}'
def style_header(cell):
cell.font=Font(name="Calibri", bold=True); cell.fill=PatternFill("solid", fgColor=GREEN)
s=Side(style="thin", color=GRAY); cell.border=Border(left=s,right=s,top=s,bottom=s)
cell.alignment=Alignment(horizontal="center", vertical="center", wrap_text=True)
def template_headers(template_path, ctype):
"""Canonical header list for a type sheet, or None if the template has no such sheet."""
master=openpyxl.load_workbook(template_path)
if ctype not in master.sheetnames: return None
ms=master[ctype]
return [ms.cell(1,c).value for c in range(1, ms.max_column+1)]
def part_to_row(part, headers):
"""Return (tag, {header: value}) for one part, following the column rules."""
vals={norm(k):v for k,v in part.get("values", {}).items()}
tag=part_tag(part)
row={}
for c,h in enumerate(headers, start=1):
key=norm(h); key=ALIAS.get(key,key)
if c==1: row[h]=tag
elif key=="class": row[h]=part.get("subclass","")
elif key=="manufacturer":row[h]=part.get("manufacturer", vals.get("manufacturer",""))
else: row[h]=vals.get(key,"")
return tag, row
def read_type_rows(path):
"""Read an existing <Type>.xlsx -> (headers, {tag: {header: value}}); Meta sheet ignored."""
wb=openpyxl.load_workbook(path)
ws=next((wb[n] for n in wb.sheetnames if n!="Meta"), None)
if ws is None: return [], {}
headers=[ws.cell(1,c).value for c in range(1, ws.max_column+1)]
rows={}
for r in range(2, ws.max_row+1):
tag=ws.cell(r,1).value
if tag in (None,""): continue
rows[tag]={headers[c-1]: ws.cell(r,c).value for c in range(1, len(headers)+1)}
return headers, rows
def write_type_workbook(ctype, headers, rows_by_tag, version, dest_dir):
"""Write one styled <Type>.xlsx (type sheet + Meta) from an ordered {tag: rowdict}.
Rows are pulled by header name, so callers can pass the canonical template headers
and any missing field simply comes out blank. Returns the filename written."""
os.makedirs(dest_dir, exist_ok=True)
out=openpyxl.Workbook(); ws=out.active; ws.title=ctype[:31]
for c,h in enumerate(headers, start=1):
ws.cell(1,c,h); style_header(ws.cell(1,c))
ws.row_dimensions[1].height=30
r=2
for tag,row in rows_by_tag.items():
for c,h in enumerate(headers, start=1):
ws.cell(r,c, row.get(h,""))
r+=1
for c,h in enumerate(headers, start=1):
w=32 if norm(h)=="description" else (24 if c==1 else max(11,min(18,len(str(h))+2)))
ws.column_dimensions[get_column_letter(c)].width=w
ws.freeze_panes="A2"
meta=out.create_sheet("Meta")
mrows=[("Template Version", version),
("Generated", datetime.date.today().isoformat()),
("Component Type", ctype),
("Parts in file", len(rows_by_tag)),
("Source", "datasheet-extractor skill")]
for i,(k,v) in enumerate(mrows, start=1):
meta.cell(i,1,k).font=Font(bold=True); meta.cell(i,2,v)
meta.column_dimensions["A"].width=20; meta.column_dimensions["B"].width=40
fn=re.sub(r'\s+','_',ctype)+".xlsx"
out.save(os.path.join(dest_dir, fn))
return fn
def resolve_version(template_path, version):
if version: return version
vf=os.path.join(os.path.dirname(template_path), "VERSION")
return "v"+open(vf).read().strip() if os.path.exists(vf) else "v1"
def main():
ap=argparse.ArgumentParser()
ap.add_argument("parts_json"); ap.add_argument("--template", required=True)
ap.add_argument("--dest", required=True); ap.add_argument("--version", default=None)
a=ap.parse_args()
data=json.load(open(a.parts_json, encoding="utf-8"))
version=resolve_version(a.template, a.version)
os.makedirs(a.dest, exist_ok=True)
by={}
for p in data.get("parts", []):
by.setdefault(p["type"], []).append(p)
for ctype, parts in by.items():
headers=template_headers(a.template, ctype)
if headers is None:
print(f"! no template sheet for type '{ctype}' - skipping"); continue
rows_by_tag={}
for p in parts:
tag,row=part_to_row(p, headers)
rows_by_tag[tag]=row
fn=write_type_workbook(ctype, headers, rows_by_tag, version, a.dest)
print(f"{ctype}: {len(rows_by_tag)} row(s) -> {fn} (template {version})")
if __name__=="__main__":
main()