132 lines
5.8 KiB
Python
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()
|