diff --git a/.gitignore b/.gitignore index 495a325..14350c5 100644 --- a/.gitignore +++ b/.gitignore @@ -208,4 +208,10 @@ __marimo__/ scripts/output/ output/item_diff_report/ output/excel_diff_report/ +output/item_diff_report_*/**/*.html +output/item_diff_report_*/assets/ +output/item_diff_report_*/diff.json +output/excel_diff_report_*/*.html +output/excel_diff_report_*/*.json +output/excel_diff_report_*/assets/ output/wiki/ diff --git a/README.md b/README.md index daa6d10..ebed43b 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ That command exports item databases, compares BKDiablo against BTDiablo and reta - `output/skill_trees/`: generated class skill tree markdown - `output/wiki/`: generated static wiki site, ignored as a local/publish artifact -Legacy direct-command defaults such as `output/item_diff_report/` and `output/excel_diff_report/` are ignored. Prefer `scripts/generate_reports.py` for repeatable project output. +Diff report directories contain structured JSON DTOs plus renderer outputs: Markdown for text review and browser-friendly HTML entry points at `index.html`. Legacy direct-command defaults such as `output/item_diff_report/` and `output/excel_diff_report/` are ignored. Prefer `scripts/generate_reports.py` for repeatable project output. ## Wiki diff --git a/docs/wiki-generation-plan.md b/docs/wiki-generation-plan.md index b737e1d..375790f 100644 --- a/docs/wiki-generation-plan.md +++ b/docs/wiki-generation-plan.md @@ -31,8 +31,8 @@ This document defines the first-pass wiki generation strategy for the project. ### Patch And System Pages -- Excel diff outputs from `scripts/cli/compare_all_excel.py` -- Item diff outputs from `scripts/cli/compare_item_db.py` +- Excel diff DTOs and rendered outputs from `scripts/cli/compare_all_excel.py` +- Item diff DTOs and rendered outputs from `scripts/cli/compare_item_db.py` ## First Milestone diff --git a/scripts/cli/compare_all_excel.py b/scripts/cli/compare_all_excel.py index a55960f..e8bfcfc 100644 --- a/scripts/cli/compare_all_excel.py +++ b/scripts/cli/compare_all_excel.py @@ -3,7 +3,7 @@ import argparse from d2lib.repository import D2Repository from d2lib.services import ExcelComparisonService -from d2lib.exporters import MarkdownExporter +from d2lib.exporters import HtmlReportExporter, JsonExporter, MarkdownExporter def get_key_column(filename: str) -> str: mapping = { @@ -15,10 +15,10 @@ def get_key_column(filename: str) -> str: return mapping.get(filename, 'code') def main() -> None: - parser = argparse.ArgumentParser(description="Compare two Diablo II Excel directories and export markdown diffs.") + parser = argparse.ArgumentParser(description="Compare two Diablo II Excel directories and export Markdown and HTML diffs.") parser.add_argument("--new-dir", default="../mods/BKDiablo/bkdiablo.mpq/data/global/excel", help="Path to the new/target Excel directory") parser.add_argument("--old-dir", default="../mods/BTDiablo/btdiablo.mpq/data/global/excel", help="Path to the old/base Excel directory") - parser.add_argument("--out", default="../output/excel_diff_report", help="Output directory for generated markdown diffs") + parser.add_argument("--out", default="../output/excel_diff_report", help="Output directory for generated diff reports") args = parser.parse_args() bk_dir = args.new_dir @@ -27,6 +27,8 @@ def main() -> None: repo = D2Repository(".") exporter = MarkdownExporter() + html_exporter = HtmlReportExporter() + json_exporter = JsonExporter() summary_rows = [] files_bk = {f for f in os.listdir(bk_dir) if f.endswith('.txt')} @@ -40,7 +42,15 @@ def main() -> None: diff = ExcelComparisonService.compare_tables(table_bk, table_bt, get_key_column(filename), filename) report_name = filename.replace('.txt', '.md') + json_exporter.export( + { + "schema": "bt-bkdiff.excel-diff.v1", + "diff": diff, + }, + os.path.join(report_dir, report_name.replace('.md', '.json')), + ) exporter.export_excel_diff(diff, os.path.join(report_dir, report_name)) + html_exporter.export_excel_diff(diff, os.path.join(report_dir, report_name.replace('.md', '.html'))) summary_rows.append({ "filename": filename, "report_name": report_name, @@ -71,6 +81,14 @@ def main() -> None: os.makedirs(report_dir, exist_ok=True) with open(os.path.join(report_dir, "SUMMARY.md"), "w", encoding="utf-8") as f: f.write("\n".join(summary_lines).strip() + "\n") + json_exporter.export( + { + "schema": "bt-bkdiff.excel-diff-summary.v1", + "files": summary_rows, + }, + os.path.join(report_dir, "summary.json"), + ) + html_exporter.export_excel_summary(summary_rows, os.path.join(report_dir, "index.html")) print(f"Reports generated in {report_dir}") diff --git a/scripts/cli/compare_item_db.py b/scripts/cli/compare_item_db.py index a7dc30b..7d5620e 100644 --- a/scripts/cli/compare_item_db.py +++ b/scripts/cli/compare_item_db.py @@ -6,7 +6,7 @@ from typing import Dict, List, Any from d2lib.repository import D2Repository from d2lib.services import ItemComparisonService -from d2lib.exporters import MarkdownExporter +from d2lib.exporters import HtmlReportExporter, JsonExporter, MarkdownExporter def load_json_db(db_dir: str) -> Dict[str, Dict[str, Any]]: # type -> id -> item @@ -36,10 +36,10 @@ def load_json_db(db_dir: str) -> Dict[str, Dict[str, Any]]: return types def main() -> None: - parser = argparse.ArgumentParser(description="Compare two exported item databases and generate markdown reports.") + parser = argparse.ArgumentParser(description="Compare two exported item databases and generate Markdown and HTML reports.") parser.add_argument("--new-db", default="../exports/item_db", help="Path to the new/target exported JSON item database") parser.add_argument("--old-db", default="../exports/item_db_bt", help="Path to the old/base exported JSON item database") - parser.add_argument("--out", default="../output/item_diff_report", help="Output directory for generated markdown diffs") + parser.add_argument("--out", default="../output/item_diff_report", help="Output directory for generated diff reports") args = parser.parse_args() bk_json_dir = args.new_db @@ -75,9 +75,21 @@ def main() -> None: 'removed': combined_removed, 'modified': combined_modified } - + + json_exporter = JsonExporter() + json_exporter.export( + { + "schema": "bt-bkdiff.item-diff.v1", + "type_counts": type_counts, + "diff": combined_diff, + }, + os.path.join(out_dir, "diff.json"), + ) + exporter = MarkdownExporter() exporter.export_item_diff(combined_diff, out_dir) + html_exporter = HtmlReportExporter() + html_exporter.export_item_diff(combined_diff, out_dir, type_counts=type_counts) # Inject breakdown into SUMMARY.md summary_path = os.path.join(out_dir, "SUMMARY.md") diff --git a/scripts/d2lib/exporters.py b/scripts/d2lib/exporters.py index 0c4e97d..3038069 100644 --- a/scripts/d2lib/exporters.py +++ b/scripts/d2lib/exporters.py @@ -2,6 +2,7 @@ import json import re import difflib +import html from typing import List, Dict, Any, Tuple from d2lib.models import AnalyzedItemDTO, RunewordDTO, ExcelDiffDTO, CubeRecipeDTO, ItemDiffDTO, SkillTreeDTO @@ -344,3 +345,371 @@ def export_excel_diff(self, diff: ExcelDiffDTO, output_path: str): lines.append(f"- `{self.escape_markdown(col)}`: {old_fmt} (Old) → {new_fmt} (New)") with open(output_path, 'w', encoding='utf-8') as f: f.write("\n".join(lines).strip() + "\n") + + +class HtmlReportExporter(BaseExporter): + """Writes browser-friendly versions of the generated diff reports.""" + + REPORT_CSS = """ + :root { + color-scheme: light; + --bg: #f7f5f0; + --surface: #fffdf8; + --surface-2: #f0ebe1; + --text: #241f19; + --muted: #6c6258; + --line: #d8cfc2; + --old: #7a7168; + --old-bg: #ebe6df; + --new: #075c8f; + --new-bg: #dceefa; + --accent: #8c4b2f; + } + * { box-sizing: border-box; } + html { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } + body { margin: 0; background: var(--bg); color: var(--text); line-height: 1.5; } + .page { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 56px; } + header { border-bottom: 1px solid var(--line); margin-bottom: 24px; padding-bottom: 18px; } + h1 { margin: 0 0 8px; font-size: clamp(1.8rem, 4vw, 3rem); line-height: 1.05; } + h2 { margin: 28px 0 12px; font-size: 1.2rem; } + h3 { margin: 22px 0 10px; font-size: 1rem; color: var(--accent); } + a { color: #0a5b86; text-decoration-thickness: 1px; text-underline-offset: 3px; } + .muted { color: var(--muted); margin: 0; } + .nav { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 0; } + .nav a, .pill { border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px; background: var(--surface); font-size: .9rem; text-decoration: none; } + .card { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 16px; margin: 14px 0; } + .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } + .stat { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 14px; } + .stat strong { display: block; font-size: 1.6rem; } + table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--line); margin: 12px 0 18px; } + th, td { border-bottom: 1px solid var(--line); padding: 9px 10px; text-align: left; vertical-align: top; } + th { background: var(--surface-2); font-size: .86rem; color: var(--muted); } + tr:last-child td { border-bottom: 0; } + code { background: var(--surface-2); border-radius: 4px; padding: 1px 4px; } + .diff-old { color: var(--old); background: var(--old-bg); border-radius: 4px; padding: 1px 3px; } + .diff-new { color: var(--new); background: var(--new-bg); border-radius: 4px; padding: 1px 3px; } + .empty { color: var(--muted); font-style: italic; } + .item-title { font-weight: 700; margin: 0 0 8px; } + .properties { margin: 8px 0 0; padding-left: 20px; } + .properties li { margin: 3px 0; } + @media (max-width: 720px) { + .page { width: min(100% - 20px, 1180px); padding-top: 18px; } + th, td { padding: 8px; } + table { display: block; overflow-x: auto; } + } + """ + + def export(self, data: Any, output_path: str): + raise NotImplementedError + + @staticmethod + def escape(value: Any) -> str: + if value is None: + return "" + text = re.sub(r'ÿc.', '', str(value)) + return html.escape(text, quote=True) + + @staticmethod + def report_filename(value: str, ext: str = ".html") -> str: + safe = str(value).lower().replace(" ", "_").replace("/", "_").replace("\\", "_") + safe = re.sub(r"[^a-z0-9_.-]+", "_", safe).strip("._") + return f"{safe or 'other'}{ext}" + + @staticmethod + def _stat_key(text: str) -> str: + text = re.sub(r'\s+', ' ', re.sub(r'ÿc.', '', text or '')).strip() + return re.sub(r'[+-]?\d+(?:-\d+)?', '', text).strip().lower() + + def _ensure_assets(self, output_dir: str) -> None: + assets_dir = os.path.join(output_dir, "assets") + os.makedirs(assets_dir, exist_ok=True) + with open(os.path.join(assets_dir, "report.css"), "w", encoding="utf-8") as f: + f.write(self.REPORT_CSS.strip() + "\n") + + def _page(self, title: str, body: str, subtitle: str = "", css_href: str = "assets/report.css") -> str: + subtitle_html = f'

{self.escape(subtitle)}

' if subtitle else "" + return f""" + + + + + {self.escape(title)} + + + +
+
+

{self.escape(title)}

+ {subtitle_html} +
+ {body} +
+ + +""" + + def _write_page(self, path: str, title: str, body: str, subtitle: str = "", css_href: str = "assets/report.css") -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(self._page(title, body, subtitle, css_href)) + + def _diff_pair(self, old_s: Any, new_s: Any) -> Tuple[str, str]: + old_text = "" if old_s is None else str(old_s) + new_text = "" if new_s is None else str(new_s) + if not old_text and not new_text: + return 'blank', 'blank' + if not old_text: + return 'blank', f'{self.escape(new_text)}' + if not new_text or new_text == "(removed)": + return f'{self.escape(old_text)}', '(removed)' + + def normalize(text: str) -> str: + return re.sub(r'\s+', ' ', re.sub(r'ÿc.', '', text)).strip() + + if normalize(old_text) == normalize(new_text): + escaped = self.escape(old_text) + return escaped, escaped + + token_pattern = r'[+-]?\d+(?:-\d+)?%?|[a-zA-Z]+|[^\w\s]|\s+' + old_tokens = re.findall(token_pattern, old_text) + new_tokens = re.findall(token_pattern, new_text) + matcher = difflib.SequenceMatcher(None, old_tokens, new_tokens) + + def render(tokens: List[str], is_old: bool) -> str: + parts = [] + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + part = "".join(tokens[i1:i2] if is_old else tokens[j1:j2]) + if not part: + continue + escaped = self.escape(part) + changed = (is_old and tag in ["replace", "delete"]) or (not is_old and tag in ["replace", "insert"]) + if changed: + css = "diff-old" if is_old else "diff-new" + parts.append(f'{escaped}') + elif tag == "equal": + parts.append(escaped) + return "".join(parts) or 'blank' + + return render(old_tokens, True), render(new_tokens, False) + + def _align_properties(self, old_props: List[str], new_props: List[str]) -> List[Tuple[str, str]]: + aligned = [] + old_used, new_used = set(), set() + for i, old_prop in enumerate(old_props): + for j, new_prop in enumerate(new_props): + if j not in new_used and old_prop == new_prop: + aligned.append((old_prop, new_prop)) + old_used.add(i) + new_used.add(j) + break + for i, old_prop in enumerate(old_props): + if i in old_used: + continue + old_key = self._stat_key(old_prop) + for j, new_prop in enumerate(new_props): + if j not in new_used and old_key and old_key == self._stat_key(new_prop): + aligned.append((old_prop, new_prop)) + old_used.add(i) + new_used.add(j) + break + for i, old_prop in enumerate(old_props): + if i not in old_used: + aligned.append((old_prop, "(removed)")) + for j, new_prop in enumerate(new_props): + if j not in new_used: + aligned.append(("", new_prop)) + return aligned + + def export_item_diff(self, diff: ItemDiffDTO, output_dir: str, type_counts: Dict[str, Dict[str, int]] | None = None) -> None: + os.makedirs(output_dir, exist_ok=True) + self._ensure_assets(output_dir) + type_counts = type_counts or {} + + def count_for(key: str, bucket: str) -> int: + return int(type_counts.get(key, {}).get(bucket, 0)) + + rows = [] + for key, label in [("uniques", "Uniques"), ("sets", "Sets"), ("runewords", "Runewords")]: + rows.append( + f"{label}{count_for(key, 'added')}{count_for(key, 'removed')}{count_for(key, 'modified')}" + ) + rows.append( + f"Total{len(diff['added'])}{len(diff['removed'])}{len(diff['modified'])}" + ) + summary_body = f""" + + + + {''.join(rows)} +
CategoryAddedRemovedModified
+""" + self._write_page(os.path.join(output_dir, "index.html"), "Item Database Comparison Summary", summary_body) + + def classify(item: Dict[str, Any]) -> str: + raw_row = item.get("raw_row", {}) + if "runes" in item or "Rune1" in raw_row: + return "runewords" + if "set" in raw_row: + return "sets" + return "uniques" + + def title_for(item: Dict[str, Any]) -> str: + return str(item.get("display_name") or item.get("name") or item.get("id") or "Unknown") + + def base_for(item: Dict[str, Any]) -> str: + return str(item.get("base_item", "") or ", ".join(item.get("base_items", []))) + + def group_for(item: Dict[str, Any], family: str) -> str: + if family == "runewords": + bases = item.get("base_items", []) + return str(bases[0] if bases else "Other") + return str(item.get("item_type", "Other")) + + def write_toc(items_dict: Dict[str, Dict[str, Any]], base_name: str, is_modified: bool = False) -> None: + family_groups: Dict[str, Dict[str, List[Dict[str, Any]]]] = {"uniques": {}, "sets": {}, "runewords": {}} + for key, item in items_dict.items(): + item_copy = dict(item) + item_copy["original_key"] = key + family = classify(item_copy) + group = group_for(item_copy, family) + family_groups[family].setdefault(group, []).append(item_copy) + + toc_parts = [''] + family_labels = {"uniques": "Uniques", "runewords": "Runewords", "sets": "Sets"} + for family in ["uniques", "runewords", "sets"]: + groups = family_groups[family] + if not groups: + continue + toc_parts.append(f"

{family_labels[family]}

") + if len(toc_parts) == 1: + toc_parts.append('

No items in this bucket.

') + self._write_page(os.path.join(output_dir, f"{base_name.upper()}.html"), f"{base_name} Items Breakdown", "\n".join(toc_parts)) + + write_toc(diff["added"], "Added") + removed_cards = [''] + if diff["removed"]: + removed_cards.append("") + else: + removed_cards.append('

No removed items.

') + self._write_page(os.path.join(output_dir, "REMOVED.html"), "Removed Items", "\n".join(removed_cards)) + write_toc(diff["modified"], "Modified", is_modified=True) + + def _write_item_group_page(self, items: List[Dict[str, Any]], path: str, title: str, is_modified: bool) -> None: + cards = [''] + for item in sorted(items, key=title_for_item): + key = item.get("original_key", "") + if is_modified: + base_old, base_new = self._diff_pair(item.get("bt_base", ""), item.get("bk_base", "")) + lvl_old, lvl_new = self._diff_pair(item.get("bt_lvl", ""), item.get("bk_lvl", "")) + prop_rows = [] + for old_prop, new_prop in self._align_properties(item.get("bt_props", []), item.get("bk_props", [])): + old_html, new_html = self._diff_pair(old_prop, new_prop) + prop_rows.append(f"{old_html}{new_html}") + cards.append(f""" +
+

{self.escape(item.get('name', 'Unknown'))} {self.escape(key)}

+ + + + + + + {''.join(prop_rows)} + +
OldNew
Base Item: {base_old}Base Item: {base_new}
Level Requirement: {lvl_old}Level Requirement: {lvl_new}
PropertiesProperties
+
+""") + else: + props = "".join(f"
  • {self.escape(prop.get('resolved_text', ''))}
  • " for prop in item.get("properties", [])) + cards.append(f""" +
    +

    {self.escape(item.get('display_name') or item.get('name') or 'Unknown')} {self.escape(key)}

    +

    Base Item: {self.escape(item.get('base_item', '') or ', '.join(item.get('base_items', [])))}

    +

    Level Requirement: {self.escape(item.get('lvl_req', '0'))}

    + +
    +""") + self._write_page(path, title, "\n".join(cards), css_href="../../assets/report.css") + + def export_excel_diff(self, diff: ExcelDiffDTO, output_path: str) -> None: + self._ensure_assets(os.path.dirname(output_path)) + nav = '' + sections = [nav, f'

    Key column used: {self.escape(diff["key_used"])}

    '] + if diff["added_cols"]: + sections.append(f"

    Added Columns

    {self.escape(', '.join(diff['added_cols']))}

    ") + if diff["removed_cols"]: + sections.append(f"

    Removed Columns

    {self.escape(', '.join(diff['removed_cols']))}

    ") + if diff["added_rows"]: + rows = "".join(f"
  • {self.escape(row)}
  • " for row in diff["added_rows"]) + sections.append(f"

    Added Rows ({len(diff['added_rows'])})

    ") + if diff["removed_rows"]: + rows = "".join(f"
  • {self.escape(row)}
  • " for row in diff["removed_rows"]) + sections.append(f"

    Removed Rows ({len(diff['removed_rows'])})

    ") + if diff["modified_rows"]: + sections.append(f"

    Modified Rows ({len(diff['modified_rows'])})

    ") + for key, row_diff in sorted(diff["modified_rows"].items()): + rows = [] + for col, values in row_diff.items(): + old_html, new_html = self._diff_pair(values["bt_old"], values["bk_new"]) + rows.append(f"{self.escape(col)}{old_html}{new_html}") + sections.append(f""" +
    +

    {self.escape(key)}

    + + + {''.join(rows)} +
    ColumnOldNew
    +
    +""") + self._write_page(output_path, f"Differences for {diff['filename']}", "\n".join(sections)) + + def export_excel_summary(self, summary_rows: List[Dict[str, Any]], output_path: str) -> None: + self._ensure_assets(os.path.dirname(output_path)) + rows = [] + for row in summary_rows: + html_report = str(row["report_name"]).replace(".md", ".html") + rows.append( + f"{self.escape(row['filename'])}" + f"{row['added_cols']}{row['removed_cols']}" + f"{row['added_rows']}{row['removed_rows']}{row['modified_rows']}" + ) + totals = { + "added_cols": sum(row["added_cols"] for row in summary_rows), + "removed_cols": sum(row["removed_cols"] for row in summary_rows), + "added_rows": sum(row["added_rows"] for row in summary_rows), + "removed_rows": sum(row["removed_rows"] for row in summary_rows), + "modified_rows": sum(row["modified_rows"] for row in summary_rows), + } + rows.append( + f"Total{totals['added_cols']}{totals['removed_cols']}" + f"{totals['added_rows']}{totals['removed_rows']}{totals['modified_rows']}" + ) + body = f""" + + + {''.join(rows)} +
    FileAdded ColsRemoved ColsAdded RowsRemoved RowsModified Rows
    +""" + self._write_page(output_path, "Excel Diff Summary", body) + + +def title_for_item(item: Dict[str, Any]) -> str: + return str(item.get("display_name") or item.get("name") or "") diff --git a/scripts/d2lib/wiki.py b/scripts/d2lib/wiki.py index 47eeec9..24d227b 100644 --- a/scripts/d2lib/wiki.py +++ b/scripts/d2lib/wiki.py @@ -11,6 +11,36 @@ TEMPLATE_DIR = os.path.join(MODULE_DIR, "wiki_templates") ASSET_SOURCE_DIR = os.path.join(MODULE_DIR, "wiki_assets") ITEM_FAMILIES = ("unique", "set", "runeword") +REPORT_SOURCES = ( + { + "title": "Item Diff: BKDiablo vs Retail", + "description": "Item database changes comparing BKDiablo against retail data.", + "source_dir": "item_diff_report_retail_bk", + "output_dir": "reports/items/retail-bk", + "source_kind": "item_diff", + }, + { + "title": "Item Diff: BKDiablo vs BTDiablo", + "description": "Item database changes comparing BKDiablo against BTDiablo.", + "source_dir": "item_diff_report_bt_bk", + "output_dir": "reports/items/bt-bk", + "source_kind": "item_diff", + }, + { + "title": "Excel Diff: BKDiablo vs Retail", + "description": "Raw Excel table changes comparing BKDiablo against retail data.", + "source_dir": "excel_diff_report_retail_bk", + "output_dir": "reports/excel/retail-bk", + "source_kind": "excel_diff", + }, + { + "title": "Excel Diff: BKDiablo vs BTDiablo", + "description": "Raw Excel table changes comparing BKDiablo against BTDiablo.", + "source_dir": "excel_diff_report_bt_bk", + "output_dir": "reports/excel/bt-bk", + "source_kind": "excel_diff", + }, +) def _slugify(value: str) -> str: @@ -52,6 +82,10 @@ def class_output_path(slug: str) -> str: def patch_notes_output_path() -> str: return "patch-notes/full-patch-notes-draft/index.html" + @staticmethod + def reports_index_output_path() -> str: + return "reports/index.html" + @staticmethod def route_from_output_path(output_path: str) -> str: normalized = output_path.replace("\\", "/") @@ -156,7 +190,8 @@ def generate(self) -> None: self._write_assets() item_entries = self._write_item_pages(items, old_item_index) class_entries = self._write_class_pages(class_pages) - self._write_indexes(item_entries, class_entries) + report_entries = self._publish_reports() + self._write_indexes(item_entries, class_entries, report_entries) self._write_patch_notes_draft(item_entries, class_entries) self._write_item_index_data(item_entries) self._write_manifest() @@ -375,6 +410,7 @@ def _write_indexes( self, item_entries: Dict[str, List[Dict[str, str]]], class_entries: List[Dict[str, str]], + report_entries: List[Dict[str, str]], ) -> None: self._write_page( title="BT Diablo Data Wiki", @@ -385,6 +421,7 @@ def _write_indexes( item_counts={family: len(item_entries[family]) for family in ITEM_FAMILIES}, class_count=len(class_entries), total_items=sum(len(entries) for entries in item_entries.values()), + reports=report_entries, ) group_to_types: Dict[str, set[str]] = {} @@ -414,6 +451,15 @@ def _write_indexes( classes=class_entries, ) + self._write_page( + title="Reports | BT Diablo Data Wiki", + output_path=WikiRoutes.reports_index_output_path(), + template_name="reports_index.html", + category="index", + source_files=[], + reports=report_entries, + ) + def _write_patch_notes_draft( self, item_entries: Dict[str, List[Dict[str, str]]], @@ -446,6 +492,48 @@ def _write_item_index_data(self, item_entries: Dict[str, List[Dict[str, str]]]) ] self.writer.write_text("data/items-index.json", json.dumps(rows, indent=2)) + def _publish_reports(self) -> List[Dict[str, str]]: + reports_root = os.path.normpath(os.path.join(self.output_dir, "..")) + entries: List[Dict[str, str]] = [] + for report in REPORT_SOURCES: + source_dir = os.path.join(reports_root, report["source_dir"]) + if not os.path.isdir(source_dir): + continue + + copied_files = 0 + for root, _, files in os.walk(source_dir): + for filename in files: + if not filename.endswith((".html", ".css", ".json")): + continue + source_path = os.path.join(root, filename) + rel_source = os.path.relpath(source_path, source_dir).replace("\\", "/") + rel_output = f"{report['output_dir']}/{rel_source}" + self.writer.copy_asset(source_path, rel_output) + copied_files += 1 + + if copied_files == 0: + continue + + href = WikiRoutes.route_from_output_path(f"{report['output_dir']}/index.html") + entry = { + "title": report["title"], + "href": href, + "summary": report["description"], + "source_dir": report["source_dir"], + "source_kind": report["source_kind"], + "file_count": str(copied_files), + } + entries.append(entry) + self.manifest.append( + { + "title": report["title"], + "path": href, + "category": "report", + "sources": [report["source_dir"]], + } + ) + return entries + def _write_manifest(self) -> None: self.writer.write_text("manifest.json", json.dumps(self.manifest, indent=2)) diff --git a/scripts/d2lib/wiki_assets/site.css b/scripts/d2lib/wiki_assets/site.css index 79358ab..b13e480 100644 --- a/scripts/d2lib/wiki_assets/site.css +++ b/scripts/d2lib/wiki_assets/site.css @@ -167,6 +167,10 @@ code { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); } +.card-grid.two-up { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} + .metric-card, .link-card, .content-section, diff --git a/scripts/d2lib/wiki_templates/base.html b/scripts/d2lib/wiki_templates/base.html index 6ec12c6..d1ac87a 100644 --- a/scripts/d2lib/wiki_templates/base.html +++ b/scripts/d2lib/wiki_templates/base.html @@ -17,6 +17,7 @@ Home All Items Classes + Reports Patch Notes diff --git a/scripts/d2lib/wiki_templates/home.html b/scripts/d2lib/wiki_templates/home.html index 9482fe4..f6bbac8 100644 --- a/scripts/d2lib/wiki_templates/home.html +++ b/scripts/d2lib/wiki_templates/home.html @@ -22,6 +22,11 @@

    All Items

    Browse every generated item page with quick search and family filters.

    Open page

    Classes

    Open class skill pages built from the current skill tree exports.

    Open page
    -

    Patch Notes Draft

    Use the generated patch-notes page as a website-facing summary scaffold.

    Open page
    +

    Reports

    Open the generated item and raw Excel diff reports as browser-friendly pages.

    Open page
    +
    +
    + {% for report in reports %} +

    {{ report.title }}

    {{ report.summary }}

    Open report
    + {% endfor %}
    {% endblock %} diff --git a/scripts/d2lib/wiki_templates/reports_index.html b/scripts/d2lib/wiki_templates/reports_index.html new file mode 100644 index 0000000..e583a0d --- /dev/null +++ b/scripts/d2lib/wiki_templates/reports_index.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% from "components.html" import hero %} +{% block content %} +{{ hero( + "Reports", + "Browser-friendly generated diff reports", + "Open the item and raw Excel comparison reports that are produced from structured DTOs during the project rebuild.", + [ + {"label": "DTO", "tone": "default"}, + {"label": "HTML", "tone": "default"}, + {"label": "Markdown", "tone": "default"}, + {"label": "Generated Reports", "tone": "accent"}, + ] +) }} +
    + {% for report in reports %} + +

    {{ report.title }}

    +

    {{ report.summary }}

    +

    {{ report.file_count }} published files from {{ report.source_dir }}

    + Open report +
    + {% else %} +
    +

    No Reports Generated

    +

    Run python scripts/generate_reports.py before generating the wiki.

    +
    + {% endfor %} +
    +{% endblock %} diff --git a/tests/test_html_report_exporter.py b/tests/test_html_report_exporter.py new file mode 100644 index 0000000..a9b2ad8 --- /dev/null +++ b/tests/test_html_report_exporter.py @@ -0,0 +1,110 @@ +import os +import sys +import tempfile +import unittest + + +SCRIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts") +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) + +from d2lib.exporters import HtmlReportExporter + + +class TestHtmlReportExporter(unittest.TestCase): + def test_exports_item_diff_pages(self): + diff = { + "added": { + "new-sword": { + "display_name": "New Sword", + "base_item": "Sword", + "item_type": "Sword", + "lvl_req": "12", + "properties": [{"resolved_text": "+10 to Strength"}], + "raw_row": {}, + } + }, + "removed": {}, + "modified": { + "old-helm": { + "name": "Old Helm", + "bt_base": "Cap", + "bk_base": "War Hat", + "bt_lvl": "3", + "bk_lvl": "12", + "bt_props": ["+5 to Life"], + "bk_props": ["+15 to Life"], + "item_type": "Helm", + "raw_row": {}, + } + }, + } + + with tempfile.TemporaryDirectory() as temp_dir: + HtmlReportExporter().export_item_diff( + diff, + temp_dir, + type_counts={"uniques": {"added": 1, "removed": 0, "modified": 1}}, + ) + + index_path = os.path.join(temp_dir, "index.html") + added_path = os.path.join(temp_dir, "added", "uniques", "sword.html") + modified_path = os.path.join(temp_dir, "modified", "uniques", "helm.html") + + self.assertTrue(os.path.exists(index_path)) + self.assertTrue(os.path.exists(added_path)) + self.assertTrue(os.path.exists(modified_path)) + + with open(modified_path, "r", encoding="utf-8") as f: + modified_html = f.read() + self.assertIn("Old Helm", modified_html) + self.assertIn("diff-old", modified_html) + self.assertIn("diff-new", modified_html) + + def test_exports_excel_diff_pages(self): + diff = { + "filename": "gems.txt", + "key_used": "name", + "added_cols": ["newCol"], + "removed_cols": [], + "added_rows": ["new rune"], + "removed_rows": [], + "modified_rows": { + "ber rune": { + "weaponMod1Code": {"bt_old": "crush", "bk_new": "addxp"}, + } + }, + } + + with tempfile.TemporaryDirectory() as temp_dir: + exporter = HtmlReportExporter() + report_path = os.path.join(temp_dir, "gems.html") + exporter.export_excel_diff(diff, report_path) + exporter.export_excel_summary( + [ + { + "filename": "gems.txt", + "report_name": "gems.md", + "added_cols": 1, + "removed_cols": 0, + "added_rows": 1, + "removed_rows": 0, + "modified_rows": 1, + } + ], + os.path.join(temp_dir, "index.html"), + ) + + with open(report_path, "r", encoding="utf-8") as f: + report_html = f.read() + self.assertIn("Differences for gems.txt", report_html) + self.assertIn("diff-old", report_html) + self.assertIn("diff-new", report_html) + + with open(os.path.join(temp_dir, "index.html"), "r", encoding="utf-8") as f: + index_html = f.read() + self.assertIn('href="gems.html"', index_html) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wiki_generator.py b/tests/test_wiki_generator.py index 8d312b5..fb70367 100644 --- a/tests/test_wiki_generator.py +++ b/tests/test_wiki_generator.py @@ -21,6 +21,7 @@ def setUp(self): self.skill_trees = os.path.join(self.root, "skill_trees") self.output = os.path.join(self.root, "wiki") self._write_fixture_data() + self._write_report_fixture_data() def tearDown(self): self.temp_dir.cleanup() @@ -114,6 +115,16 @@ def _write_fixture_data(self): "| Damage | Linear (+1) | +1 | +10 | +20 | +30 | -- |\n" ) + def _write_report_fixture_data(self): + report_root = os.path.join(self.root, "item_diff_report_retail_bk") + os.makedirs(os.path.join(report_root, "assets"), exist_ok=True) + with open(os.path.join(report_root, "index.html"), "w", encoding="utf-8") as f: + f.write("Item Report") + with open(os.path.join(report_root, "diff.json"), "w", encoding="utf-8") as f: + json.dump({"schema": "test"}, f) + with open(os.path.join(report_root, "assets", "report.css"), "w", encoding="utf-8") as f: + f.write("body { color: black; }") + def _generate(self): generator = WikiGenerator( self.item_db, @@ -139,6 +150,10 @@ def test_generation_writes_pretty_routes_and_manifest(self): os.path.join(self.output, "items", "unique", "twin-item-second-base-2", "index.html"), os.path.join(self.output, "items", "index.html"), os.path.join(self.output, "classes", "amazon", "index.html"), + os.path.join(self.output, "reports", "index.html"), + os.path.join(self.output, "reports", "items", "retail-bk", "index.html"), + os.path.join(self.output, "reports", "items", "retail-bk", "diff.json"), + os.path.join(self.output, "reports", "items", "retail-bk", "assets", "report.css"), ] for path in expected_paths: self.assertTrue(os.path.exists(path), path) @@ -149,6 +164,8 @@ def test_generation_writes_pretty_routes_and_manifest(self): self.assertIn("items/unique/twin-item/", manifest_paths) self.assertIn("items/unique/twin-item-second-base/", manifest_paths) self.assertIn("items/", manifest_paths) + self.assertIn("reports/", manifest_paths) + self.assertIn("reports/items/retail-bk/", manifest_paths) def test_item_index_json_schema_and_ordering(self): self._generate() @@ -176,8 +193,14 @@ def test_templates_render_item_and_index_pages(self): self.assertIn("Twin Item", item_page) self.assertIn("Structured diff view using Retail (Old) and BKDiablo (New).", item_page) + self.assertIn('href="../../../reports/"', item_page) self.assertIn('data-item-index-url="../data/items-index.json"', items_page) + with open(os.path.join(self.output, "reports", "index.html"), "r", encoding="utf-8") as f: + reports_page = f.read() + self.assertIn("Item Diff: BKDiablo vs Retail", reports_page) + self.assertIn("reports/items/retail-bk/", reports_page) + with open(os.path.join(self.output, "items", "runeword", "practice", "index.html"), "r", encoding="utf-8") as f: runeword_page = f.read() self.assertIn("Properties from Runes", runeword_page)