From 5aa7be281624aeee63e00727ae517753189cd7a5 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 28 Aug 2026 08:04:23 +0200 Subject: [PATCH] Modernize the comparison UI and add a review workflow Move the pages out of Python f-strings into Jinja templates and static assets, and turn the compare page into something a review can actually be done in: update the reference from the page, see whether a diff was accepted, and step through the documents. Compare page: - "Update reference" copies the monitored file over the reference one - files whose reference was updated are marked accepted for the session; the mark is dropped again when the monitored file changes afterwards - previous/next navigation with a position counter and a "diffs only" toggle that skips files that already match - view modes (A / A|B / B), a collapsible diff panel and keyboard shortcuts, which also work while the focus is inside a pane - the diff strip paints differing pixels red, draws the differing regions as overlay bars (a few pixels vanish when the image is scaled into the column), tracks the visible part of the document and jumps both panes to a clicked position Overview page: the per-status counts moved into the filter buttons, the status cell is a plain table cell again so the columns line up, and statuses are patched in place instead of reloading the whole page. Fixes found on the way: - a comparison could start before submit() had stored its future, which raised inside the worker and left the file on "pending" forever - a failing comparison left the same permanent "pending" - screenshots are cropped to the content, so positions and areas derived from a diff refer to the page instead of the very tall render window - rendered diffs are cached by the inputs' mtimes instead of re-rendering per request - paths are resolved against their root before serving or copying update_ref is now POST /api/update_ref/; the previous GET endpoint and /status are kept for scripted use. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019rpRWyT4az4Lz1irRRWrPD --- README.md | 36 ++ pyproject.toml | 3 + src/htmlcmp/compare_output_server.py | 704 ++++++++++++--------------- src/htmlcmp/html_render_diff.py | 28 +- src/htmlcmp/static/common.js | 38 ++ src/htmlcmp/static/compare.js | 340 +++++++++++++ src/htmlcmp/static/index.js | 152 ++++++ src/htmlcmp/static/style.css | 253 ++++++++++ src/htmlcmp/templates/compare.html | 104 ++++ src/htmlcmp/templates/index.html | 69 +++ src/htmlcmp/templates/missing.html | 15 + 11 files changed, 1345 insertions(+), 397 deletions(-) create mode 100644 src/htmlcmp/static/common.js create mode 100644 src/htmlcmp/static/compare.js create mode 100644 src/htmlcmp/static/index.js create mode 100644 src/htmlcmp/static/style.css create mode 100644 src/htmlcmp/templates/compare.html create mode 100644 src/htmlcmp/templates/index.html create mode 100644 src/htmlcmp/templates/missing.html diff --git a/README.md b/README.md index 15ed08e..b4ea3b4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,42 @@ Provides various entry points to run: Used for regression testing in https://github.com/opendocument-app/OpenDocument.core. +## Reviewing differences in the browser + +`compare-html-server` serves two pages: + +- the **overview** lists every comparable file with its status (`same`, + `different`, `pending`), a search box, status filters with counts, and an + `update ref` button per file plus `Update all in view` +- the **compare page** (click a path) shows reference (A) and monitored (B) + side by side with synchronised scrolling, a diff strip in between, and the + controls to work through a review + +On the compare page: + +- **Update reference** copies the monitored file over the reference one; the + file is then marked `✓ accepted` for the rest of the session (the mark + disappears again if the monitored file changes afterwards) +- **← / →** step to the previous and next document; tick *diffs only* to skip + files that already match +- the **diff strip** maps the whole page into the column: red bars mark the + regions that differ, the blue box marks the visible part of the document, and + a click jumps both panes to that position + +| key | action | +| --- | --- | +| `←` / `k`, `→` / `j` | previous / next document | +| `1` / `2` / `3` | reference only / side by side / monitored only | +| `d` | toggle the diff strip | +| `u` | update the reference | +| `r` | reload both panes | +| `Esc` | back to the overview | +| `?` | shortcut help | + +Comparison results are computed live, so the pages update themselves while the +monitored directory is being regenerated. Accepted marks are kept in memory +only and are gone after a restart. + ## Install via PyPI ```bash diff --git a/pyproject.toml b/pyproject.toml index 5c81be1..1e1a360 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ download = "https://pypi.org/project/pyodr/#files" tracker = "https://github.com/opendocument-app/compare-html/issues" "release notes" = "https://github.com/opendocument-app/compare-html/releases" +[tool.setuptools.package-data] +htmlcmp = ["templates/*.html", "static/*.css", "static/*.js"] + [tool.pytest.ini_options] pythonpath = ["src"] diff --git a/src/htmlcmp/compare_output_server.py b/src/htmlcmp/compare_output_server.py index e641807..fd3b72a 100755 --- a/src/htmlcmp/compare_output_server.py +++ b/src/htmlcmp/compare_output_server.py @@ -3,18 +3,22 @@ import io import sys +import time import shutil import argparse import logging import threading import functools +import collections from pathlib import Path from concurrent.futures import ThreadPoolExecutor -from flask import Flask, send_from_directory, send_file +from flask import Flask, Response, render_template, send_from_directory, url_for import watchdog.observers import watchdog.events +from PIL import Image + from htmlcmp.common import ( comparable_file, compare_files, @@ -166,17 +170,24 @@ def compare(self, path: Path) -> None: if not isinstance(path, Path): raise TypeError("Path must be of type Path") - if path not in self._future: - raise RuntimeError("Path not submitted for comparison") browser = getattr(Config.thread_local, "browser", None) - result = compare_files( - Config.path_a / path, - Config.path_b / path, - browser=browser, - ) + try: + result = compare_files( + Config.path_a / path, + Config.path_b / path, + browser=browser, + ) + except Exception: + # A file may have vanished or failed to render between submission + # and comparison; report it as different rather than leaving the + # path stuck on "pending" forever. + logger.exception(f"Comparison failed for path: {path}") + result = False self._result[path] = "same" if result else "different" - self._future.pop(path) + # The worker can start before ``submit`` stored the future, so the + # entry is not guaranteed to be there yet. + self._future.pop(path, None) def result(self, path: Path) -> str | None: logger.debug(f"Getting comparison result for path: {path}") @@ -257,7 +268,179 @@ def forget(self, path: Path) -> None: self._result.pop(path, None) -app = Flask("compare") +class Accepted: + """Remembers which files had their reference updated during this session. + + That is what "accepted" means on the pages: someone looked at the diff and + promoted the monitored file to be the new reference. The mark is dropped + again as soon as the monitored file changes after the update, so it never + claims more than it knows. State is in-memory only. + """ + + def __init__(self): + self._lock = threading.Lock() + self._marks: dict[str, float] = {} + + def mark(self, path: str) -> None: + logger.debug(f"Marking as accepted: {path}") + + with self._lock: + self._marks[path] = time.time() + + def check(self, path: str) -> bool: + with self._lock: + timestamp = self._marks.get(path) + + if timestamp is None: + return False + + try: + stale = (Config.path_b / path).stat().st_mtime > timestamp + except OSError: + stale = True + + if stale: + logger.debug(f"Dropping stale acceptance: {path}") + with self._lock: + self._marks.pop(path, None) + return False + + return True + + +def highlight(diff: Image.Image) -> Image.Image: + """Turn a raw difference image into something readable at a glance. + + Pixel-wise differences are mostly very dark, which is unusable as an image, + so every differing pixel is painted in full-strength red on a near-black + background. + """ + mask = diff.convert("L").point(lambda value: 255 if value > 0 else 0) + visual = Image.new("RGB", diff.size, (12, 14, 18)) + visual.paste((255, 76, 76), mask=mask) + return visual + + +def diff_regions(diff: Image.Image, bands: int = 256) -> list[list[float]]: + """Locate the vertical bands of the page that contain differences. + + Returned as [start, end] fractions of the page height. The compare page + draws these as overlay bars: a few differing pixels vanish when the diff + image itself is scaled into a narrow strip, whereas a band always stays + visible. Costs one scan over the image. + """ + width, height = diff.size + band = max(1, height // bands) + + regions = [] + for top in range(0, height, band): + bottom = min(height, top + band) + if diff.crop((0, top, width, bottom)).getbbox() is None: + continue + if regions and regions[-1][1] == top: + regions[-1][1] = bottom + else: + regions.append([top, bottom]) + + return [[top / height, bottom / height] for top, bottom in regions] + + +class DiffCache: + """Caches rendered diff images, keyed by the mtimes of both inputs. + + Rendering drives a real browser and is by far the most expensive thing the + server does. The compare page wants both the image and its statistics, and + asks again on every reload, so without a cache a single review step would + pay for several full-page renders. + """ + + MAX_ENTRIES = 32 + + def __init__(self): + self._lock = threading.Lock() + self._entries = collections.OrderedDict() + + def get(self, path: str) -> tuple[bytes, dict]: + a = Config.path_a / path + b = Config.path_b / path + key = (path, a.stat().st_mtime_ns, b.stat().st_mtime_ns) + + with self._lock: + hit = self._entries.get(key) + if hit is not None: + self._entries.move_to_end(key) + logger.debug(f"Diff cache hit: {path}") + return hit + + logger.debug(f"Rendering diff: {path}") + with Config.browser_lock: + diff, _ = html_render_diff(a, b, Config.browser) + + width, height = diff.size + bbox = diff.getbbox() + stats = { + "available": True, + "identical": bbox is None, + "width": width, + "height": height, + } + if bbox is not None: + left, top, right, bottom = bbox + stats["first_diff"] = top / height + stats["area"] = ((right - left) * (bottom - top)) / (width * height) + stats["regions"] = diff_regions(diff) + + buffer = io.BytesIO() + highlight(diff).save(buffer, "PNG", optimize=True) + result = (buffer.getvalue(), stats) + + with self._lock: + self._entries[key] = result + while len(self._entries) > self.MAX_ENTRIES: + self._entries.popitem(last=False) + + return result + + +app = Flask(__name__) + +accepted = Accepted() +diff_cache = DiffCache() + + +@app.context_processor +def template_helpers() -> dict: + def static_url(filename: str) -> str: + """URL for a static asset, tagged with its modification time. + + Keeps a page and its scripts in lockstep: a browser holding on to an + older stylesheet or script after an upgrade would otherwise render a + page whose markup no longer matches. + """ + try: + stamp = int((Path(app.static_folder) / filename).stat().st_mtime) + except OSError: + stamp = 0 + return url_for("static", filename=filename, v=stamp) + + return {"static_url": static_url} + + +@app.after_request +def no_store_html(response: Response) -> Response: + # The listings are generated per request and go stale immediately. + if response.mimetype == "text/html": + response.headers["Cache-Control"] = "no-store" + return response + + +def resolve_in(root: Path, path: str) -> Path | None: + """Resolve ``path`` below ``root``, or None if it would escape it.""" + candidate = (root / path).resolve() + if not candidate.is_relative_to(root.resolve()): + logger.warning(f"Rejecting path outside of {root}: {path}") + return None + return candidate def collect_entries() -> list[dict]: @@ -270,6 +453,15 @@ def collect_entries() -> list[dict]: """ has_comparator = Config.comparator is not None + def entry(path: Path, message: str, result: str | None) -> dict: + key = str(path) + return { + "path": key, + "message": message, + "result": result, + "accepted": accepted.check(key), + } + def collect_one_sided(existing: Path, root: Path, message: str) -> list[dict]: """Walk a directory present on only one side. @@ -283,14 +475,7 @@ def collect_one_sided(existing: Path, root: Path, message: str) -> list[dict]: if child.is_dir(): entries.extend(collect_one_sided(child, root, message)) elif child.is_file() and comparable_file(child): - entries.append( - { - "path": str(child.relative_to(root)), - "comparable": True, - "message": message, - "result": "different", - } - ) + entries.append(entry(child.relative_to(root), message, "different")) return entries @@ -315,32 +500,11 @@ def collect(a: Path, b: Path) -> list[dict]: rel = common_path / name if name in left_files and name in right_files: result = Config.comparator.result(rel) if has_comparator else None - entries.append( - { - "path": str(rel), - "comparable": True, - "message": "", - "result": result, - } - ) + entries.append(entry(rel, "", result)) elif name in right_files: - entries.append( - { - "path": str(rel), - "comparable": True, - "message": "missing in reference (A)", - "result": "different", - } - ) + entries.append(entry(rel, "missing in reference (A)", "different")) else: - entries.append( - { - "path": str(rel), - "comparable": True, - "message": "missing in monitored (B)", - "result": "different", - } - ) + entries.append(entry(rel, "missing in monitored (B)", "different")) for name in sorted(left_dirs ^ right_dirs): if name in left_dirs: @@ -366,296 +530,57 @@ def collect(a: Path, b: Path) -> list[dict]: return entries -@app.route("/script.js") -def script_js(): - logger.debug("Serving script.js") - - return r""" -function updateRef(path) { - fetch(`/update_ref/${path}`) - .then(response => { - if (response.ok) { - alert(`Reference updated for ${path}`); - location.reload(); - } else { - alert(`Failed to update reference for ${path}: ${response.statusText}`); - } - }) - .catch(error => { - alert(`Error updating reference for ${path}: ${error}`); - }); -} -""" - - @app.route("/") def root(): logger.debug("Generating root directory listing") - has_comparator = Config.comparator is not None - entries = collect_entries() - - def badge(result: str | None) -> str: - if result is None: - return "" - return f'{result}' - - rows = [] - for e in entries: - path = e["path"] - if e["comparable"]: - name_cell = f'{path}' - else: - name_cell = f"{path}" - rows.append( - f'' - f'{badge(e["result"])}' - f'{name_cell}' - f'{e["message"]}' - f'' - f"" - ) - - log_link = "" - if Config.log_file is not None: - log_link = f'log file' - - head = r""" - - - -compare-html - - - - -""" - - header = rf"""
-
compare-html
-
-
reference (A): {Config.path_a}
-
monitored (B): {Config.path_b}
-
-
- -
- 0 total - 0 same - 0 diff - 0 pending -
-
- - - - -
- - {log_link} -
-
-""" - - table = ( - "" - "" - "" + "".join(rows) + "
StatusPathMessageActions
" + return render_template( + "index.html", + entries=collect_entries(), + live=Config.comparator is not None, + path_a=Config.path_a, + path_b=Config.path_b, + log_file=Config.log_file, ) - script = ( - r""" - - - -""" + +@app.route("/compare/") +def compare(path: str): + logger.debug(f"Generating comparison page for path: {path}") + + a = resolve_in(Config.path_a, path) + b = resolve_in(Config.path_b, path) + if a is None or b is None: + return "Invalid path", 400 + + return render_template( + "compare.html", + path=path, + file_a=Config.path_a / path, + file_b=Config.path_b / path, + live=Config.comparator is not None, + diff_available=Config.driver is not None and a.is_file() and b.is_file(), ) - return head + header + table + script + +@app.route("/api/entries") +def api_entries(): + logger.debug("Serving entries") + + return { + "live": Config.comparator is not None, + "entries": collect_entries(), + } @app.route("/status") def status(): + """Legacy status endpoint: a flat path -> result mapping.""" logger.debug("Serving comparison status") if Config.comparator is None: return {} - # Walk the filesystem the same way the page does so a poll reflects the - # current state (including files created/deleted after startup), rather - # than a cache the watchdog may not have kept current. return {e["path"]: e["result"] for e in collect_entries()} @@ -669,110 +594,78 @@ def logfile(): return send_from_directory(Config.log_file.parent, Config.log_file.name) -@app.route("/compare/") -def compare(path: str): - logger.debug(f"Generating comparison page for path: {path}") +def render_diff(path: str) -> tuple[bytes | None, dict, int]: + """Render (or fetch from cache) the diff for ``path``. + + Returns the PNG bytes, the statistics and an HTTP status code; the bytes + are None when no diff could be produced. + """ + if Config.driver is None: + return None, {"available": False, "error": "no browser driver"}, 404 + + a = resolve_in(Config.path_a, path) + b = resolve_in(Config.path_b, path) + if a is None or b is None: + return None, {"available": False, "error": "invalid path"}, 400 + if not a.is_file() or not b.is_file(): + return None, {"available": False, "error": "file missing on one side"}, 404 - if not isinstance(path, str): - raise TypeError("Path must be a string") - - return rf""" - - - - - - - -
- diff - - -
- - - - -""" + try: + image, stats = diff_cache.get(path) + except Exception as error: + logger.exception(f"Failed to render diff for path: {path}") + return None, {"available": False, "error": str(error)}, 500 + + return image, stats, 200 @app.route("/image_diff/") def image_diff(path: str): - logger.debug(f"Generating image diff for path: {path}") + logger.debug(f"Serving image diff for path: {path}") - if not isinstance(path, str): - raise TypeError("Path must be a string") + image, stats, code = render_diff(path) + if image is None: + return stats.get("error", "image diff not available"), code - if Config.driver is None: - return "Image diff not available without browser driver", 404 + return Response(image, mimetype="image/png") - if not (Config.path_a / path).is_file() or not (Config.path_b / path).is_file(): - return "Image diff not available: file missing on one side", 404 - with Config.browser_lock: - diff, _ = html_render_diff( - Config.path_a / path, - Config.path_b / path, - Config.browser, - ) - tmp = io.BytesIO() - diff.save(tmp, "JPEG", quality=70) - tmp.seek(0) - return send_file(tmp, mimetype="image/jpeg") +@app.route("/api/diff_info/") +def api_diff_info(path: str): + logger.debug(f"Serving diff info for path: {path}") + + _, stats, code = render_diff(path) + return stats, code @app.route("/file//") def file(variant: str, path: str): logger.debug(f"Serving file for variant: {variant}, path: {path}") - if not isinstance(variant, str) or not isinstance(path, str): - raise TypeError("Variant and path must be strings") if variant not in ["a", "b"]: - raise ValueError("Variant must be 'a' or 'b'") + return "Variant must be 'a' or 'b'", 404 variant_root = Config.path_a if variant == "a" else Config.path_b - if not (variant_root / path).is_file(): + resolved = resolve_in(variant_root, path) + if resolved is None: + return "Invalid path", 400 + + if not resolved.is_file(): side = "reference (A)" if variant == "a" else "monitored (B)" - return ( - "" - f"
file missing in {side}
" - ) + return render_template("missing.html", side=side), 404 return send_from_directory(variant_root, path) -@app.route("/update_ref/") -def update_ref(path: str): - logger.debug(f"Updating reference for path: {path}") - - if not isinstance(path, str): - raise TypeError("Path must be a string") - - src = Config.path_b / path - dst = Config.path_a / path +def do_update_ref(path: str): + src = resolve_in(Config.path_b, path) + dst = resolve_in(Config.path_a, path) + if src is None or dst is None: + return {"error": "invalid path"}, 400 if not src.exists(): - return f"Source file does not exist: {src}", 404 + return {"error": f"source does not exist: {src}"}, 404 dst.parent.mkdir(parents=True, exist_ok=True) @@ -781,6 +674,31 @@ def update_ref(path: str): else: shutil.copytree(src, dst, dirs_exist_ok=True) + accepted.mark(path) + + # The watchdog picks the copy up as well, but re-comparing right away keeps + # the UI from briefly showing the old result after an accepted diff. + if Config.comparator is not None and src.is_file(): + Config.comparator.submit(Path(path)) + + return {"ok": True, "path": path}, 200 + + +@app.route("/api/update_ref/", methods=["POST"]) +def api_update_ref(path: str): + logger.debug(f"Updating reference for path: {path}") + + return do_update_ref(path) + + +@app.route("/update_ref/", methods=["GET", "POST"]) +def update_ref(path: str): + """Legacy endpoint kept for scripted use; prefer POST /api/update_ref.""" + logger.debug(f"Updating reference for path: {path}") + + body, code = do_update_ref(path) + if code != 200: + return body["error"], code return "Reference updated", 200 diff --git a/src/htmlcmp/html_render_diff.py b/src/htmlcmp/html_render_diff.py index cae4f34..13f0f51 100755 --- a/src/htmlcmp/html_render_diff.py +++ b/src/htmlcmp/html_render_diff.py @@ -55,6 +55,19 @@ def screenshot(browser: webdriver.Remote, url: str) -> Image.Image: return Image.open(io.BytesIO(png)) +def content_bottom(image: Image.Image) -> int: + """Row just below the last pixel that differs from the page background. + + The background is sampled from the bottom right corner, so a page that + paints its whole window (a full-height gradient, say) simply yields the + full height and nothing is cropped. + """ + background = image.getpixel((image.width - 1, image.height - 1)) + canvas = Image.new(image.mode, image.size, background) + bbox = ImageChops.difference(image, canvas).getbbox() + return 0 if bbox is None else bbox[3] + + def get_browser( driver: str, max_width: int = 1000, max_height: int = 10000 ) -> webdriver.Remote: @@ -94,11 +107,18 @@ def html_render_diff( elif not isinstance(browser_b, webdriver.Remote): raise TypeError(f"Expected webdriver.Remote, got {type(browser_b)}") - image_a = screenshot(browser, to_url(a)) - image_b = screenshot(browser_b, to_url(b)) + image_a = screenshot(browser, to_url(a)).convert("RGB") + image_b = screenshot(browser_b, to_url(b)).convert("RGB") + + # The browser window is deliberately very tall so that long pages fit into + # one screenshot, which leaves most shots mostly empty. That empty tail is + # identical on both sides by construction and would otherwise dominate + # every position and area derived from the diff, so it is cropped away. + height = max(content_bottom(image_a), content_bottom(image_b), 1) + if height < min(image_a.height, image_b.height): + image_a = image_a.crop((0, 0, image_a.width, height)) + image_b = image_b.crop((0, 0, image_b.width, height)) - image_a = image_a.convert("RGB") - image_b = image_b.convert("RGB") diff = ImageChops.difference(image_a, image_b) return diff, (image_a, image_b) diff --git a/src/htmlcmp/static/common.js b/src/htmlcmp/static/common.js new file mode 100644 index 0000000..df61442 --- /dev/null +++ b/src/htmlcmp/static/common.js @@ -0,0 +1,38 @@ +"use strict"; + +/* Helpers shared by the index and compare pages. */ + +function encodePath(path) { + return path.split("/").map(encodeURIComponent).join("/"); +} + +let toastTimer = null; + +function showToast(message, isError) { + const el = document.getElementById("toast"); + if (!el) return; + el.textContent = message; + el.classList.toggle("error", !!isError); + el.classList.add("show"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.classList.remove("show"), 2600); +} + +/* Copy the monitored (B) file over the reference (A) file. + Resolves to an error message, or null on success. */ +async function updateRef(path) { + try { + const res = await fetch(`/api/update_ref/${encodePath(path)}`, { method: "POST" }); + const data = await res.json().catch(() => ({})); + if (!res.ok) return data.error || res.statusText || "request failed"; + return null; + } catch (e) { + return String(e); + } +} + +async function fetchEntries() { + const res = await fetch("/api/entries"); + if (!res.ok) throw new Error(res.statusText); + return await res.json(); +} diff --git a/src/htmlcmp/static/compare.js b/src/htmlcmp/static/compare.js new file mode 100644 index 0000000..8465572 --- /dev/null +++ b/src/htmlcmp/static/compare.js @@ -0,0 +1,340 @@ +"use strict"; + +const body = document.body; +const PATH = body.dataset.path; +const LIVE = body.dataset.live === "true"; + +const frameA = document.getElementById("frame-a"); +const frameB = document.getElementById("frame-b"); + +/* ---------------- view mode ---------------- */ + +function setView(view) { + body.dataset.view = view; + for (const b of document.getElementById("views").children) { + b.classList.toggle("active", b.dataset.view === view); + } + localStorage.setItem("htmlcmp.view", view); +} + +function setDiffOpen(open) { + body.dataset.diffOpen = open ? "true" : "false"; + localStorage.setItem("htmlcmp.diffOpen", open ? "true" : "false"); +} + +document.getElementById("views").addEventListener("click", (event) => { + const btn = event.target.closest("button"); + if (btn) setView(btn.dataset.view); +}); + +document.getElementById("toggle-diff").addEventListener("click", () => { + setDiffOpen(body.dataset.diffOpen !== "true"); +}); + +document.getElementById("expand-diff").addEventListener("click", () => { + document.getElementById("diff-col").classList.toggle("wide"); +}); + +setView(localStorage.getItem("htmlcmp.view") || "split"); +setDiffOpen(localStorage.getItem("htmlcmp.diffOpen") !== "false"); + +/* ---------------- navigation ---------------- */ + +const diffsOnlyBox = document.getElementById("diffs-only"); +const prevBtn = document.getElementById("prev"); +const nextBtn = document.getElementById("next"); +const positionEl = document.getElementById("position"); + +let entries = []; +let prevPath = null; +let nextPath = null; + +diffsOnlyBox.checked = localStorage.getItem("htmlcmp.diffsOnly") === "true"; +diffsOnlyBox.addEventListener("change", () => { + localStorage.setItem("htmlcmp.diffsOnly", diffsOnlyBox.checked ? "true" : "false"); + renderNav(); +}); + +function navCandidates() { + if (!diffsOnlyBox.checked) return entries; + return entries.filter((e) => e.result !== "same"); +} + +function renderNav() { + const candidates = navCandidates(); + prevPath = null; + nextPath = null; + for (const e of candidates) { + if (e.path < PATH) prevPath = e.path; + else if (e.path > PATH && nextPath === null) nextPath = e.path; + } + prevBtn.disabled = prevPath === null; + nextBtn.disabled = nextPath === null; + + const index = candidates.findIndex((e) => e.path === PATH); + positionEl.textContent = + candidates.length === 0 + ? "–" + : `${index >= 0 ? index + 1 : "–"} / ${candidates.length}`; +} + +function goTo(path) { + if (path) location.href = `/compare/${encodePath(path)}`; +} + +prevBtn.addEventListener("click", () => goTo(prevPath)); +nextBtn.addEventListener("click", () => goTo(nextPath)); + +/* ---------------- status ---------------- */ + +const statusBadge = document.getElementById("status-badge"); +const acceptedBadge = document.getElementById("accepted-badge"); + +function renderStatus(entry) { + if (entry && entry.result) { + statusBadge.hidden = false; + statusBadge.className = `badge ${entry.result}`; + statusBadge.textContent = entry.message ? `${entry.result} · ${entry.message}` : entry.result; + } else if (entry && entry.message) { + statusBadge.hidden = false; + statusBadge.className = "badge"; + statusBadge.textContent = entry.message; + } else { + statusBadge.hidden = true; + } + acceptedBadge.hidden = !(entry && entry.accepted); +} + +async function refreshEntries() { + try { + const data = await fetchEntries(); + entries = data.entries; + } catch (e) { + return; + } + renderNav(); + renderStatus(entries.find((e) => e.path === PATH)); +} + +/* ---------------- diff panel ---------------- */ + +const minimap = document.getElementById("minimap"); +const diffImg = document.getElementById("diff-img"); +const viewportEl = document.getElementById("viewport"); +const regionsEl = document.getElementById("regions"); +const statsEl = document.getElementById("diff-stats"); +const noteEl = document.getElementById("diff-note"); + +function percent(fraction) { + return `${(100 * fraction).toFixed(1)}%`; +} + +function showDiffPanel(available, note) { + minimap.hidden = !available; + statsEl.hidden = !available; + noteEl.hidden = available; + noteEl.textContent = note || ""; +} + +/* Whether a rendered diff exists can change while the page is open — updating + the reference creates a file that was missing a moment ago — so the panel + follows what the endpoint reports rather than the state at render time. */ +async function refreshDiffInfo() { + try { + const res = await fetch(`/api/diff_info/${encodePath(PATH)}`); + const info = await res.json(); + if (!res.ok || !info.available) { + showDiffPanel(false, `no rendered diff: ${info.error || res.statusText}`); + return; + } + showDiffPanel(true); + regionsEl.replaceChildren(); + if (info.identical) { + statsEl.textContent = "renders identically"; + return; + } + statsEl.innerHTML = + `first diff at ${percent(info.first_diff)}
` + + `bounding box ${percent(info.area)} of page`; + for (const [start, end] of info.regions || []) { + const bar = document.createElement("div"); + bar.className = "region"; + bar.style.top = `${100 * start}%`; + bar.style.height = `${100 * (end - start)}%`; + regionsEl.append(bar); + } + } catch (e) { + showDiffPanel(false, "no rendered diff"); + } +} + +function reloadDiffImage() { + const base = diffImg.src.split("?")[0]; + diffImg.src = `${base}?t=${Date.now()}`; + refreshDiffInfo(); +} + +/* The diff strip is a full-page render squeezed into the column, so a vertical + position on it maps onto the documents' scroll height. */ +minimap.addEventListener("click", (event) => { + if (document.getElementById("diff-col").classList.contains("wide")) return; + const rect = minimap.getBoundingClientRect(); + const fraction = (event.clientY - rect.top) / rect.height; + for (const frame of [frameA, frameB]) { + const win = frameWindow(frame); + if (!win) continue; + const doc = win.document.documentElement; + win.scrollTo({ top: fraction * doc.scrollHeight - win.innerHeight / 2 }); + } +}); + +function updateViewportIndicator() { + if (!viewportEl) return; + const win = frameWindow(frameA) || frameWindow(frameB); + if (!win) return; + const height = win.document.documentElement.scrollHeight; + if (!height) return; + const visible = win.innerHeight / height; + // Nothing to point at when the whole document fits on screen. + viewportEl.hidden = visible > 0.98; + viewportEl.style.top = `${(100 * win.scrollY) / height}%`; + viewportEl.style.height = `${Math.min(100, 100 * visible)}%`; +} + +/* ---------------- scroll sync ---------------- */ + +function frameWindow(frame) { + try { + return frame.contentWindow && frame.contentWindow.document ? frame.contentWindow : null; + } catch (e) { + return null; // cross-origin, should not happen for local files + } +} + +/* Whichever pane the user last scrolled drives the other one for a short + while; that keeps the echo scroll events the sync itself provokes from + bouncing back and fighting the pane being scrolled. */ +let driver = null; +let driverUntil = 0; + +function syncFrom(source, target) { + const now = performance.now(); + if (driver !== source && now < driverUntil) return; + + driver = source; + driverUntil = now + 100; + + updateViewportIndicator(); + + const from = frameWindow(source); + const to = frameWindow(target); + if (!from || !to) return; + if (to.scrollX === from.scrollX && to.scrollY === from.scrollY) return; + to.scrollTo(from.scrollX, from.scrollY); +} + +/* Listeners must be (re-)attached on every load: navigating an iframe replaces + its window, dropping anything registered on the previous one. */ +function attachFrame(frame, other) { + const win = frameWindow(frame); + if (!win) return; + win.addEventListener("scroll", () => syncFrom(frame, other), { passive: true }); + win.document.addEventListener("keydown", onKeyDown); + updateViewportIndicator(); +} + +frameA.addEventListener("load", () => attachFrame(frameA, frameB)); +frameB.addEventListener("load", () => attachFrame(frameB, frameA)); +attachFrame(frameA, frameB); +attachFrame(frameB, frameA); + +function reloadFrames() { + for (const frame of [frameA, frameB]) { + // eslint-disable-next-line no-self-assign + frame.src = frame.src; + } +} + +/* ---------------- actions ---------------- */ + +const updateBtn = document.getElementById("update-ref"); + +async function doUpdateRef() { + updateBtn.disabled = true; + const label = updateBtn.textContent; + updateBtn.textContent = "Updating…"; + const error = await updateRef(PATH); + updateBtn.textContent = label; + updateBtn.disabled = false; + if (error) { + showToast(`Update failed: ${error}`, true); + return; + } + showToast("Reference updated — diff accepted"); + acceptedBadge.hidden = false; + reloadFrames(); + reloadDiffImage(); + refreshEntries(); +} + +updateBtn.addEventListener("click", doUpdateRef); + +/* ---------------- keyboard ---------------- */ + +const help = document.getElementById("help"); +document.getElementById("help-btn").addEventListener("click", () => help.showModal()); + +function onKeyDown(event) { + if (event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return; + + switch (event.key) { + case "ArrowLeft": + case "k": + goTo(prevPath); + break; + case "ArrowRight": + case "j": + goTo(nextPath); + break; + case "1": + setView("a"); + break; + case "2": + setView("split"); + break; + case "3": + setView("b"); + break; + case "d": + setDiffOpen(body.dataset.diffOpen !== "true"); + break; + case "u": + doUpdateRef(); + break; + case "r": + reloadFrames(); + reloadDiffImage(); + break; + case "Escape": + if (help.open) help.close(); + else location.href = "/"; + break; + case "?": + help.open ? help.close() : help.showModal(); + break; + default: + return; + } + event.preventDefault(); +} + +document.addEventListener("keydown", onKeyDown); + +/* ---------------- boot ---------------- */ + +refreshEntries(); +refreshDiffInfo(); +setInterval(updateViewportIndicator, 500); +if (LIVE) setInterval(refreshEntries, 2000); diff --git a/src/htmlcmp/static/index.js b/src/htmlcmp/static/index.js new file mode 100644 index 0000000..ab3f4db --- /dev/null +++ b/src/htmlcmp/static/index.js @@ -0,0 +1,152 @@ +"use strict"; + +const LIVE = document.body.dataset.live === "true"; +const tbody = document.querySelector("tbody"); + +let activeFilter = "all"; + +function rowMatchesFilter(tr) { + if (activeFilter === "all") return true; + if (activeFilter === "accepted") return tr.dataset.accepted === "true"; + return tr.dataset.status === activeFilter; +} + +function applyFilter() { + const q = document.getElementById("search").value.trim().toLowerCase(); + for (const tr of tbody.rows) { + const matchesSearch = !q || tr.dataset.path.toLowerCase().includes(q); + tr.hidden = !(rowMatchesFilter(tr) && matchesSearch); + } +} + +function updateSummary() { + const counts = { total: 0, same: 0, different: 0, pending: 0, accepted: 0 }; + for (const tr of tbody.rows) { + counts.total++; + if (counts[tr.dataset.status] !== undefined) counts[tr.dataset.status]++; + if (tr.dataset.accepted === "true") counts.accepted++; + } + for (const el of document.querySelectorAll("#filters .count")) { + el.textContent = counts[el.dataset.count]; + } +} + +function renderRow(tr, entry) { + const status = entry.result || ""; + if (tr.dataset.status !== status) { + tr.dataset.status = status; + const badge = tr.querySelector(".status .badge:not(.accepted)"); + if (status) { + if (badge) { + badge.className = `badge ${status}`; + badge.textContent = status; + } else { + tr.querySelector(".status").insertAdjacentHTML( + "afterbegin", + `` + ); + tr.querySelector(".status .badge").textContent = status; + } + } else if (badge) { + badge.remove(); + } + } + const accepted = !!entry.accepted; + if ((tr.dataset.accepted === "true") !== accepted) { + tr.dataset.accepted = accepted ? "true" : "false"; + tr.querySelector(".status .badge.accepted").hidden = !accepted; + } + const message = tr.querySelector(".message"); + if (message.textContent !== (entry.message || "")) { + message.textContent = entry.message || ""; + } +} + +/* Refresh statuses in place; reload only when the set of files changed. */ +async function poll() { + let data; + try { + data = await fetchEntries(); + } catch (e) { + return; + } + const known = new Set([...tbody.rows].map((tr) => tr.dataset.path)); + if (data.entries.length !== known.size || data.entries.some((e) => !known.has(e.path))) { + location.reload(); + return; + } + const byPath = new Map(data.entries.map((e) => [e.path, e])); + for (const tr of tbody.rows) { + const entry = byPath.get(tr.dataset.path); + if (entry) renderRow(tr, entry); + } + updateSummary(); + applyFilter(); +} + +async function updateAll() { + const rows = [...tbody.rows].filter((tr) => !tr.hidden); + if (rows.length === 0) { + showToast("No files in the current view.", true); + return; + } + if (!confirm(`Update the reference for ${rows.length} file(s) in the current view?`)) return; + + const btn = document.getElementById("update-all"); + const label = btn.textContent; + btn.disabled = true; + + const failed = []; + let done = 0; + for (const tr of rows) { + btn.textContent = `Updating ${++done}/${rows.length}…`; + const error = await updateRef(tr.dataset.path); + if (error) failed.push(tr.dataset.path); + } + + btn.disabled = false; + btn.textContent = label; + if (failed.length) { + showToast(`Updated ${rows.length - failed.length}/${rows.length}, ${failed.length} failed.`, true); + } else { + showToast(`Updated ${rows.length} reference file(s).`); + } + poll(); +} + +document.getElementById("search").addEventListener("input", applyFilter); + +document.getElementById("filters").addEventListener("click", (event) => { + const btn = event.target.closest("button"); + if (!btn) return; + activeFilter = btn.dataset.filter; + for (const b of event.currentTarget.children) b.classList.toggle("active", b === btn); + applyFilter(); +}); + +document.getElementById("update-all").addEventListener("click", updateAll); + +tbody.addEventListener("click", async (event) => { + const btn = event.target.closest("button[data-action='update-ref']"); + if (!btn) return; + const tr = btn.closest("tr"); + btn.disabled = true; + const error = await updateRef(tr.dataset.path); + btn.disabled = false; + if (error) { + showToast(`${tr.dataset.path}: ${error}`, true); + } else { + showToast(`Reference updated: ${tr.dataset.path}`); + poll(); + } +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "/" && document.activeElement !== document.getElementById("search")) { + event.preventDefault(); + document.getElementById("search").focus(); + } +}); + +updateSummary(); +if (LIVE) setInterval(poll, 1500); diff --git a/src/htmlcmp/static/style.css b/src/htmlcmp/static/style.css new file mode 100644 index 0000000..e402db1 --- /dev/null +++ b/src/htmlcmp/static/style.css @@ -0,0 +1,253 @@ +/* Shared styling for the compare-html server pages. */ + +:root { + color-scheme: light dark; + + --bg: #fafafa; + --surface: #ffffff; + --surface-alt: #f4f5f7; + --text: #1f2023; + --muted: #63666c; + --border: #e2e3e6; + --accent: #1a73e8; + --accent-text: #ffffff; + + --green: #137333; --green-bg: #e6f4ea; + --red: #c5221f; --red-bg: #fce8e6; + --amber: #b06000; --amber-bg: #fef7e0; + --blue: #1a56b8; --blue-bg: #e8f0fe; + + --radius: 8px; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #16171a; + --surface: #1e2024; + --surface-alt: #24262b; + --text: #e6e7ea; + --muted: #9ba0a8; + --border: #33363c; + --accent: #6ea8fe; + --accent-text: #10131a; + + --green: #81c995; --green-bg: #1c3025; + --red: #f28b82; --red-bg: #38211f; + --amber: #fdd663; --amber-bg: #352b12; + --blue: #8ab4f8; --blue-bg: #1c2a44; + } +} + +* { box-sizing: border-box; } + +/* Several components below set an explicit display, which would otherwise + override the user agent's rule for the hidden attribute. */ +[hidden] { display: none !important; } + +html, body { height: 100%; } + +body { + margin: 0; + font-family: var(--sans); + font-size: 13px; + color: var(--text); + background: var(--bg); +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +code { font-family: var(--mono); } + +/* ---------- shared atoms ---------- */ + +.btn { + display: inline-flex; align-items: center; gap: 6px; + font: inherit; font-size: 12px; line-height: 1; + padding: 6px 10px; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); color: var(--text); + cursor: pointer; + white-space: nowrap; +} +.btn:hover:not(:disabled) { background: var(--surface-alt); } +.btn:disabled { opacity: .45; cursor: not-allowed; } +.btn.primary { + background: var(--accent); color: var(--accent-text); border-color: transparent; + font-weight: 600; +} +.btn.primary:hover:not(:disabled) { filter: brightness(1.08); } +.btn.ghost { background: transparent; } +.btn.icon { padding: 6px 9px; font-size: 13px; } + +.badge { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 8px; border-radius: 999px; + font-size: 11px; font-weight: 600; white-space: nowrap; + background: var(--surface-alt); color: var(--muted); +} +.badge.same { background: var(--green-bg); color: var(--green); } +.badge.different { background: var(--red-bg); color: var(--red); } +.badge.pending { background: var(--amber-bg); color: var(--amber); } +.badge.accepted { background: var(--blue-bg); color: var(--blue); } + +.tag { + display: inline-block; width: 16px; height: 16px; line-height: 16px; + text-align: center; border-radius: 4px; + font-size: 10px; font-weight: 700; + background: var(--surface-alt); color: var(--muted); +} +.tag.a { background: var(--blue-bg); color: var(--blue); } +.tag.b { background: var(--amber-bg); color: var(--amber); } + +.spacer { flex: 1; } + +.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.segmented button { + font: inherit; font-size: 12px; line-height: 1; + padding: 6px 10px; border: 0; border-right: 1px solid var(--border); + background: var(--surface); color: var(--muted); cursor: pointer; +} +.segmented button:last-child { border-right: 0; } +.segmented button.active { background: var(--accent); color: var(--accent-text); font-weight: 600; } + +/* Counts live inside the filter buttons: one place that names each state. */ +.segmented .count { + display: inline-block; margin-left: 5px; padding: 1px 6px; border-radius: 999px; + font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; + background: var(--surface-alt); color: var(--muted); +} +.segmented .count.same { background: var(--green-bg); color: var(--green); } +.segmented .count.different { background: var(--red-bg); color: var(--red); } +.segmented .count.pending { background: var(--amber-bg); color: var(--amber); } +.segmented .count.accepted { background: var(--blue-bg); color: var(--blue); } +.segmented button.active .count { background: rgba(255, 255, 255, .22); color: inherit; } + +/* ---------- index page ---------- */ + +body.index { display: flex; flex-direction: column; } + +body.index header { + position: sticky; top: 0; z-index: 3; + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 12px 16px; +} +.headline { display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; } +.title { font-size: 16px; font-weight: 600; } +.paths { font-size: 12px; color: var(--muted); margin-top: 4px; display: grid; gap: 2px; } +.paths > div { display: flex; align-items: center; gap: 6px; } + +.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; } +.toolbar input[type="search"] { + flex: 1 1 220px; min-width: 160px; + padding: 7px 10px; font: inherit; font-size: 13px; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); color: var(--text); +} + +body.index main { flex: 1; overflow: auto; } + +table { width: 100%; border-collapse: collapse; } +thead th { + position: sticky; top: 0; z-index: 1; + text-align: left; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; + color: var(--muted); font-weight: 600; + padding: 8px 12px; background: var(--surface-alt); + border-bottom: 1px solid var(--border); +} +tbody td { padding: 5px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; } +tbody tr:hover { background: var(--surface-alt); } +td.status { white-space: nowrap; } +td.status .badge + .badge { margin-left: 4px; } +td.path { font-family: var(--mono); font-size: 12px; word-break: break-all; } +td.message { color: var(--muted); font-size: 12px; } +td.actions { text-align: right; white-space: nowrap; } + +.empty { padding: 40px; text-align: center; color: var(--muted); } + +/* ---------- compare page ---------- */ + +body.compare { display: flex; flex-direction: column; overflow: hidden; } + +body.compare header { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding: 8px 12px; + background: var(--surface); border-bottom: 1px solid var(--border); +} +header .group { display: flex; align-items: center; gap: 8px; min-width: 0; } +.crumb { + font-family: var(--mono); font-size: 12px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 46vw; +} +.position { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; min-width: 64px; text-align: center; } +.switch { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted); cursor: pointer; } + +main.panes { flex: 1; display: flex; min-height: 0; } + +.pane { display: flex; flex-direction: column; flex: 1 1 50%; min-width: 0; border-right: 1px solid var(--border); } +.pane:last-child { border-right: 0; } +.pane-head { + display: flex; align-items: center; gap: 6px; + padding: 5px 10px; font-size: 11px; color: var(--muted); + background: var(--surface-alt); border-bottom: 1px solid var(--border); +} +.pane-head .file { font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pane iframe { flex: 1; width: 100%; border: 0; background: #fff; } + +body.compare[data-view="a"] #pane-b, +body.compare[data-view="b"] #pane-a { display: none; } +body.compare[data-diff-open="false"] #diff-col { display: none; } + +#diff-col { + display: flex; flex-direction: column; + flex: 0 0 var(--diff-width, 72px); + border-right: 1px solid var(--border); + background: var(--surface-alt); +} +#diff-col.wide { --diff-width: 320px; } +.minimap { position: relative; flex: 1; min-height: 0; cursor: crosshair; overflow: hidden; } +.minimap img { display: block; width: 100%; height: 100%; object-fit: fill; background: #000; } +#diff-col.wide .minimap { overflow: auto; cursor: default; } +#diff-col.wide .minimap img { height: auto; object-fit: contain; } +.minimap .viewport { + position: absolute; left: 0; right: 0; + border: 1px solid color-mix(in srgb, var(--accent) 70%, transparent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + pointer-events: none; +} +.minimap .regions { position: absolute; inset: 0; pointer-events: none; } +.minimap .region { + position: absolute; left: 0; right: 0; min-height: 3px; + background: rgba(255, 76, 76, .75); + box-shadow: 0 0 0 1px rgba(255, 76, 76, .35); +} +.diff-stats { padding: 6px 8px; font-size: 10px; color: var(--muted); line-height: 1.4; border-top: 1px solid var(--border); } +.diff-note { padding: 10px 8px; font-size: 11px; color: var(--muted); text-align: center; } + +.toast { + position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); + padding: 9px 14px; border-radius: var(--radius); + background: var(--text); color: var(--bg); + font-size: 12px; z-index: 20; opacity: 0; pointer-events: none; + transition: opacity .15s ease; +} +.toast.show { opacity: 1; } +.toast.error { background: var(--red); color: #fff; } + +dialog#help { + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); color: var(--text); padding: 16px 20px; + font-size: 13px; min-width: 260px; +} +dialog#help::backdrop { background: rgba(0, 0, 0, .35); } +dialog#help h2 { margin: 0 0 10px; font-size: 14px; } +dialog#help dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; margin: 0; } +dialog#help dt { font-family: var(--mono); color: var(--muted); } +kbd { + font-family: var(--mono); font-size: 11px; + border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; + padding: 1px 5px; background: var(--surface-alt); +} diff --git a/src/htmlcmp/templates/compare.html b/src/htmlcmp/templates/compare.html new file mode 100644 index 0000000..40c5079 --- /dev/null +++ b/src/htmlcmp/templates/compare.html @@ -0,0 +1,104 @@ + + + + + +{{ path }} — compare-html + + + + + + +
+
+ + {{ path }} + + +
+ +
+ +
+ + + + +
+ +
+ +
+
+ + + +
+ + + +
+
+ +
+
+
+ A + {{ file_a }} + + open +
+ +
+ + + +
+
+ B + {{ file_b }} + + open +
+ +
+
+ + +

Keyboard shortcuts

+
+
← / k
previous file
+
→ / j
next file
+
1 / 2 / 3
A only / side by side / B only
+
d
toggle diff panel
+
u
update reference (copy B over A)
+
r
reload both panes
+
Esc
back to index
+
?
this help
+
+
+ +
+ + diff --git a/src/htmlcmp/templates/index.html b/src/htmlcmp/templates/index.html new file mode 100644 index 0000000..8c7f5cb --- /dev/null +++ b/src/htmlcmp/templates/index.html @@ -0,0 +1,69 @@ + + + + + +compare-html + + + + + + +
+
+
+
compare-html
+
+
A reference {{ path_a }}
+
B monitored {{ path_b }}
+
+
+
+ +
+ +
+ + + + + +
+ + {% if log_file %}log file{% endif %} +
+
+ +
+ + + + + + + + + + + {% for e in entries %} + + + + + + + {% endfor %} + +
StatusPathMessage
+ {% if e.result %}{{ e.result }}{% endif %} + ✓ accepted + {{ e.path }}{{ e.message }}
+ {% if not entries %}
No comparable files found.
{% endif %} +
+ +
+ + diff --git a/src/htmlcmp/templates/missing.html b/src/htmlcmp/templates/missing.html new file mode 100644 index 0000000..7142e7f --- /dev/null +++ b/src/htmlcmp/templates/missing.html @@ -0,0 +1,15 @@ + + + + +file missing + + + + +
file missing in {{ side }}
+ +