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"""
+
+ Added
+ Removed
+ Modified
+
+
+ Category Added Removed Modified
+ {''.join(rows)}
+
+"""
+ 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 = ['Summary ']
+ 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]} ")
+ for group, grouped_items in sorted(groups.items()):
+ filename = self.report_filename(group)
+ rel = f"{base_name.lower()}/{family}/{filename}"
+ self._write_item_group_page(
+ grouped_items,
+ os.path.join(output_dir, base_name.lower(), family, filename),
+ f"{base_name} {family_labels[family][:-1] if family != 'runewords' else group} {group if family != 'runewords' else 'Runewords'}",
+ is_modified,
+ )
+ toc_parts.append(f'{self.escape(group)} ({len(grouped_items)}) ')
+ toc_parts.append(" ")
+ 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 = ['Summary ']
+ if diff["removed"]:
+ removed_cards.append("")
+ for key, item in sorted(diff["removed"].items()):
+ removed_cards.append(f"{self.escape(title_for(item))} {self.escape(key)} ")
+ 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 = ['Summary ']
+ 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)}
+
+ Old New
+
+ Base Item: {base_old}Base Item: {base_new}
+ Level Requirement: {lvl_old}Level Requirement: {lvl_new}
+ Properties Properties
+ {''.join(prop_rows)}
+
+
+
+""")
+ 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 = 'Summary '
+ 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)}
+
+ Column Old New
+ {''.join(rows)}
+
+
+""")
+ 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"""
+
+ File Added Cols Removed Cols Added Rows Removed Rows Modified Rows
+ {''.join(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 @@
+
{% 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"},
+ ]
+) }}
+
+{% 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)