Files
o-phone/catalogue.py
T

1077 lines
30 KiB
Python
Raw Normal View History

2026-08-14 21:06:25 +01:00
#!/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)">&times;</button>
<button class="lb-nav lb-prev" id="lb-prev" title="Previous">&#8249;</button>
<button class="lb-nav lb-next" id="lb-next" title="Next">&#8250;</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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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()