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()
|