diff --git a/src/htmlcmp/compare_output_server.py b/src/htmlcmp/compare_output_server.py
index 6167c80..8ad8c80 100755
--- a/src/htmlcmp/compare_output_server.py
+++ b/src/htmlcmp/compare_output_server.py
@@ -45,19 +45,6 @@ def result_symbol(result: str) -> str | None:
return None
-def result_css(result: str) -> str | None:
- if not isinstance(result, str):
- raise TypeError("Result must be of type str")
-
- if result == "pending":
- return "color:blue;"
- if result == "same":
- return "color:green;"
- if result == "different":
- return "color:red;"
- return None
-
-
class Observer:
def __init__(self):
class Handler(watchdog.events.FileSystemEventHandler):
@@ -248,6 +235,11 @@ def result(self, path: Path) -> str | None:
logger.debug(f"No comparison result for path: {path}")
return None
+ def all_results(self) -> dict[str, str]:
+ # Snapshot the per-file results as a plain {path: result} map.
+ # Cheap O(n) dict copy used by the live-status endpoint; no tree walk.
+ return {str(path): result for path, result in list(self._result.items())}
+
app = Flask("compare")
@@ -278,268 +270,308 @@ def script_js():
def root():
logger.debug("Generating root directory listing")
- current_entry_id = 0
-
- def next_entry_id() -> int:
- nonlocal current_entry_id
- entry_id = current_entry_id
- current_entry_id += 1
- return entry_id
-
- def generate_entry(
- entry_id: int,
- parrent_id: int | None,
- depth: int,
- is_directory: bool,
- is_comparable: bool,
- path: Path,
- name: str,
- message: str = "",
- cmp_result: str = None,
- ) -> str:
- result = ""
-
- if cmp_result is None and Config.comparator is not None:
- cmp_result = Config.comparator.result(path)
- is_hidden = (
- Config.comparator is not None
- and Config.comparator.result(path.parent) == "same"
- )
- is_collapsed = cmp_result == "same"
-
- hidden_str = "hidden" if is_hidden else ""
- result += f'
'
-
- if is_directory:
- result += f'
'
- else:
- result += "
"
-
- if is_comparable:
- main = f'{name}'
- else:
- main = f"{name}"
- if cmp_result is not None:
- status = result_symbol(cmp_result)
- style = result_css(cmp_result)
- result += f'
{status}
'
- else:
- style = ""
- result += f"
"
- result += f'
{main}
'
- result += f"
{message}
"
-
- result += (
- f"
"
- )
-
- result += "
"
+ has_comparator = Config.comparator is not None
- return result
+ def collect(a: Path, b: Path) -> list[dict]:
+ """Single O(n) walk producing flat leaf rows.
- def generate_tree(
- a: Path, b: Path, name: str, parrent_id: int | None, depth: int
- ) -> str:
- result = ""
-
- if not isinstance(a, Path) or not isinstance(b, Path):
- raise TypeError("Paths must be of type Path")
- if not a.is_dir() or not b.is_dir():
- raise ValueError("Both paths must be directories")
+ One row per comparable file (and per missing file/dir). File statuses
+ are O(1) dict lookups, so there is no recursive per-directory status
+ aggregation and the whole tree is walked exactly once.
+ """
+ entries = []
common_path = a.relative_to(Config.path_a)
- directory_id = next_entry_id()
- result += generate_entry(
- directory_id,
- parrent_id,
- depth,
- True,
- False,
- common_path,
- name + "/",
- )
-
left = sorted(p.name for p in a.iterdir())
right = sorted(p.name for p in b.iterdir())
- left_files = sorted(
- [
- name
- for name in left
- if (a / name).is_file() and comparable_file(a / name)
- ]
- )
- right_files = sorted(
- [
- name
- for name in right
- if (b / name).is_file() and comparable_file(b / name)
- ]
- )
-
- left_dirs = sorted([name for name in left if (a / name).is_dir()])
- right_dirs = sorted([name for name in right if (b / name).is_dir()])
-
- common_files = [name for name in left_files if name in right_files]
- common_dirs = [name for name in left_dirs if name in right_dirs]
-
- left_files_missing = [name for name in right_files if name not in left_files]
- right_files_missing = [name for name in left_files if name not in right_files]
- left_dirs_missing = [name for name in right_dirs if name not in left_dirs]
- right_dirs_missing = [name for name in left_dirs if name not in right_dirs]
- for name in left_files_missing:
- result += generate_entry(
- next_entry_id(),
- directory_id,
- depth + 1,
- False,
- False,
- common_path / name,
- name,
- "file missing in A",
- cmp_result="different",
- )
- for name in right_files_missing:
- result += generate_entry(
- next_entry_id(),
- directory_id,
- depth + 1,
- False,
- False,
- common_path / name,
- name,
- "file missing in B",
- cmp_result="different",
- )
- for name in left_dirs_missing:
- result += generate_entry(
- next_entry_id(),
- directory_id,
- depth + 1,
- False,
- False,
- common_path / name,
- name + "/",
- "dir missing in A",
- cmp_result="different",
- )
- for name in right_dirs_missing:
- result += generate_entry(
- next_entry_id(),
- directory_id,
- depth + 1,
- False,
- False,
- common_path / name,
- name + "/",
- "dir missing in B",
- cmp_result="different",
+ left_files = {
+ name for name in left if (a / name).is_file() and comparable_file(a / name)
+ }
+ right_files = {
+ name for name in right if (b / name).is_file() and comparable_file(b / name)
+ }
+ left_dirs = {name for name in left if (a / name).is_dir()}
+ right_dirs = {name for name in right if (b / name).is_dir()}
+
+ for name in sorted(left_files | right_files):
+ 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,
+ }
+ )
+ elif name in right_files:
+ entries.append(
+ {
+ "path": str(rel),
+ "comparable": False,
+ "message": "missing in reference (A)",
+ "result": "different",
+ }
+ )
+ else:
+ entries.append(
+ {
+ "path": str(rel),
+ "comparable": False,
+ "message": "missing in monitored (B)",
+ "result": "different",
+ }
+ )
+
+ for name in sorted(left_dirs ^ right_dirs):
+ rel = common_path / name
+ where = "reference (A)" if name in right_dirs else "monitored (B)"
+ entries.append(
+ {
+ "path": str(rel) + "/",
+ "comparable": False,
+ "message": f"directory missing in {where}",
+ "result": "different",
+ }
)
- for name in common_files:
- result += generate_entry(
- next_entry_id(),
- directory_id,
- depth + 1,
- False,
- True,
- common_path / name,
- name,
- "",
- )
+ for name in sorted(left_dirs & right_dirs):
+ entries.extend(collect(a / name, b / name))
- for name in common_dirs:
- result += generate_tree(a / name, b / name, name, directory_id, depth + 1)
+ return entries
- return result
+ entries = collect(Config.path_a, Config.path_b)
+ entries.sort(key=lambda e: e["path"])
- result = """
+ def badge(result: str | None) -> str:
+ if result is None:
+ return ""
+ return f'{result_symbol(result) or ""} {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 = """
+
+compare-html
"""
- result += "
"
- result += "comparing "
- result += f"Reference: {Config.path_a} "
- result += f"Monitored: {Config.path_b}"
- result += "
"
+ header = f"""
+
compare-html
+
+
reference (A): {Config.path_a}
+
monitored (B): {Config.path_b}
+
+
+
+
+ 0 total
+ 0 same
+ 0 diff
+ 0 pending
+
+
+
+
+
+
+
+ {log_link}
+
+
+"""
+
+ table = (
+ "
"
+ "
Status
Path
Message
Actions
"
+ "
" + "".join(rows) + "
"
+ )
- if Config.log_file is not None:
- result += "