1077 lines
30 KiB
Python
1077 lines
30 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
"""
|
|||
|
|
catalogue.py — Read-only HTML catalogue generator for o-phone.py backups.
|
|||
|
|
|
|||
|
|
Does not import, modify, or depend on o-phone.py.
|
|||
|
|
Reads the existing SQLite database and generates a single offline index.html.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import html
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import sqlite3
|
|||
|
|
import sys
|
|||
|
|
import webbrowser
|
|||
|
|
from collections import defaultdict
|
|||
|
|
from datetime import datetime
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# CONFIGURATION (must match o-phone.py)
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
DATABASE_NAME = ".photo_organiser.db"
|
|||
|
|
|
|||
|
|
# file_type values produced by o-phone.py classify_file()
|
|||
|
|
TYPE_IMAGE = "image"
|
|||
|
|
TYPE_VIDEO = "video"
|
|||
|
|
TYPE_SCREENSHOT = "screenshot"
|
|||
|
|
TYPE_UNKNOWN = "unknown"
|
|||
|
|
|
|||
|
|
MONTH_NAMES = [
|
|||
|
|
"January", "February", "March", "April", "May", "June",
|
|||
|
|
"July", "August", "September", "October", "November", "December",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# Extensions that browsers generally cannot display as <img>
|
|||
|
|
RAW_EXTENSIONS = {
|
|||
|
|
".dng", ".raw", ".arw", ".cr2", ".cr3", ".nef", ".nrw",
|
|||
|
|
".orf", ".rw2", ".raf", ".pef", ".srw", ".x3f",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# UTILITIES
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
def eprint(*args: Any, **kwargs: Any) -> None:
|
|||
|
|
print(*args, file=sys.stderr, **kwargs)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_date_taken(value: str) -> datetime | None:
|
|||
|
|
"""Parse date_taken stored by o-phone.py (ISO-ish)."""
|
|||
|
|
if not value:
|
|||
|
|
return None
|
|||
|
|
value = value.strip()
|
|||
|
|
# Handle fractional seconds and timezone suffixes loosely
|
|||
|
|
for fmt in (
|
|||
|
|
"%Y-%m-%dT%H:%M:%S.%f%z",
|
|||
|
|
"%Y-%m-%dT%H:%M:%S%z",
|
|||
|
|
"%Y-%m-%dT%H:%M:%S.%f",
|
|||
|
|
"%Y-%m-%dT%H:%M:%S",
|
|||
|
|
"%Y-%m-%d %H:%M:%S",
|
|||
|
|
"%Y-%m-%d",
|
|||
|
|
"%Y:%m:%d %H:%M:%S",
|
|||
|
|
"%Y:%m:%d",
|
|||
|
|
):
|
|||
|
|
try:
|
|||
|
|
return datetime.strptime(value, fmt)
|
|||
|
|
except ValueError:
|
|||
|
|
continue
|
|||
|
|
# Last resort: take first 19 chars as YYYY-MM-DDTHH:MM:SS
|
|||
|
|
try:
|
|||
|
|
return datetime.strptime(value[:19], "%Y-%m-%dT%H:%M:%S")
|
|||
|
|
except ValueError:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def format_display_date(dt: datetime) -> str:
|
|||
|
|
return f"{dt.day} {MONTH_NAMES[dt.month - 1]} {dt.year}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def format_display_day(dt: datetime) -> str:
|
|||
|
|
return f"{dt.day} {MONTH_NAMES[dt.month - 1][:3]}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_raw(path: str, original_name: str) -> bool:
|
|||
|
|
ext = Path(path).suffix.lower() or Path(original_name).suffix.lower()
|
|||
|
|
return ext in RAW_EXTENSIONS
|
|||
|
|
|
|||
|
|
|
|||
|
|
def open_database_readonly(db_path: Path) -> sqlite3.Connection:
|
|||
|
|
"""Open SQLite in read-only mode. Fail if the file does not exist."""
|
|||
|
|
if not db_path.is_file():
|
|||
|
|
eprint(f"ERROR: SQLite database not found:\n{db_path}")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
# URI mode forces read-only; also prevents accidental creation
|
|||
|
|
uri = f"file:{db_path.resolve().as_posix()}?mode=ro"
|
|||
|
|
try:
|
|||
|
|
conn = sqlite3.connect(uri, uri=True)
|
|||
|
|
conn.row_factory = sqlite3.Row
|
|||
|
|
# Quick schema sanity check
|
|||
|
|
cols = {
|
|||
|
|
row[1]
|
|||
|
|
for row in conn.execute("PRAGMA table_info(files)").fetchall()
|
|||
|
|
}
|
|||
|
|
required = {
|
|||
|
|
"id", "sha256", "size", "original_name", "stored_name",
|
|||
|
|
"relative_path", "date_taken", "file_type",
|
|||
|
|
"device_make", "device_model", "imported_at",
|
|||
|
|
}
|
|||
|
|
if not required.issubset(cols):
|
|||
|
|
eprint("ERROR: Database schema is not recognised.")
|
|||
|
|
eprint(f"Expected columns including: {sorted(required)}")
|
|||
|
|
eprint(f"Found: {sorted(cols)}")
|
|||
|
|
sys.exit(1)
|
|||
|
|
return conn
|
|||
|
|
except sqlite3.Error as exc:
|
|||
|
|
eprint(f"ERROR: Unable to open database.\n{exc}")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_records(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
|||
|
|
rows = conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT
|
|||
|
|
id,
|
|||
|
|
sha256,
|
|||
|
|
size,
|
|||
|
|
original_name,
|
|||
|
|
stored_name,
|
|||
|
|
relative_path,
|
|||
|
|
date_taken,
|
|||
|
|
file_type,
|
|||
|
|
device_make,
|
|||
|
|
device_model,
|
|||
|
|
imported_at
|
|||
|
|
FROM files
|
|||
|
|
ORDER BY date_taken DESC, id DESC
|
|||
|
|
"""
|
|||
|
|
).fetchall()
|
|||
|
|
|
|||
|
|
records: list[dict[str, Any]] = []
|
|||
|
|
for row in rows:
|
|||
|
|
dt = parse_date_taken(row["date_taken"])
|
|||
|
|
records.append({
|
|||
|
|
"id": row["id"],
|
|||
|
|
"sha256": row["sha256"],
|
|||
|
|
"size": row["size"],
|
|||
|
|
"original_name": row["original_name"],
|
|||
|
|
"stored_name": row["stored_name"],
|
|||
|
|
"relative_path": row["relative_path"],
|
|||
|
|
"date_taken": row["date_taken"],
|
|||
|
|
"file_type": row["file_type"] or TYPE_UNKNOWN,
|
|||
|
|
"device_make": row["device_make"] or "",
|
|||
|
|
"device_model": row["device_model"] or "",
|
|||
|
|
"imported_at": row["imported_at"],
|
|||
|
|
"_dt": dt,
|
|||
|
|
})
|
|||
|
|
return records
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# CHECK MODE
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
def run_check(
|
|||
|
|
backup_root: Path,
|
|||
|
|
records: list[dict[str, Any]],
|
|||
|
|
verbose: bool,
|
|||
|
|
) -> int:
|
|||
|
|
"""
|
|||
|
|
Report consistency issues. Returns number of problems found.
|
|||
|
|
Does not modify anything.
|
|||
|
|
"""
|
|||
|
|
print("=" * 60)
|
|||
|
|
print("CATALOGUE CHECK")
|
|||
|
|
print("=" * 60)
|
|||
|
|
print(f"Backup: {backup_root}")
|
|||
|
|
print(f"Database: {backup_root / DATABASE_NAME}")
|
|||
|
|
print(f"Records: {len(records):,}")
|
|||
|
|
print()
|
|||
|
|
|
|||
|
|
missing: list[str] = []
|
|||
|
|
path_escape: list[str] = []
|
|||
|
|
present = 0
|
|||
|
|
hash_counts: dict[str, int] = defaultdict(int)
|
|||
|
|
|
|||
|
|
for rec in records:
|
|||
|
|
rel = rec["relative_path"]
|
|||
|
|
hash_counts[rec["sha256"]] += 1
|
|||
|
|
|
|||
|
|
# Detect path traversal / escape
|
|||
|
|
try:
|
|||
|
|
full = (backup_root / rel).resolve()
|
|||
|
|
if not str(full).startswith(str(backup_root.resolve())):
|
|||
|
|
path_escape.append(rel)
|
|||
|
|
continue
|
|||
|
|
except Exception:
|
|||
|
|
path_escape.append(rel)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if full.is_file():
|
|||
|
|
present += 1
|
|||
|
|
else:
|
|||
|
|
missing.append(rel)
|
|||
|
|
|
|||
|
|
duplicate_hashes = {
|
|||
|
|
h: c for h, c in hash_counts.items() if c > 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Files on disk not in database (optional, can be slow on huge trees)
|
|||
|
|
db_paths = {rec["relative_path"] for rec in records}
|
|||
|
|
unindexed: list[str] = []
|
|||
|
|
skip_names = {
|
|||
|
|
DATABASE_NAME,
|
|||
|
|
f"{DATABASE_NAME}-wal",
|
|||
|
|
f"{DATABASE_NAME}-shm",
|
|||
|
|
"index.html",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for path in backup_root.rglob("*"):
|
|||
|
|
if not path.is_file():
|
|||
|
|
continue
|
|||
|
|
if path.name in skip_names or path.name.startswith("._"):
|
|||
|
|
continue
|
|||
|
|
if path.name == ".DS_Store":
|
|||
|
|
continue
|
|||
|
|
# Ignore anything under a .catalogue folder if present later
|
|||
|
|
try:
|
|||
|
|
rel = str(path.relative_to(backup_root))
|
|||
|
|
except ValueError:
|
|||
|
|
continue
|
|||
|
|
if rel.startswith(".catalogue/") or rel == "index.html":
|
|||
|
|
continue
|
|||
|
|
if rel not in db_paths:
|
|||
|
|
unindexed.append(rel)
|
|||
|
|
|
|||
|
|
print(f"Files present: {present:,}")
|
|||
|
|
print(f"Missing files: {len(missing):,}")
|
|||
|
|
print(f"Path escape attempts: {len(path_escape):,}")
|
|||
|
|
print(f"Duplicate SHA-256: {len(duplicate_hashes):,}")
|
|||
|
|
print(f"Unindexed on disk: {len(unindexed):,}")
|
|||
|
|
|
|||
|
|
problems = (
|
|||
|
|
len(missing)
|
|||
|
|
+ len(path_escape)
|
|||
|
|
+ len(duplicate_hashes)
|
|||
|
|
+ len(unindexed)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if missing:
|
|||
|
|
print()
|
|||
|
|
print("MISSING FILES:")
|
|||
|
|
for p in missing[:50]:
|
|||
|
|
print(f" {p}")
|
|||
|
|
if len(missing) > 50:
|
|||
|
|
print(f" ... and {len(missing) - 50} more")
|
|||
|
|
|
|||
|
|
if path_escape:
|
|||
|
|
print()
|
|||
|
|
print("PATHS THAT ESCAPE BACKUP ROOT:")
|
|||
|
|
for p in path_escape:
|
|||
|
|
print(f" {p}")
|
|||
|
|
|
|||
|
|
if duplicate_hashes and verbose:
|
|||
|
|
print()
|
|||
|
|
print("DUPLICATE HASHES (first few):")
|
|||
|
|
for i, (h, c) in enumerate(duplicate_hashes.items()):
|
|||
|
|
if i >= 10:
|
|||
|
|
print(f" ... and {len(duplicate_hashes) - 10} more")
|
|||
|
|
break
|
|||
|
|
print(f" {h[:16]}… ×{c}")
|
|||
|
|
|
|||
|
|
if unindexed:
|
|||
|
|
print()
|
|||
|
|
print("UNINDEXED FILES ON DISK (sample):")
|
|||
|
|
for p in unindexed[:30]:
|
|||
|
|
print(f" {p}")
|
|||
|
|
if len(unindexed) > 30:
|
|||
|
|
print(f" ... and {len(unindexed) - 30} more")
|
|||
|
|
|
|||
|
|
print()
|
|||
|
|
if problems == 0:
|
|||
|
|
print("RESULT: CONSISTENT")
|
|||
|
|
else:
|
|||
|
|
print("RESULT: PROBLEMS FOUND")
|
|||
|
|
print("=" * 60)
|
|||
|
|
return problems
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# BUILD CATALOGUE DATA
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
def build_items(
|
|||
|
|
backup_root: Path,
|
|||
|
|
records: list[dict[str, Any]],
|
|||
|
|
verbose: bool,
|
|||
|
|
) -> tuple[list[dict[str, Any]], int, int, int]:
|
|||
|
|
"""
|
|||
|
|
Convert DB records into catalogue items.
|
|||
|
|
Returns (items, present_count, missing_count, unknown_count).
|
|||
|
|
Only includes items whose files exist.
|
|||
|
|
"""
|
|||
|
|
items: list[dict[str, Any]] = []
|
|||
|
|
missing = 0
|
|||
|
|
unknown = 0
|
|||
|
|
present = 0
|
|||
|
|
|
|||
|
|
for rec in records:
|
|||
|
|
rel = rec["relative_path"]
|
|||
|
|
full = backup_root / rel
|
|||
|
|
|
|||
|
|
if not full.is_file():
|
|||
|
|
missing += 1
|
|||
|
|
if verbose:
|
|||
|
|
eprint(f" missing: {rel}")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
present += 1
|
|||
|
|
dt = rec["_dt"]
|
|||
|
|
ftype = rec["file_type"]
|
|||
|
|
|
|||
|
|
if ftype == TYPE_UNKNOWN:
|
|||
|
|
unknown += 1
|
|||
|
|
|
|||
|
|
# Determine display type for the UI
|
|||
|
|
if ftype == TYPE_SCREENSHOT:
|
|||
|
|
ui_type = "screenshot"
|
|||
|
|
elif ftype == TYPE_VIDEO:
|
|||
|
|
ui_type = "video"
|
|||
|
|
elif ftype == TYPE_IMAGE:
|
|||
|
|
if is_raw(rel, rec["original_name"]):
|
|||
|
|
ui_type = "raw"
|
|||
|
|
else:
|
|||
|
|
ui_type = "image"
|
|||
|
|
else:
|
|||
|
|
ui_type = "unknown"
|
|||
|
|
|
|||
|
|
# Relative path suitable for HTML (forward slashes, no leading ./)
|
|||
|
|
web_path = rel.replace("\\", "/")
|
|||
|
|
|
|||
|
|
device = " ".join(
|
|||
|
|
part for part in (rec["device_make"], rec["device_model"]) if part
|
|||
|
|
).strip()
|
|||
|
|
|
|||
|
|
item = {
|
|||
|
|
"id": rec["id"],
|
|||
|
|
"name": rec["stored_name"],
|
|||
|
|
"original": rec["original_name"],
|
|||
|
|
"path": web_path,
|
|||
|
|
"type": ui_type,
|
|||
|
|
"date": dt.strftime("%Y-%m-%d") if dt else "",
|
|||
|
|
"displayDate": format_display_date(dt) if dt else "",
|
|||
|
|
"displayDay": format_display_day(dt) if dt else "",
|
|||
|
|
"time": dt.strftime("%H:%M:%S") if dt and (dt.hour or dt.minute or dt.second) else "",
|
|||
|
|
"year": dt.year if dt else 0,
|
|||
|
|
"month": dt.month if dt else 0,
|
|||
|
|
"monthName": MONTH_NAMES[dt.month - 1] if dt else "",
|
|||
|
|
"day": dt.day if dt else 0,
|
|||
|
|
"device": device,
|
|||
|
|
"size": rec["size"],
|
|||
|
|
# Sort key: newest first
|
|||
|
|
"sort": dt.timestamp() if dt else 0,
|
|||
|
|
}
|
|||
|
|
items.append(item)
|
|||
|
|
|
|||
|
|
# Newest first overall
|
|||
|
|
items.sort(key=lambda x: (-x["sort"], -x["id"]))
|
|||
|
|
return items, present, missing, unknown
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# HTML GENERATION
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
# Large HTML/JS template. Data is injected via a unique placeholder
|
|||
|
|
# so we never use Python f-strings for the JS body (avoids { } conflicts).
|
|||
|
|
|
|||
|
|
HTML_TEMPLATE = r'''<!DOCTYPE html>
|
|||
|
|
<html lang="en">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|||
|
|
<title>Photo Archive</title>
|
|||
|
|
<style>
|
|||
|
|
:root {
|
|||
|
|
--bg: #f4f4f2;
|
|||
|
|
--surface: #ffffff;
|
|||
|
|
--text: #1c1c1c;
|
|||
|
|
--muted: #6b6b6b;
|
|||
|
|
--border: #e0e0dc;
|
|||
|
|
--accent: #2a2a2a;
|
|||
|
|
--thumb: 140px;
|
|||
|
|
--radius: 4px;
|
|||
|
|
}
|
|||
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|||
|
|
html { font-size: 15px; }
|
|||
|
|
body {
|
|||
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|||
|
|
background: var(--bg);
|
|||
|
|
color: var(--text);
|
|||
|
|
line-height: 1.4;
|
|||
|
|
min-height: 100vh;
|
|||
|
|
}
|
|||
|
|
a { color: inherit; text-decoration: none; }
|
|||
|
|
|
|||
|
|
/* ---- Header ---- */
|
|||
|
|
.header {
|
|||
|
|
position: sticky;
|
|||
|
|
top: 0;
|
|||
|
|
z-index: 100;
|
|||
|
|
background: var(--surface);
|
|||
|
|
border-bottom: 1px solid var(--border);
|
|||
|
|
padding: 0.75rem 1.25rem;
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.75rem 1.25rem;
|
|||
|
|
}
|
|||
|
|
.header-title {
|
|||
|
|
font-size: 1.05rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
letter-spacing: -0.01em;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
.header-title span {
|
|||
|
|
font-weight: 400;
|
|||
|
|
color: var(--muted);
|
|||
|
|
margin-left: 0.35rem;
|
|||
|
|
}
|
|||
|
|
.search-wrap {
|
|||
|
|
flex: 1 1 180px;
|
|||
|
|
max-width: 280px;
|
|||
|
|
}
|
|||
|
|
.search-wrap input {
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 0.4rem 0.65rem;
|
|||
|
|
border: 1px solid var(--border);
|
|||
|
|
border-radius: var(--radius);
|
|||
|
|
background: var(--bg);
|
|||
|
|
color: var(--text);
|
|||
|
|
font-size: 0.9rem;
|
|||
|
|
outline: none;
|
|||
|
|
}
|
|||
|
|
.search-wrap input:focus {
|
|||
|
|
border-color: #aaa;
|
|||
|
|
}
|
|||
|
|
.filters {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 0.25rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
.filters button {
|
|||
|
|
padding: 0.35rem 0.7rem;
|
|||
|
|
border: 1px solid var(--border);
|
|||
|
|
border-radius: var(--radius);
|
|||
|
|
background: var(--bg);
|
|||
|
|
color: var(--muted);
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
.filters button:hover { color: var(--text); }
|
|||
|
|
.filters button.active {
|
|||
|
|
background: var(--accent);
|
|||
|
|
color: #fff;
|
|||
|
|
border-color: var(--accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---- Main ---- */
|
|||
|
|
main {
|
|||
|
|
max-width: 1400px;
|
|||
|
|
margin: 0 auto;
|
|||
|
|
padding: 1.25rem 1.25rem 3rem;
|
|||
|
|
}
|
|||
|
|
.year-block { margin-bottom: 2.5rem; }
|
|||
|
|
.year-heading {
|
|||
|
|
font-size: 1.35rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
margin-bottom: 1rem;
|
|||
|
|
letter-spacing: -0.02em;
|
|||
|
|
}
|
|||
|
|
.month-block { margin-bottom: 1.75rem; }
|
|||
|
|
.month-heading {
|
|||
|
|
font-size: 1rem;
|
|||
|
|
font-weight: 500;
|
|||
|
|
color: var(--muted);
|
|||
|
|
margin-bottom: 0.75rem;
|
|||
|
|
padding-bottom: 0.25rem;
|
|||
|
|
border-bottom: 1px solid var(--border);
|
|||
|
|
}
|
|||
|
|
.day-block { margin-bottom: 1.25rem; }
|
|||
|
|
.day-heading {
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
color: var(--muted);
|
|||
|
|
margin-bottom: 0.5rem;
|
|||
|
|
}
|
|||
|
|
.day-heading strong { color: var(--text); font-weight: 500; }
|
|||
|
|
|
|||
|
|
/* ---- Grid ---- */
|
|||
|
|
.grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(auto-fill, minmax(var(--thumb), 1fr));
|
|||
|
|
gap: 6px;
|
|||
|
|
}
|
|||
|
|
.card {
|
|||
|
|
position: relative;
|
|||
|
|
aspect-ratio: 1;
|
|||
|
|
background: #ddd;
|
|||
|
|
border-radius: var(--radius);
|
|||
|
|
overflow: hidden;
|
|||
|
|
cursor: pointer;
|
|||
|
|
border: 1px solid transparent;
|
|||
|
|
}
|
|||
|
|
.card:hover { border-color: #bbb; }
|
|||
|
|
.card img,
|
|||
|
|
.card video {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
object-fit: cover;
|
|||
|
|
display: block;
|
|||
|
|
background: #e8e8e4;
|
|||
|
|
}
|
|||
|
|
.card .placeholder {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
background: #e4e4e0;
|
|||
|
|
color: var(--muted);
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
text-align: center;
|
|||
|
|
padding: 0.5rem;
|
|||
|
|
word-break: break-all;
|
|||
|
|
}
|
|||
|
|
.card .badge {
|
|||
|
|
position: absolute;
|
|||
|
|
bottom: 4px;
|
|||
|
|
left: 4px;
|
|||
|
|
background: rgba(0,0,0,0.55);
|
|||
|
|
color: #fff;
|
|||
|
|
font-size: 0.65rem;
|
|||
|
|
padding: 1px 5px;
|
|||
|
|
border-radius: 3px;
|
|||
|
|
pointer-events: none;
|
|||
|
|
letter-spacing: 0.02em;
|
|||
|
|
}
|
|||
|
|
.empty-state {
|
|||
|
|
text-align: center;
|
|||
|
|
color: var(--muted);
|
|||
|
|
padding: 4rem 1rem;
|
|||
|
|
font-size: 0.95rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---- Lightbox ---- */
|
|||
|
|
.lightbox {
|
|||
|
|
display: none;
|
|||
|
|
position: fixed;
|
|||
|
|
inset: 0;
|
|||
|
|
z-index: 1000;
|
|||
|
|
background: rgba(12,12,12,0.92);
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
.lightbox.open { display: flex; }
|
|||
|
|
.lb-media {
|
|||
|
|
max-width: 92vw;
|
|||
|
|
max-height: 78vh;
|
|||
|
|
object-fit: contain;
|
|||
|
|
border-radius: 2px;
|
|||
|
|
}
|
|||
|
|
.lb-video {
|
|||
|
|
max-width: 92vw;
|
|||
|
|
max-height: 78vh;
|
|||
|
|
background: #000;
|
|||
|
|
}
|
|||
|
|
.lb-placeholder {
|
|||
|
|
color: #ccc;
|
|||
|
|
font-size: 1rem;
|
|||
|
|
text-align: center;
|
|||
|
|
padding: 2rem;
|
|||
|
|
max-width: 80vw;
|
|||
|
|
}
|
|||
|
|
.lb-meta {
|
|||
|
|
margin-top: 0.85rem;
|
|||
|
|
color: #ccc;
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
text-align: center;
|
|||
|
|
max-width: 90vw;
|
|||
|
|
}
|
|||
|
|
.lb-meta .name { color: #fff; font-weight: 500; }
|
|||
|
|
.lb-meta .detail { color: #999; margin-top: 0.2rem; }
|
|||
|
|
.lb-close {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 0.75rem;
|
|||
|
|
right: 1rem;
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
color: #ccc;
|
|||
|
|
font-size: 1.75rem;
|
|||
|
|
cursor: pointer;
|
|||
|
|
line-height: 1;
|
|||
|
|
padding: 0.25rem 0.5rem;
|
|||
|
|
}
|
|||
|
|
.lb-close:hover { color: #fff; }
|
|||
|
|
.lb-nav {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 50%;
|
|||
|
|
transform: translateY(-50%);
|
|||
|
|
background: rgba(255,255,255,0.08);
|
|||
|
|
border: none;
|
|||
|
|
color: #ddd;
|
|||
|
|
font-size: 1.6rem;
|
|||
|
|
width: 2.5rem;
|
|||
|
|
height: 2.5rem;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
cursor: pointer;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
.lb-nav:hover { background: rgba(255,255,255,0.18); color: #fff; }
|
|||
|
|
.lb-prev { left: 0.75rem; }
|
|||
|
|
.lb-next { right: 0.75rem; }
|
|||
|
|
|
|||
|
|
@media (max-width: 600px) {
|
|||
|
|
:root { --thumb: 100px; }
|
|||
|
|
.header { padding: 0.6rem 0.75rem; }
|
|||
|
|
main { padding: 1rem 0.75rem 2rem; }
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
|
|||
|
|
<header class="header">
|
|||
|
|
<div class="header-title">Photo Archive <span id="item-count"></span></div>
|
|||
|
|
<div class="search-wrap">
|
|||
|
|
<input type="search" id="search" placeholder="Search…" autocomplete="off" spellcheck="false">
|
|||
|
|
</div>
|
|||
|
|
<div class="filters" id="filters">
|
|||
|
|
<button data-filter="all" class="active">All</button>
|
|||
|
|
<button data-filter="image">Photos</button>
|
|||
|
|
<button data-filter="video">Videos</button>
|
|||
|
|
<button data-filter="screenshot">Screenshots</button>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<main id="catalogue"></main>
|
|||
|
|
|
|||
|
|
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true">
|
|||
|
|
<button class="lb-close" id="lb-close" title="Close (Esc)">×</button>
|
|||
|
|
<button class="lb-nav lb-prev" id="lb-prev" title="Previous">‹</button>
|
|||
|
|
<button class="lb-nav lb-next" id="lb-next" title="Next">›</button>
|
|||
|
|
<div id="lb-content"></div>
|
|||
|
|
<div class="lb-meta" id="lb-meta"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
/* ---- Embedded data (replaced by generator) ---- */
|
|||
|
|
const ARCHIVE = __CATALOGUE_DATA__;
|
|||
|
|
|
|||
|
|
/* ---- State ---- */
|
|||
|
|
let currentFilter = "all";
|
|||
|
|
let currentQuery = "";
|
|||
|
|
let visibleItems = [];
|
|||
|
|
let viewerIndex = -1;
|
|||
|
|
|
|||
|
|
const $catalogue = document.getElementById("catalogue");
|
|||
|
|
const $search = document.getElementById("search");
|
|||
|
|
const $itemCount = document.getElementById("item-count");
|
|||
|
|
const $lightbox = document.getElementById("lightbox");
|
|||
|
|
const $lbContent = document.getElementById("lb-content");
|
|||
|
|
const $lbMeta = document.getElementById("lb-meta");
|
|||
|
|
|
|||
|
|
/* ---- Helpers ---- */
|
|||
|
|
function esc(s) {
|
|||
|
|
if (s == null) return "";
|
|||
|
|
return String(s)
|
|||
|
|
.replace(/&/g, "&")
|
|||
|
|
.replace(/</g, "<")
|
|||
|
|
.replace(/>/g, ">")
|
|||
|
|
.replace(/"/g, """)
|
|||
|
|
.replace(/'/g, "'");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function matchesQuery(item, q) {
|
|||
|
|
if (!q) return true;
|
|||
|
|
const hay = [
|
|||
|
|
item.name,
|
|||
|
|
item.original,
|
|||
|
|
item.path,
|
|||
|
|
item.date,
|
|||
|
|
item.displayDate,
|
|||
|
|
item.displayDay,
|
|||
|
|
item.time,
|
|||
|
|
item.type,
|
|||
|
|
item.device,
|
|||
|
|
item.monthName,
|
|||
|
|
String(item.year),
|
|||
|
|
].join(" ").toLowerCase();
|
|||
|
|
return hay.includes(q);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function filterItems() {
|
|||
|
|
const q = currentQuery.trim().toLowerCase();
|
|||
|
|
visibleItems = ARCHIVE.items.filter(function(item) {
|
|||
|
|
if (currentFilter === "image") {
|
|||
|
|
// Photos filter shows normal images + RAW (not screenshots)
|
|||
|
|
if (item.type !== "image" && item.type !== "raw") return false;
|
|||
|
|
} else if (currentFilter === "video") {
|
|||
|
|
if (item.type !== "video") return false;
|
|||
|
|
} else if (currentFilter === "screenshot") {
|
|||
|
|
if (item.type !== "screenshot") return false;
|
|||
|
|
}
|
|||
|
|
// "all" shows everything including unknown/raw
|
|||
|
|
return matchesQuery(item, q);
|
|||
|
|
});
|
|||
|
|
return visibleItems;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---- Render ---- */
|
|||
|
|
function render() {
|
|||
|
|
const items = filterItems();
|
|||
|
|
$itemCount.textContent = items.length.toLocaleString() + " items";
|
|||
|
|
|
|||
|
|
if (items.length === 0) {
|
|||
|
|
$catalogue.innerHTML = '<div class="empty-state">No items match.</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Group: year → month → day
|
|||
|
|
const years = {};
|
|||
|
|
items.forEach(function(item) {
|
|||
|
|
const y = item.year || 0;
|
|||
|
|
const m = item.month || 0;
|
|||
|
|
const d = item.day || 0;
|
|||
|
|
if (!years[y]) years[y] = {};
|
|||
|
|
if (!years[y][m]) years[y][m] = {};
|
|||
|
|
if (!years[y][m][d]) years[y][m][d] = [];
|
|||
|
|
years[y][m][d].push(item);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const yearKeys = Object.keys(years).map(Number).sort(function(a, b) { return b - a; });
|
|||
|
|
let html = "";
|
|||
|
|
|
|||
|
|
yearKeys.forEach(function(y) {
|
|||
|
|
html += '<section class="year-block">';
|
|||
|
|
html += '<h2 class="year-heading">' + (y || "Unknown year") + '</h2>';
|
|||
|
|
|
|||
|
|
const months = years[y];
|
|||
|
|
const monthKeys = Object.keys(months).map(Number).sort(function(a, b) { return b - a; });
|
|||
|
|
|
|||
|
|
monthKeys.forEach(function(m) {
|
|||
|
|
const monthName = (m >= 1 && m <= 12)
|
|||
|
|
? ["January","February","March","April","May","June","July","August","September","October","November","December"][m - 1]
|
|||
|
|
: "Unknown";
|
|||
|
|
html += '<div class="month-block">';
|
|||
|
|
html += '<h3 class="month-heading">' + monthName + '</h3>';
|
|||
|
|
|
|||
|
|
const days = months[m];
|
|||
|
|
const dayKeys = Object.keys(days).map(Number).sort(function(a, b) { return b - a; });
|
|||
|
|
|
|||
|
|
dayKeys.forEach(function(d) {
|
|||
|
|
const dayItems = days[d];
|
|||
|
|
// Within day: newest time first (already sorted globally, but re-sort for safety)
|
|||
|
|
dayItems.sort(function(a, b) { return b.sort - a.sort; });
|
|||
|
|
const label = dayItems[0].displayDay || (d || "?");
|
|||
|
|
html += '<div class="day-block">';
|
|||
|
|
html += '<div class="day-heading"><strong>' + esc(label) + '</strong> · ' + dayItems.length + '</div>';
|
|||
|
|
html += '<div class="grid">';
|
|||
|
|
|
|||
|
|
dayItems.forEach(function(item) {
|
|||
|
|
// Find global index in visibleItems for viewer navigation
|
|||
|
|
const idx = visibleItems.indexOf(item);
|
|||
|
|
html += cardHtml(item, idx);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
html += '</div></div>';
|
|||
|
|
});
|
|||
|
|
html += '</div>';
|
|||
|
|
});
|
|||
|
|
html += '</section>';
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
$catalogue.innerHTML = html;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cardHtml(item, idx) {
|
|||
|
|
let media = "";
|
|||
|
|
let badge = "";
|
|||
|
|
|
|||
|
|
if (item.type === "video") {
|
|||
|
|
media = '<div class="placeholder">▶</div>';
|
|||
|
|
badge = '<span class="badge">video</span>';
|
|||
|
|
} else if (item.type === "raw") {
|
|||
|
|
media = '<div class="placeholder">RAW<br>' + esc(item.name) + '</div>';
|
|||
|
|
badge = '<span class="badge">RAW</span>';
|
|||
|
|
} else if (item.type === "unknown") {
|
|||
|
|
media = '<div class="placeholder">FILE<br>' + esc(item.name) + '</div>';
|
|||
|
|
} else if (item.type === "screenshot") {
|
|||
|
|
media = '<img src="' + esc(item.path) + '" alt="" loading="lazy" decoding="async">';
|
|||
|
|
badge = '<span class="badge">shot</span>';
|
|||
|
|
} else {
|
|||
|
|
// normal image
|
|||
|
|
media = '<img src="' + esc(item.path) + '" alt="" loading="lazy" decoding="async">';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return '<div class="card" data-idx="' + idx + '" tabindex="0" role="button">'
|
|||
|
|
+ media + badge + '</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---- Lightbox ---- */
|
|||
|
|
function openViewer(idx) {
|
|||
|
|
if (idx < 0 || idx >= visibleItems.length) return;
|
|||
|
|
viewerIndex = idx;
|
|||
|
|
const item = visibleItems[idx];
|
|||
|
|
$lbContent.innerHTML = "";
|
|||
|
|
|
|||
|
|
if (item.type === "video") {
|
|||
|
|
const v = document.createElement("video");
|
|||
|
|
v.className = "lb-video";
|
|||
|
|
v.controls = true;
|
|||
|
|
v.autoplay = true;
|
|||
|
|
v.src = item.path;
|
|||
|
|
$lbContent.appendChild(v);
|
|||
|
|
} else if (item.type === "image" || item.type === "screenshot") {
|
|||
|
|
const img = document.createElement("img");
|
|||
|
|
img.className = "lb-media";
|
|||
|
|
img.src = item.path;
|
|||
|
|
img.alt = item.name;
|
|||
|
|
$lbContent.appendChild(img);
|
|||
|
|
} else {
|
|||
|
|
const p = document.createElement("div");
|
|||
|
|
p.className = "lb-placeholder";
|
|||
|
|
p.textContent = (item.type === "raw" ? "RAW file — open from disk:\n" : "File:\n") + item.path;
|
|||
|
|
$lbContent.appendChild(p);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let meta = '<div class="name">' + esc(item.name) + '</div>';
|
|||
|
|
let detail = [];
|
|||
|
|
if (item.displayDate) detail.push(item.displayDate + (item.time ? " · " + item.time : ""));
|
|||
|
|
if (item.type && item.type !== "image") detail.push(item.type);
|
|||
|
|
if (item.device) detail.push(item.device);
|
|||
|
|
if (item.original && item.original !== item.name) detail.push("orig: " + item.original);
|
|||
|
|
if (detail.length) meta += '<div class="detail">' + esc(detail.join(" · ")) + '</div>';
|
|||
|
|
$lbMeta.innerHTML = meta;
|
|||
|
|
|
|||
|
|
$lightbox.classList.add("open");
|
|||
|
|
document.body.style.overflow = "hidden";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function closeViewer() {
|
|||
|
|
$lightbox.classList.remove("open");
|
|||
|
|
document.body.style.overflow = "";
|
|||
|
|
$lbContent.innerHTML = "";
|
|||
|
|
viewerIndex = -1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function navViewer(delta) {
|
|||
|
|
if (viewerIndex < 0) return;
|
|||
|
|
const next = viewerIndex + delta;
|
|||
|
|
if (next < 0 || next >= visibleItems.length) return;
|
|||
|
|
openViewer(next);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---- Events ---- */
|
|||
|
|
$catalogue.addEventListener("click", function(e) {
|
|||
|
|
const card = e.target.closest(".card");
|
|||
|
|
if (!card) return;
|
|||
|
|
const idx = parseInt(card.getAttribute("data-idx"), 10);
|
|||
|
|
if (!isNaN(idx)) openViewer(idx);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
$catalogue.addEventListener("keydown", function(e) {
|
|||
|
|
if (e.key !== "Enter" && e.key !== " ") return;
|
|||
|
|
const card = e.target.closest(".card");
|
|||
|
|
if (!card) return;
|
|||
|
|
e.preventDefault();
|
|||
|
|
const idx = parseInt(card.getAttribute("data-idx"), 10);
|
|||
|
|
if (!isNaN(idx)) openViewer(idx);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.getElementById("lb-close").addEventListener("click", closeViewer);
|
|||
|
|
document.getElementById("lb-prev").addEventListener("click", function() { navViewer(-1); });
|
|||
|
|
document.getElementById("lb-next").addEventListener("click", function() { navViewer(1); });
|
|||
|
|
|
|||
|
|
$lightbox.addEventListener("click", function(e) {
|
|||
|
|
if (e.target === $lightbox) closeViewer();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.addEventListener("keydown", function(e) {
|
|||
|
|
if (!$lightbox.classList.contains("open")) return;
|
|||
|
|
if (e.key === "Escape") closeViewer();
|
|||
|
|
else if (e.key === "ArrowLeft") navViewer(-1);
|
|||
|
|
else if (e.key === "ArrowRight") navViewer(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.getElementById("filters").addEventListener("click", function(e) {
|
|||
|
|
const btn = e.target.closest("button[data-filter]");
|
|||
|
|
if (!btn) return;
|
|||
|
|
currentFilter = btn.getAttribute("data-filter");
|
|||
|
|
document.querySelectorAll("#filters button").forEach(function(b) {
|
|||
|
|
b.classList.toggle("active", b === btn);
|
|||
|
|
});
|
|||
|
|
render();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let searchTimer = null;
|
|||
|
|
$search.addEventListener("input", function() {
|
|||
|
|
clearTimeout(searchTimer);
|
|||
|
|
searchTimer = setTimeout(function() {
|
|||
|
|
currentQuery = $search.value;
|
|||
|
|
render();
|
|||
|
|
}, 120);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/* ---- Init ---- */
|
|||
|
|
render();
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
'''
|
|||
|
|
|
|||
|
|
|
|||
|
|
def generate_html(items: list[dict[str, Any]]) -> str:
|
|||
|
|
"""Build final index.html with JSON data safely embedded."""
|
|||
|
|
# Strip internal sort helper before serialising
|
|||
|
|
clean = []
|
|||
|
|
for it in items:
|
|||
|
|
clean.append({
|
|||
|
|
"id": it["id"],
|
|||
|
|
"name": it["name"],
|
|||
|
|
"original": it["original"],
|
|||
|
|
"path": it["path"],
|
|||
|
|
"type": it["type"],
|
|||
|
|
"date": it["date"],
|
|||
|
|
"displayDate": it["displayDate"],
|
|||
|
|
"displayDay": it["displayDay"],
|
|||
|
|
"time": it["time"],
|
|||
|
|
"year": it["year"],
|
|||
|
|
"month": it["month"],
|
|||
|
|
"monthName": it["monthName"],
|
|||
|
|
"day": it["day"],
|
|||
|
|
"device": it["device"],
|
|||
|
|
"size": it["size"],
|
|||
|
|
"sort": it["sort"],
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
payload = {"items": clean}
|
|||
|
|
# Ensure safe embedding inside <script>: break out of potential </script>
|
|||
|
|
json_text = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|||
|
|
json_text = json_text.replace("<", "\\u003c").replace(">", "\\u003e")
|
|||
|
|
|
|||
|
|
html_out = HTML_TEMPLATE.replace("__CATALOGUE_DATA__", json_text)
|
|||
|
|
return html_out
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# MAIN
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
def main() -> None:
|
|||
|
|
parser = argparse.ArgumentParser(
|
|||
|
|
prog="catalogue.py",
|
|||
|
|
description=(
|
|||
|
|
"Generate a read-only offline HTML catalogue from an "
|
|||
|
|
"o-phone.py photo/video backup."
|
|||
|
|
),
|
|||
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|||
|
|
epilog="""
|
|||
|
|
Examples:
|
|||
|
|
python3 catalogue.py "/Volumes/all/phone-media-backup"
|
|||
|
|
python3 catalogue.py "/Volumes/all/phone-media-backup" --open
|
|||
|
|
python3 catalogue.py "/Volumes/all/phone-media-backup" --check
|
|||
|
|
python3 catalogue.py "/Volumes/all/phone-media-backup" --check --verbose
|
|||
|
|
""".strip(),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
parser.add_argument(
|
|||
|
|
"backup",
|
|||
|
|
type=Path,
|
|||
|
|
nargs="?",
|
|||
|
|
help="Root folder of the existing backup (contains .photo_organiser.db)",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--check",
|
|||
|
|
action="store_true",
|
|||
|
|
help="Check database/filesystem consistency without generating the catalogue",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--open",
|
|||
|
|
action="store_true",
|
|||
|
|
help="After generating, open index.html in the default browser",
|
|||
|
|
)
|
|||
|
|
parser.add_argument(
|
|||
|
|
"--verbose",
|
|||
|
|
action="store_true",
|
|||
|
|
help="Show additional diagnostic information",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
|
|||
|
|
if args.backup is None:
|
|||
|
|
parser.print_help()
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
backup_root = args.backup.expanduser().resolve()
|
|||
|
|
|
|||
|
|
if not backup_root.exists():
|
|||
|
|
eprint(f"ERROR: Backup folder does not exist.\n{backup_root}")
|
|||
|
|
sys.exit(1)
|
|||
|
|
if not backup_root.is_dir():
|
|||
|
|
eprint(f"ERROR: Not a folder.\n{backup_root}")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
db_path = backup_root / DATABASE_NAME
|
|||
|
|
|
|||
|
|
print("Reading database...")
|
|||
|
|
conn = open_database_readonly(db_path)
|
|||
|
|
try:
|
|||
|
|
records = load_records(conn)
|
|||
|
|
finally:
|
|||
|
|
conn.close()
|
|||
|
|
|
|||
|
|
print(f"Found {len(records):,} records.")
|
|||
|
|
|
|||
|
|
if args.check:
|
|||
|
|
problems = run_check(backup_root, records, args.verbose)
|
|||
|
|
sys.exit(1 if problems else 0)
|
|||
|
|
|
|||
|
|
# ---- Generate catalogue ----
|
|||
|
|
print("Checking files...")
|
|||
|
|
items, present, missing, unknown = build_items(
|
|||
|
|
backup_root, records, args.verbose
|
|||
|
|
)
|
|||
|
|
print(f"{present:,} files present.")
|
|||
|
|
if missing:
|
|||
|
|
print(f"WARNING: {missing:,} files referenced by the database are missing.")
|
|||
|
|
if unknown:
|
|||
|
|
print(f"Note: {unknown:,} items have unknown/unsupported type.")
|
|||
|
|
|
|||
|
|
print("Generating catalogue...")
|
|||
|
|
html_content = generate_html(items)
|
|||
|
|
|
|||
|
|
out_path = backup_root / "index.html"
|
|||
|
|
try:
|
|||
|
|
out_path.write_text(html_content, encoding="utf-8")
|
|||
|
|
except OSError as exc:
|
|||
|
|
eprint(f"ERROR: Could not write index.html.\n{exc}")
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
print("Wrote:")
|
|||
|
|
print(f" {out_path}")
|
|||
|
|
|
|||
|
|
if args.open:
|
|||
|
|
try:
|
|||
|
|
# Prefer OS open on macOS; fall back to webbrowser
|
|||
|
|
if sys.platform == "darwin":
|
|||
|
|
os.system(f'open "{out_path}"') # noqa: S605
|
|||
|
|
else:
|
|||
|
|
webbrowser.open(out_path.as_uri())
|
|||
|
|
except Exception as exc:
|
|||
|
|
eprint(f"WARNING: Could not open browser: {exc}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|