diff --git a/handlers/backup.py b/handlers/backup.py index 63436e3..ab6a365 100644 --- a/handlers/backup.py +++ b/handlers/backup.py @@ -74,6 +74,98 @@ def _tail(path: str, lines: int = 120) -> str: return "".join(data).strip() or "(empty)" +def _beautify_restic_forget(raw: str) -> str | None: + """ + Parse restic forget output tables into a compact bullet list. + """ + if "Reasons" not in raw or "Paths" not in raw: + return None + + lines = raw.splitlines() + headers = [] + for idx, line in enumerate(lines): + if line.startswith("ID") and "Reasons" in line and "Paths" in line: + headers.append(idx) + if not headers: + return None + + def parse_block(start_idx: int, end_idx: int) -> list[dict]: + header = lines[start_idx] + cols = ["ID", "Time", "Host", "Tags", "Reasons", "Paths", "Size"] + positions = [] + for name in cols: + pos = header.find(name) + if pos == -1: + return [] + positions.append(pos) + positions.append(len(header)) + + entries: list[dict] = [] + current: dict | None = None + for line in lines[start_idx + 2 : end_idx]: + if not line.strip(): + continue + segments = [] + for i in range(len(cols)): + segments.append(line[positions[i] : positions[i + 1]].strip()) + row = dict(zip(cols, segments)) + if row["ID"]: + current = { + "id": row["ID"], + "time": row["Time"], + "host": row["Host"], + "size": row["Size"], + "tags": row["Tags"], + "reasons": [], + "paths": [], + } + if row["Reasons"]: + current["reasons"].append(row["Reasons"]) + if row["Paths"]: + current["paths"].append(row["Paths"]) + entries.append(current) + elif current: + if row["Reasons"]: + current["reasons"].append(row["Reasons"]) + if row["Paths"]: + current["paths"].append(row["Paths"]) + return entries + + blocks = [] + for i, start in enumerate(headers): + end = headers[i + 1] if i + 1 < len(headers) else len(lines) + entries = parse_block(start, end) + if not entries: + continue + label = "Plan" + prev_line = lines[start - 1].lower() if start - 1 >= 0 else "" + prev2 = lines[start - 2].lower() if start - 2 >= 0 else "" + if "keep" in prev_line: + label = prev_line.strip() + elif "keep" in prev2: + label = prev2.strip() + elif "snapshots" in prev_line: + label = prev_line.strip() + blocks.append((label, entries)) + + if not blocks: + return None + + out_lines = [] + for label, entries in blocks: + out_lines.append(f"📦 {label}") + for e in entries: + head = f"🧉 {e['id']} | {e['time']} | {e['host']} | {e['size'] or 'n/a'}" + out_lines.append(head) + if e["reasons"]: + out_lines.append(" 📌 " + "; ".join(e["reasons"])) + if e["paths"]: + for p in e["paths"]: + out_lines.append(f" • {p}") + out_lines.append("") + return "\n".join(out_lines).rstrip() + + def _load_json(raw: str, label: str) -> tuple[bool, object | None, str]: if not raw or not raw.strip(): return False, None, f"? {label} returned empty output" @@ -436,6 +528,7 @@ async def backup_history(msg: Message): if content.startswith("⚠️"): await msg.answer(content, reply_markup=backup_kb) return + pretty = _beautify_restic_forget(content) trimmed = False max_len = 3500 if len(content) > max_len: @@ -444,11 +537,14 @@ async def backup_history(msg: Message): header = "📜 Backup history (tail)" if trimmed: header += " (trimmed)" - await msg.answer( - f"{header}\n`{log_path}`\n```\n{content}\n```", - reply_markup=backup_kb, - parse_mode="Markdown", - ) + if pretty: + await msg.answer(f"{header}\n`{log_path}`\n\n{pretty}", reply_markup=backup_kb) + else: + await msg.answer( + f"{header}\n`{log_path}`\n```\n{content}\n```", + reply_markup=backup_kb, + parse_mode="Markdown", + ) @dp.callback_query(F.data == "backup:retry")