chore: remove travel-memories service from repo (moved to separate project)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
This commit is contained in:
2026-06-26 18:23:54 +02:00
co-authored by Claude Sonnet 4.6
parent 4df191b9f4
commit a80b0a90fc
42 changed files with 0 additions and 2911 deletions
-4
View File
@@ -1,4 +0,0 @@
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
-10
View File
@@ -1,10 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && \
playwright install chromium --with-deps
COPY app/ ./app/
ENV FLASK_APP=app
ENV FLASK_RUN_HOST=0.0.0.0
ENV FLASK_RUN_PORT=8082
CMD ["flask", "run"]
-26
View File
@@ -1,26 +0,0 @@
import os
from flask import Flask
def create_app(state_dir=None, pages_dir=None):
app = Flask(__name__)
app.config["STATE_DIR"] = state_dir or os.environ.get("STATE_DIR", "/app/state")
app.config["PAGES_DIR"] = pages_dir or os.environ.get("PAGES_DIR", "/app/pages")
app.config["IMMICH_URL"] = os.environ.get("IMMICH_URL", "")
app.config["IMMICH_API_KEY"] = os.environ.get("IMMICH_API_KEY", "")
from .routes import albums, triage, proxy, notes, nav, curate, group, write, export
app.register_blueprint(albums.bp)
app.register_blueprint(triage.bp)
app.register_blueprint(proxy.bp)
app.register_blueprint(notes.bp)
app.register_blueprint(nav.bp)
app.register_blueprint(curate.bp)
app.register_blueprint(group.bp)
app.register_blueprint(write.bp)
app.register_blueprint(export.bp)
@app.get("/health")
def health():
return {"ok": True}
return app
-30
View File
@@ -1,30 +0,0 @@
import requests
class ImmichClient:
def __init__(self, base_url: str, api_key: str):
self.base_url = base_url.rstrip("/")
self.headers = {"Authorization": f"Bearer {api_key}"}
def _get(self, path: str, **kwargs):
try:
r = requests.get(f"{self.base_url}{path}",
headers=self.headers, timeout=10, **kwargs)
r.raise_for_status()
return r
except requests.exceptions.ConnectionError as e:
raise ConnectionError(f"Cannot reach Immich: {e}") from e
def list_albums(self) -> list:
return self._get("/api/albums").json()
def get_album(self, album_id: str) -> dict:
return self._get(f"/api/albums/{album_id}",
params={"withoutAssets": "false"}).json()
def get_thumbnail(self, asset_id: str) -> bytes:
return self._get(f"/api/assets/{asset_id}/thumbnail",
params={"size": "preview"}).content
def get_original(self, asset_id: str) -> bytes:
return self._get(f"/api/assets/{asset_id}/original").content
@@ -1,79 +0,0 @@
import re
from pathlib import Path
from flask import Blueprint, current_app, redirect, render_template, request
from app.immich import ImmichClient
from app.state import TripState, Photo, load_state, save_state
bp = Blueprint("albums", __name__)
def _sanitise_slug(s: str) -> str:
s = s.strip().lower()
s = re.sub(r'[^a-z0-9-]+', '-', s)
return s.strip('-')
def _client():
return ImmichClient(current_app.config["IMMICH_URL"],
current_app.config["IMMICH_API_KEY"])
@bp.get("/")
def index():
try:
albums = _client().list_albums()
error = None
except ConnectionError as e:
albums = []
error = str(e)
state_dir = Path(current_app.config["STATE_DIR"])
for album in albums:
album["has_state"] = (state_dir / f"{album['id']}.json").exists()
return render_template("phase1.html", albums=albums, error=error,
current_phase="", album_id=None,
phase_stale=[], notes_content="")
@bp.post("/select")
def select():
album_ids = request.form.getlist("album_ids[]")
grav_trip_slug = _sanitise_slug(request.form["grav_trip_slug"])
start_over = request.form.get("start_over") == "1"
if len(album_ids) == 1:
primary_id = album_ids[0]
else:
primary_id = "__merged__" + "_".join(sorted(album_ids))
existing = load_state(primary_id, current_app)
if existing and not start_over:
return redirect(f"/{existing.phase}?album_id={primary_id}")
# Fetch and merge assets, deduplicating by asset ID
all_assets = {}
album_name_parts = []
for aid in album_ids:
album = _client().get_album(aid)
album_name_parts.append(album["albumName"])
for asset in album["assets"]:
if asset["id"] not in all_assets:
all_assets[asset["id"]] = asset
photos = [
Photo(id=a["id"], original_filename=a["originalFileName"],
local_datetime=a["localDateTime"])
for a in sorted(all_assets.values(), key=lambda x: x["localDateTime"])
]
for i, p in enumerate(photos):
p.order = i
state = TripState(
album_id=primary_id,
album_name=", ".join(album_name_parts),
grav_trip_slug=grav_trip_slug,
photos=photos,
)
save_state(state, current_app)
return redirect(f"/triage?album_id={primary_id}")
@@ -1,71 +0,0 @@
from flask import Blueprint, current_app, jsonify, render_template, request
from app.state import load_state, save_state
bp = Blueprint("curate", __name__)
@bp.get("/curate")
def curate():
album_id = request.args["album_id"]
state = load_state(album_id, current_app)
kept = [p for p in state.photos if p.tag in ("journal", "story")]
photos_by_day = {}
for p in kept:
day = p.local_datetime[:10]
photos_by_day.setdefault(day, []).append(p)
return render_template(
"phase3.html",
state=state,
photos_by_day=photos_by_day,
current_phase="curate",
album_id=album_id,
phase_stale=state.phase_stale,
notes_content=state.notes,
)
@bp.post("/curate/remove")
def remove():
body = request.get_json()
state = load_state(body["album_id"], current_app)
photo = next((p for p in state.photos if p.id == body["asset_id"]), None)
if photo is None:
return jsonify({"ok": False, "error": "photo not found"}), 404
photo.tag = "skip"
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/curate/swap")
def swap():
body = request.get_json()
state = load_state(body["album_id"], current_app)
photo = next((p for p in state.photos if p.id == body["asset_id"]), None)
if photo is None:
return jsonify({"ok": False, "error": "photo not found"}), 404
photo.tag = "story" if photo.tag == "journal" else "journal"
save_state(state, current_app)
return jsonify({"ok": True, "new_tag": photo.tag})
@bp.post("/curate/reorder")
def reorder():
body = request.get_json()
state = load_state(body["album_id"], current_app)
order_map = {aid: i for i, aid in enumerate(body["order"])}
for p in state.photos:
if p.id in order_map:
p.order = order_map[p.id]
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/curate/done")
def done():
body = request.get_json()
state = load_state(body["album_id"], current_app)
if "curate" not in state.phases_completed:
state.phases_completed.append("curate")
state.phase = "group"
save_state(state, current_app)
return jsonify({"ok": True, "redirect": f"/group?album_id={body['album_id']}"})
@@ -1,229 +0,0 @@
import re
import shutil
from pathlib import Path
from flask import Blueprint, current_app, jsonify, render_template, request
from app.immich import ImmichClient
from app.state import load_state, save_state
bp = Blueprint("export", __name__)
def slugify(text: str) -> str:
text = text.lower().strip()
text = re.sub(r"[^\w\s-]", "", text)
return re.sub(r"[\s_-]+", "-", text).strip("-")
def _yaml_str(s: str) -> str:
return s.replace("'", "''")
def _client():
return ImmichClient(
current_app.config["IMMICH_URL"],
current_app.config["IMMICH_API_KEY"],
)
@bp.get("/export")
def export_view():
album_id = request.args["album_id"]
state = load_state(album_id, current_app)
to_export = [g for g in state.groups if g.status == "written"]
skipped = [g for g in state.groups if g.status == "skipped"]
return render_template(
"phase6.html",
state=state,
to_export=to_export,
skipped=skipped,
current_phase="export",
album_id=album_id,
phase_stale=state.phase_stale,
notes_content=state.notes,
)
@bp.post("/export/run")
def run_export():
body = request.get_json()
album_id = body["album_id"]
state = load_state(album_id, current_app)
pages_dir = Path(current_app.config["PAGES_DIR"])
client = _client()
photo_map = {p.id: p for p in state.photos}
exported = 0
all_failed = []
for group in state.groups:
if group.status != "written":
continue
title_slug = slugify(group.title or group.date or "entry")
if group.entry_type == "journal":
folder_name = f"{group.date}-{title_slug}.entry"
dest = pages_dir / "01.trips" / state.grav_trip_slug / "01.dailies" / folder_name
md_file = "entry.md"
template = "entry"
else:
folder_name = f"{title_slug}.story"
dest = pages_dir / "01.trips" / state.grav_trip_slug / "04.stories" / folder_name
md_file = "story.md"
template = "story"
if dest.exists():
save_state(state, current_app)
return jsonify({"conflict": True, "path": str(dest)})
dest.mkdir(parents=True, exist_ok=True)
# Download photos
failed = []
hero_filename = None
photo_num = 1
for pid in group.photo_ids:
photo = photo_map.get(pid)
if not photo:
continue
filename = f"photo-{photo_num}.jpg"
try:
data = client.get_original(pid)
(dest / filename).write_bytes(data)
if pid == group.hero_photo_id or photo_num == 1:
hero_filename = filename
photo_num += 1
except Exception as e:
current_app.logger.warning("Failed to download asset %s: %s", pid, e)
failed.append(pid)
# Build frontmatter
date_str = (group.date + " 12:00") if group.date else ""
if group.entry_type == "journal":
frontmatter = (
f"---\n"
f"title: '{_yaml_str(group.title)}'\n"
f"date: '{date_str}'\n"
f"template: {template}\n"
f"published: true\n"
f"location_city: '{_yaml_str(group.location_city)}'\n"
f"location_country: '{_yaml_str(group.location_country)}'\n"
f"hero_image: {hero_filename or ''}\n"
f"---\n"
)
else:
frontmatter = (
f"---\n"
f"title: '{_yaml_str(group.title)}'\n"
f"date: '{date_str}'\n"
f"template: {template}\n"
f"published: true\n"
f"hero_image: {hero_filename or ''}\n"
f"---\n"
)
body_text = group.body or ""
if group.shortcode_hints:
body_text += f"\n<!-- shortcode hints:\n{group.shortcode_hints}\n-->"
(dest / md_file).write_text(frontmatter + "\n" + body_text)
group.status = "exported"
exported += 1
all_failed.extend(failed)
save_state(state, current_app)
return jsonify({"ok": True, "exported": exported, "failed": all_failed})
@bp.post("/export/overwrite")
def overwrite_export():
body = request.get_json()
album_id = body["album_id"]
conflict_path = Path(body["path"])
state = load_state(album_id, current_app)
pages_dir = Path(current_app.config["PAGES_DIR"])
client = _client()
photo_map = {p.id: p for p in state.photos}
# Remove the conflicting folder so the run loop can proceed past it
if conflict_path.exists():
shutil.rmtree(conflict_path)
exported = 0
all_failed = []
for group in state.groups:
if group.status != "written":
continue
title_slug = slugify(group.title or group.date or "entry")
if group.entry_type == "journal":
folder_name = f"{group.date}-{title_slug}.entry"
dest = pages_dir / "01.trips" / state.grav_trip_slug / "01.dailies" / folder_name
md_file = "entry.md"
template = "entry"
else:
folder_name = f"{title_slug}.story"
dest = pages_dir / "01.trips" / state.grav_trip_slug / "04.stories" / folder_name
md_file = "story.md"
template = "story"
if dest.exists():
save_state(state, current_app)
return jsonify({"conflict": True, "path": str(dest)})
dest.mkdir(parents=True, exist_ok=True)
failed = []
hero_filename = None
photo_num = 1
for pid in group.photo_ids:
photo = photo_map.get(pid)
if not photo:
continue
filename = f"photo-{photo_num}.jpg"
try:
data = client.get_original(pid)
(dest / filename).write_bytes(data)
if pid == group.hero_photo_id or photo_num == 1:
hero_filename = filename
photo_num += 1
except Exception as e:
current_app.logger.warning("Failed to download asset %s: %s", pid, e)
failed.append(pid)
date_str = (group.date + " 12:00") if group.date else ""
if group.entry_type == "journal":
frontmatter = (
f"---\n"
f"title: '{_yaml_str(group.title)}'\n"
f"date: '{date_str}'\n"
f"template: {template}\n"
f"published: true\n"
f"location_city: '{_yaml_str(group.location_city)}'\n"
f"location_country: '{_yaml_str(group.location_country)}'\n"
f"hero_image: {hero_filename or ''}\n"
f"---\n"
)
else:
frontmatter = (
f"---\n"
f"title: '{_yaml_str(group.title)}'\n"
f"date: '{date_str}'\n"
f"template: {template}\n"
f"published: true\n"
f"hero_image: {hero_filename or ''}\n"
f"---\n"
)
body_text = group.body or ""
if group.shortcode_hints:
body_text += f"\n<!-- shortcode hints:\n{group.shortcode_hints}\n-->"
(dest / md_file).write_text(frontmatter + "\n" + body_text)
group.status = "exported"
exported += 1
all_failed.extend(failed)
save_state(state, current_app)
return jsonify({"ok": True, "exported": exported, "failed": all_failed})
@@ -1,116 +0,0 @@
import uuid
from flask import Blueprint, current_app, jsonify, redirect, render_template, request
from app.state import Group, load_state, save_state
bp = Blueprint("group", __name__)
def _build_groups(state):
"""Compute display groups from kept photos + dividers."""
kept = sorted(
[p for p in state.photos if p.tag in ("journal", "story")],
key=lambda p: p.order,
)
divider_orders = sorted(d["after_order"] for d in state.dividers)
divider_ids = {d["after_order"]: d["id"] for d in state.dividers}
groups = []
current_group = []
for photo in kept:
current_group.append(photo)
if photo.order in divider_orders:
div_id = divider_ids[photo.order]
groups.append({
"photos": current_group,
"divider_id": div_id,
"label": state.group_labels.get(div_id, ""),
})
current_group = []
if current_group:
groups.append({"photos": current_group, "divider_id": None, "label": ""})
return groups, kept
@bp.get("/group")
def group():
album_id = request.args["album_id"]
state = load_state(album_id, current_app)
groups, kept = _build_groups(state)
return render_template(
"phase4.html",
state=state,
groups=groups,
kept=kept,
current_phase="group",
album_id=album_id,
phase_stale=state.phase_stale,
notes_content=state.notes,
)
@bp.post("/group/divider")
def add_divider():
body = request.get_json()
state = load_state(body["album_id"], current_app)
after_order = int(body["after_order"])
if not any(d["after_order"] == after_order for d in state.dividers):
state.dividers.append({"id": str(uuid.uuid4()), "after_order": after_order})
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/group/remove-divider")
def remove_divider():
body = request.get_json()
state = load_state(body["album_id"], current_app)
state.dividers = [d for d in state.dividers if d["id"] != body["divider_id"]]
state.group_labels.pop(body["divider_id"], None)
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/group/label")
def set_label():
body = request.get_json()
state = load_state(body["album_id"], current_app)
state.group_labels[body["divider_id"]] = body["label"]
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/group/done")
def done():
body = request.get_json()
state = load_state(body["album_id"], current_app)
groups, _ = _build_groups(state)
state.groups = []
for g in groups:
first_photo = g["photos"][0]
state.groups.append(Group(
id=str(uuid.uuid4()),
photo_ids=[p.id for p in g["photos"]],
entry_type=first_photo.tag,
date=first_photo.local_datetime[:10],
label=g["label"],
))
if "group" not in state.phases_completed:
state.phases_completed.append("group")
state.phase = "write"
save_state(state, current_app)
return jsonify({"ok": True, "redirect": f"/write?album_id={body['album_id']}"})
@bp.post("/group/from-note")
def from_note():
body = request.get_json()
state = load_state(body["album_id"], current_app)
state.groups.append(Group(
id=str(uuid.uuid4()),
photo_ids=[],
entry_type="journal",
body=body.get("text", ""),
))
if "write" in state.phases_completed and "write" not in state.phase_stale:
state.phase_stale.append("write")
save_state(state, current_app)
return jsonify({"ok": True})
@@ -1,51 +0,0 @@
from flask import Blueprint, current_app, jsonify, redirect, request
from app.state import load_state, save_state
bp = Blueprint("nav", __name__)
STALE_DOWNSTREAM = {
"triage": ["curate", "group", "write"],
"curate": ["group", "write"],
"group": ["write"],
"write": [],
"export": [],
}
@bp.post("/nav/phase")
def goto_phase():
body = request.get_json()
target = body["target_phase"]
state = load_state(body["album_id"], current_app)
if state is None:
return jsonify({"error": "no state"}), 404
# Mark downstream completed phases and the current phase as stale
downstream = STALE_DOWNSTREAM.get(target, [])
candidates = set(downstream) & (set(state.phases_completed) | {state.phase})
newly_stale = [p for p in candidates if p not in state.phase_stale]
state.phase_stale = list(set(state.phase_stale + newly_stale))
state.phase = target
save_state(state, current_app)
return jsonify({"ok": True, "phase": target})
@bp.post("/nav/dismiss-stale")
def dismiss_stale():
album_id = request.form["album_id"]
phase = request.form["phase"]
state = load_state(album_id, current_app)
if state:
state.phase_stale = [p for p in state.phase_stale if p != phase]
save_state(state, current_app)
return redirect(f"/{phase}?album_id={album_id}")
@bp.get("/state/<album_id>")
def get_state(album_id):
"""Debug/test endpoint — returns full state JSON."""
state = load_state(album_id, current_app)
if state is None:
return jsonify({"error": "no state"}), 404
from dataclasses import asdict
return jsonify(asdict(state))
@@ -1,23 +0,0 @@
from flask import Blueprint, current_app, jsonify, request
from app.state import load_state, save_state
bp = Blueprint("notes", __name__)
@bp.post("/notes/save")
def save_notes():
body = request.get_json()
state = load_state(body["album_id"], current_app)
if state is None:
return jsonify({"error": "no state"}), 404
state.notes = body["notes"]
save_state(state, current_app)
return jsonify({"ok": True})
@bp.get("/notes/<album_id>")
def get_notes(album_id):
state = load_state(album_id, current_app)
if state is None:
return jsonify({"error": "no state"}), 404
return jsonify({"notes": state.notes})
@@ -1,29 +0,0 @@
from flask import Blueprint, current_app, Response, abort
from app.immich import ImmichClient
bp = Blueprint("proxy", __name__)
def _client() -> ImmichClient:
return ImmichClient(
base_url=current_app.config["IMMICH_URL"],
api_key=current_app.config["IMMICH_API_KEY"],
)
@bp.get("/proxy/thumb/<asset_id>")
def thumb(asset_id):
try:
data = _client().get_thumbnail(asset_id)
except ConnectionError:
abort(502)
return Response(data, content_type="image/jpeg")
@bp.get("/proxy/original/<asset_id>")
def original(asset_id):
try:
data = _client().get_original(asset_id)
except ConnectionError:
abort(502)
return Response(data, content_type="image/jpeg")
@@ -1,51 +0,0 @@
from flask import Blueprint, current_app, jsonify, redirect, render_template, request
from app.state import load_state, save_state
bp = Blueprint("triage", __name__)
@bp.get("/triage")
def triage():
album_id = request.args["album_id"]
state = load_state(album_id, current_app)
photos_by_day = {}
for p in state.photos:
day = p.local_datetime[:10]
photos_by_day.setdefault(day, []).append(p)
all_tagged = all(p.tag != "untagged" for p in state.photos)
return render_template(
"phase2.html",
state=state,
photos_by_day=photos_by_day,
all_tagged=all_tagged,
current_phase="triage",
album_id=album_id,
phase_stale=state.phase_stale,
notes_content=state.notes,
)
@bp.post("/triage/tag")
def tag():
body = request.get_json()
state = load_state(body["album_id"], current_app)
for p in state.photos:
if p.id == body["asset_id"]:
p.tag = body["tag"]
break
save_state(state, current_app)
tagged_count = sum(1 for p in state.photos if p.tag != "untagged")
return jsonify({"ok": True, "tagged_count": tagged_count, "total": len(state.photos)})
@bp.post("/triage/done")
def done():
body = request.get_json()
state = load_state(body["album_id"], current_app)
if not all(p.tag != "untagged" for p in state.photos):
return jsonify({"error": "not all tagged"}), 400
if "triage" not in state.phases_completed:
state.phases_completed.append("triage")
state.phase = "curate"
save_state(state, current_app)
return jsonify({"ok": True, "redirect": f"/curate?album_id={body['album_id']}"})
@@ -1,103 +0,0 @@
from flask import Blueprint, current_app, jsonify, redirect, render_template, request, url_for
from app.state import load_state, save_state
bp = Blueprint("write", __name__)
@bp.get("/write")
def write():
album_id = request.args["album_id"]
group_idx = int(request.args.get("group_idx", 0))
state = load_state(album_id, current_app)
active_groups = [g for g in state.groups if g.status != "exported"]
total = len(active_groups)
group = active_groups[group_idx] if group_idx < total else None
done_count = sum(1 for g in active_groups if g.status in ("written", "skipped"))
if group is None:
all_done = all(g.status in ("written", "skipped", "exported") for g in active_groups)
if not all_done:
first_incomplete = next(i for i, g in enumerate(active_groups) if g.status == "draft")
return redirect(url_for("write.write", album_id=album_id, group_idx=first_incomplete))
photos = []
if group:
by_id = {p.id: p for p in state.photos}
photos = [by_id[pid] for pid in group.photo_ids if pid in by_id]
return render_template(
"phase5.html",
state=state,
group=group,
photos=photos,
group_idx=group_idx,
total=total,
done_count=done_count,
current_phase="write",
album_id=album_id,
phase_stale=state.phase_stale,
notes_content=state.notes,
)
@bp.post("/write/autosave")
def autosave():
body = request.get_json()
state = load_state(body["album_id"], current_app)
for g in state.groups:
if g.id == body["group_id"] and g.status != "exported":
g.title = body.get("title", g.title)
g.body = body.get("body", g.body)
g.location_city = body.get("location_city", g.location_city)
g.location_country = body.get("location_country", g.location_country)
g.date = body.get("date", g.date)
g.hero_photo_id = body.get("hero_photo_id", g.hero_photo_id)
g.shortcode_hints = body.get("shortcode_hints", g.shortcode_hints)
if body.get("entry_type"):
g.entry_type = body["entry_type"]
break
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/write/save")
def save():
body = request.get_json()
state = load_state(body["album_id"], current_app)
for g in state.groups:
if g.id == body["group_id"] and g.status != "exported":
g.title = body.get("title", g.title)
g.body = body.get("body", g.body)
g.location_city = body.get("location_city", g.location_city)
g.location_country = body.get("location_country", g.location_country)
g.date = body.get("date", g.date)
g.hero_photo_id = body.get("hero_photo_id", g.hero_photo_id)
g.shortcode_hints = body.get("shortcode_hints", g.shortcode_hints)
if body.get("entry_type"):
g.entry_type = body["entry_type"]
g.status = "written"
break
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/write/skip")
def skip():
body = request.get_json()
state = load_state(body["album_id"], current_app)
for g in state.groups:
if g.id == body["group_id"] and g.status != "exported":
g.status = "skipped"
break
save_state(state, current_app)
return jsonify({"ok": True})
@bp.post("/write/done")
def write_done():
album_id = request.form["album_id"]
state = load_state(album_id, current_app)
if state is None:
return jsonify({"ok": False, "error": "not found"}), 404
if "write" not in state.phases_completed:
state.phases_completed.append("write")
state.phase = "export"
save_state(state, current_app)
return redirect(f"/export?album_id={album_id}")
-71
View File
@@ -1,71 +0,0 @@
import json
import os
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Optional
from flask import current_app
@dataclass
class Photo:
id: str
original_filename: str
local_datetime: str
tag: str = "untagged" # untagged | journal | story | skip
order: int = 0
@dataclass
class Group:
id: str
photo_ids: list = field(default_factory=list)
entry_type: str = "journal" # journal | story
label: str = ""
title: str = ""
body: str = ""
location_city: str = ""
location_country: str = ""
date: str = ""
hero_photo_id: Optional[str] = None
shortcode_hints: str = ""
status: str = "draft" # draft | written | skipped | exported
@dataclass
class TripState:
album_id: str
album_name: str
grav_trip_slug: str
phase: str = "triage"
phases_completed: list = field(default_factory=list)
phase_stale: list = field(default_factory=list)
photos: list = field(default_factory=list)
groups: list = field(default_factory=list)
notes: str = ""
dividers: list = field(default_factory=list) # [{"id": str, "after_order": int}]
group_labels: dict = field(default_factory=dict) # {divider_id: label}
def _state_path(album_id: str, app) -> Path:
return Path(app.config["STATE_DIR"]) / f"{album_id}.json"
def load_state(album_id: str, app) -> Optional[TripState]:
path = _state_path(album_id, app)
if not path.exists():
return None
with open(path) as f:
data = json.load(f)
photos = [Photo(**p) for p in data.pop("photos", [])]
groups = [Group(**g) for g in data.pop("groups", [])]
return TripState(photos=photos, groups=groups, **data)
def save_state(state: TripState, app) -> None:
path = _state_path(state.album_id, app)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(asdict(state), f, indent=2)
os.rename(tmp, path)
@@ -1,34 +0,0 @@
function notesApp(initialNotes, albumId) {
return {
open: false,
notes: initialNotes,
status: '',
saveTimer: null,
scheduleAutosave() {
clearTimeout(this.saveTimer);
this.status = 'Saving…';
this.saveTimer = setTimeout(() => this.doSave(), 500);
},
async doSave() {
const res = await fetch('/notes/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, notes: this.notes }),
});
this.status = res.ok ? 'Saved ✓' : 'Error';
},
async convertToEntry(text) {
const res = await fetch('/group/from-note', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, text }),
});
if (res.ok) {
this.status = 'Added as entry ✓';
}
},
};
}
@@ -1,68 +0,0 @@
<!DOCTYPE html>
<html data-theme="forest" lang="en">
<head>
<meta charset="UTF-8">
<title>travel-memories</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
</head>
<body class="min-h-screen bg-base-200" x-data="notesApp({{ notes_content | tojson }}, '{{ album_id }}')">
<!-- Navbar -->
<div class="navbar bg-base-100 shadow-sm sticky top-0 z-40">
<div class="navbar-start px-4 font-bold text-lg">travel-memories</div>
<div class="navbar-center">
<ul class="steps">
{% set phases = [('','Album'),('triage','Triage'),('curate','Curate'),('group','Group'),('write','Write'),('export','Export')] %}
{% for key, label in phases %}
<li class="step {% if current_phase == key %}step-primary{% endif %}
{% if key in phase_stale %}step-warning{% endif %}">
{% if album_id %}
<a hx-post="/nav/phase" hx-vals='{"album_id":"{{ album_id }}","target_phase":"{{ key }}"}' href="/{{ key }}{% if album_id %}?album_id={{ album_id }}{% endif %}">{{ label }}</a>
{% else %}{{ label }}{% endif %}
</li>
{% endfor %}
</ul>
</div>
<div class="navbar-end px-4">
{% if album_id %}
<button class="btn btn-ghost btn-sm" @click="open = !open">📝 Notes</button>
{% endif %}
</div>
</div>
<!-- Stale warning -->
{% if current_phase in phase_stale %}
<div class="alert alert-warning rounded-none" id="stale-banner">
<span>You changed earlier decisions — review this phase before exporting.</span>
<form method="post" action="/nav/dismiss-stale">
<input type="hidden" name="album_id" value="{{ album_id }}">
<input type="hidden" name="phase" value="{{ current_phase }}">
<button class="btn btn-xs">Dismiss</button>
</form>
</div>
{% endif %}
<!-- Body with notes drawer -->
<div class="flex relative">
<div class="flex-1 min-w-0 transition-all" :class="open ? 'mr-80' : ''">
{% block content %}{% endblock %}
</div>
<!-- Notes panel -->
<div class="fixed right-0 top-16 h-[calc(100vh-4rem)] w-80 bg-base-100 shadow-2xl p-4 flex flex-col transition-transform z-30"
:class="open ? 'translate-x-0' : 'translate-x-full'" id="notes-panel">
<h3 class="font-bold text-base mb-2">Notes</h3>
<textarea class="textarea textarea-bordered flex-1 resize-none text-sm"
x-model="notes"
@input="scheduleAutosave()"
placeholder="Jot down memories at any time…"></textarea>
<div class="text-xs text-right mt-1 opacity-60" x-text="status"></div>
</div>
</div>
<script src="/static/app.js"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>
@@ -1,55 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-4">Select Album</h1>
{% if error %}
<div class="alert alert-error mb-4">
<span>Cannot reach Immich: {{ error }}</span>
<a href="/" class="btn btn-sm">Retry</a>
</div>
{% endif %}
<form method="post" action="/select">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{% for album in albums %}
<label class="album-card card bg-base-100 shadow cursor-pointer hover:shadow-lg transition"
data-album-id="{{ album.id }}">
<figure class="h-40 overflow-hidden">
<img src="/proxy/thumb/{{ album.albumThumbnailAssetId }}"
class="w-full h-full object-cover" alt="">
</figure>
<div class="card-body p-4">
<div class="flex items-start gap-2">
<input type="checkbox" name="album_ids[]" value="{{ album.id }}"
class="checkbox checkbox-primary mt-1">
<div>
<p class="font-semibold">{{ album.albumName }}</p>
<p class="text-sm opacity-60">{{ album.assetCount }} photos</p>
{% if album.has_state %}
<span class="resume-badge badge badge-warning badge-sm mt-1">In progress</span>
{% endif %}
</div>
</div>
</div>
</label>
{% endfor %}
</div>
<div class="form-control mb-4 max-w-xs">
<label class="label"><span class="label-text">Grav trip slug</span></label>
<input id="grav-slug" type="text" name="grav_trip_slug" required
placeholder="central-asia-2023" class="input input-bordered">
</div>
<input type="hidden" name="start_over" id="start-over-flag" value="0">
<div class="flex gap-2">
<button type="submit" class="btn btn-primary">Start &rarr;</button>
<button type="button" class="btn btn-ghost btn-sm"
onclick="document.getElementById('start-over-flag').value='1'; this.closest('form').submit()">
Start over (discard progress)
</button>
</div>
</form>
</div>
{% endblock %}
@@ -1,628 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="p-4 max-w-6xl mx-auto" x-data="triageApp('{{ album_id }}')"
@keydown.j.window="tagFocused('journal')"
@keydown.s.window="tagFocused('story')"
@keydown.x.window="tagFocused('skip')"
@keydown.space.prevent.window="tagFocused('skip')"
@keydown.left.prevent.window="navigate(-1)"
@keydown.right.prevent.window="navigate(1)"
@keydown.escape.window="closeLightbox()"
@keydown.enter.window="focused && openLightbox(focused)">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Triage</h1>
<div class="flex items-center gap-3">
<span class="text-sm opacity-60" id="tagged-count">
{{ state.photos | selectattr('tag', 'ne', 'untagged') | list | length }}
/ {{ state.photos | length }} tagged
</span>
<button class="btn btn-ghost btn-sm" @click="skipUntagged()">
Skip untagged
</button>
<button id="done-btn"
class="btn btn-primary btn-sm"
{% if not all_tagged %}disabled{% endif %}
@click="done()">
Done triaging &rarr;
</button>
</div>
</div>
{# ── Desktop grid (hidden on mobile) ── #}
<div id="desktop-view">
{% for day, photos in photos_by_day.items() %}
<div class="day-group mb-6">
<h2 class="sticky top-16 z-20 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mt-2">
{% for photo in photos %}
<div class="photo-card relative cursor-pointer rounded-lg overflow-hidden border-4
{% if photo.tag == 'journal' %}border-amber-500
{% elif photo.tag == 'story' %}border-sky-400
{% elif photo.tag == 'skip' %}border-base-300 opacity-40
{% else %}border-transparent{% endif %}"
data-asset-id="{{ photo.id }}"
data-tag="{{ photo.tag }}"
tabindex="0"
@click="openLightbox($el)"
@focus="select($el)">
<img src="/proxy/thumb/{{ photo.id }}"
class="w-full aspect-square object-cover" loading="lazy" alt="">
<div class="absolute bottom-0 left-0 right-0 text-[10px] text-white bg-black/40 px-1">
{{ photo.local_datetime[11:16] }}
</div>
{% if photo.tag == 'journal' %}
<div class="absolute top-1 right-1 badge badge-xs bg-amber-500 text-black border-0 font-bold">J</div>
{% elif photo.tag == 'story' %}
<div class="absolute top-1 right-1 badge badge-xs bg-sky-400 text-black border-0 font-bold">S</div>
{% elif photo.tag == 'skip' %}
<div class="absolute top-1 right-1 badge badge-xs badge-ghost opacity-60">X</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{# ── Lightbox overlay (desktop) ── #}
<div id="lb" class="fixed inset-0 z-50 bg-black/95 flex items-center justify-center" style="display:none">
<button class="absolute top-4 right-4 btn btn-circle btn-sm btn-ghost text-white opacity-60 hover:opacity-100 text-lg"
@click="closeLightbox()">&#10005;</button>
<button class="absolute left-3 top-1/2 -translate-y-1/2 btn btn-circle btn-ghost text-white text-4xl opacity-60 hover:opacity-100"
@click="navigate(-1)">&#8249;</button>
<button class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-circle btn-ghost text-white text-4xl opacity-60 hover:opacity-100"
@click="navigate(1)">&#8250;</button>
<div class="flex flex-col items-center gap-3 px-16 max-w-full">
<img id="lb-img" src="" class="max-h-[82vh] max-w-[88vw] object-contain rounded-lg shadow-2xl" alt="">
<div class="flex items-center gap-4 text-white/60 text-sm">
<span id="lb-date"></span>
<span id="lb-filename" class="opacity-40"></span>
<span id="lb-tag-badge" class="badge badge-sm"></span>
<span class="opacity-30 text-xs">J journal · S story · X skip · ← → navigate · Esc close</span>
</div>
</div>
</div>
{# ── Mobile card UI (hidden on desktop) ── #}
<div id="mobile-view" style="display:none">
{# Progress bar #}
<div class="mb-3">
<div class="flex justify-between text-xs opacity-60 mb-1">
<span id="m-progress-text">0 / {{ state.photos | length }} tagged</span>
<span id="m-undo-btn-wrap" style="display:none">
<button id="m-undo-btn" class="btn btn-ghost btn-xs">&#8592; Back</button>
</span>
</div>
<div class="w-full bg-base-300 rounded-full h-1.5">
<div id="m-progress-bar" class="bg-primary h-1.5 rounded-full transition-all" style="width:0%"></div>
</div>
</div>
{# Card stack #}
<div id="m-card-area" class="relative w-full" style="height:70vh">
{# Card is injected by JS #}
<div id="m-completion" style="display:none"
class="flex flex-col items-center justify-center h-full gap-4 text-center">
<div class="text-4xl">&#10003;</div>
<p class="text-lg font-semibold">All tagged!</p>
<button class="btn btn-primary" onclick="document.getElementById('done-btn').click()">
Done triaging &rarr;
</button>
</div>
</div>
{# Action buttons #}
<div id="m-buttons" class="flex justify-center gap-6 mt-4">
<button id="m-btn-skip"
class="btn btn-circle btn-lg btn-ghost border-2 border-base-300 text-2xl"
onclick="mobileApp && mobileApp.doTag('skip')">&#10005;</button>
<button id="m-btn-journal"
class="btn btn-circle btn-lg btn-ghost border-2 border-success text-2xl"
onclick="mobileApp && mobileApp.doTag('journal')">J</button>
<button id="m-btn-story"
class="btn btn-circle btn-lg btn-ghost border-2 border-info text-2xl"
onclick="mobileApp && mobileApp.doTag('story')">S</button>
</div>
{# Thumbnail strip — all photos, colored dot per tag, tap to jump #}
<div id="m-thumb-strip"
class="mt-3 flex gap-1.5 overflow-x-auto pb-2"
style="scrollbar-width:thin;-webkit-overflow-scrolling:touch"></div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
<script>
// ── Shared badge helper ──────────────────────────────────────────────────────
function updateBadge(cardEl, tag) {
let badge = cardEl.querySelector('.badge');
if (!badge) {
badge = document.createElement('div');
cardEl.appendChild(badge);
}
const MAP = {
journal: ['badge-xs bg-amber-500 text-black border-0 font-bold', 'J'],
story: ['badge-xs bg-sky-400 text-black border-0 font-bold', 'S'],
skip: ['badge-xs badge-ghost opacity-60', 'X'],
};
if (MAP[tag]) {
badge.className = `absolute top-1 right-1 badge ${MAP[tag][0]}`;
badge.textContent = MAP[tag][1];
} else {
badge.remove();
}
}
// ── Desktop Alpine app ───────────────────────────────────────────────────────
function triageApp(albumId) {
return {
focused: null,
lightboxOpen: false,
init() {
const first = document.querySelector('.photo-card');
if (first) this.select(first);
},
select(el) {
if (this.focused) this.focused.classList.remove('ring-4', 'ring-white', 'ring-offset-2', 'z-10');
this.focused = el;
if (el) {
el.classList.add('ring-4', 'ring-white', 'ring-offset-2', 'z-10');
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
if (this.lightboxOpen) this.updateLightbox();
},
openLightbox(el) {
this.select(el);
this.lightboxOpen = true;
document.getElementById('lb').style.display = '';
this.updateLightbox();
},
closeLightbox() {
if (!this.lightboxOpen) return;
this.lightboxOpen = false;
document.getElementById('lb').style.display = 'none';
},
updateLightbox() {
const el = this.focused;
if (!el) return;
const assetId = el.dataset.assetId;
const tag = el.dataset.tag;
document.getElementById('lb-img').src = `/proxy/thumb/${assetId}`;
const timeEl = el.querySelector('div');
document.getElementById('lb-date').textContent = timeEl ? timeEl.textContent.trim() : '';
document.getElementById('lb-filename').textContent = el.dataset.filename || '';
const badgeEl = document.getElementById('lb-tag-badge');
const MAP = {
journal: ['bg-amber-500 text-black border-0 font-bold', 'Journal'],
story: ['bg-sky-400 text-black border-0 font-bold', 'Story'],
skip: ['badge-ghost opacity-60', 'Skip'],
};
if (MAP[tag]) {
badgeEl.className = `badge badge-sm ${MAP[tag][0]}`;
badgeEl.textContent = MAP[tag][1];
} else {
badgeEl.className = 'badge badge-sm badge-outline opacity-30';
badgeEl.textContent = 'Untagged';
}
},
navigate(dir) {
const cards = [...document.querySelectorAll('.photo-card')];
if (!cards.length) return;
const idx = this.focused ? cards.indexOf(this.focused) : -1;
const next = cards[Math.max(0, Math.min(cards.length - 1, idx + dir))];
if (next) this.select(next);
},
async tagFocused(tag) {
const el = this.focused || document.querySelector('.photo-card');
if (!el) return;
const assetId = el.dataset.assetId;
await fetch('/triage/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, asset_id: assetId, tag }),
});
el.dataset.tag = tag;
// Remove any existing border/opacity classes before adding new ones
el.className = el.className
.split(/\s+/)
.filter(c => c && !c.startsWith('border-') && c !== 'opacity-40')
.join(' ');
if (tag === 'journal') {
el.classList.add('border-4', 'border-amber-500');
} else if (tag === 'story') {
el.classList.add('border-4', 'border-sky-400');
} else {
el.classList.add('border-4', 'border-base-300', 'opacity-40');
}
updateBadge(el, tag);
this.updateCount();
if (this.lightboxOpen) this.updateLightbox();
},
updateCount() {
const total = document.querySelectorAll('.photo-card').length;
const tagged = document.querySelectorAll('.photo-card:not([data-tag="untagged"])').length;
document.getElementById('tagged-count').textContent = `${tagged} / ${total} tagged`;
document.getElementById('done-btn').disabled = tagged < total;
},
async skipUntagged() {
await fetch('/triage/skip-untagged', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId }),
});
document.querySelectorAll('.photo-card[data-tag="untagged"]').forEach(el => {
el.dataset.tag = 'skip';
el.className = el.className
.split(' ')
.filter(c => !c.startsWith('border-') && c !== 'opacity-40')
.join(' ');
el.classList.add('border-4', 'border-base-300', 'opacity-40');
updateBadge(el, 'skip');
});
this.updateCount();
},
async done() {
const res = await fetch('/triage/done', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId }),
});
const data = await res.json();
if (data.redirect) window.location = data.redirect;
},
};
}
// ── Mobile swipe triage app ──────────────────────────────────────────────────
let mobileApp = null;
function mobileTriageApp(albumId, photos) {
// Build queue: only untagged photos, in their original order
let queue = photos
.filter(p => p.tag === 'untagged')
.slice(); // shallow copy
const total = photos.length;
let taggedCount = photos.filter(p => p.tag !== 'untagged').length;
// Undo stack: [{asset_id, previous_tag}, ...] (max 10)
const undoStack = [];
// DOM refs
const cardArea = document.getElementById('m-card-area');
const completion = document.getElementById('m-completion');
const progressBar = document.getElementById('m-progress-bar');
const progressText = document.getElementById('m-progress-text');
const undoBtnWrap = document.getElementById('m-undo-btn-wrap');
const undoBtn = document.getElementById('m-undo-btn');
undoBtn.addEventListener('click', undo);
// ── helpers ─────────────────────────────────────────────────────────────
function updateProgress() {
progressText.textContent = `${taggedCount} / ${total} tagged`;
progressBar.style.width = total > 0 ? `${(taggedCount / total) * 100}%` : '0%';
undoBtnWrap.style.display = undoStack.length > 0 ? '' : 'none';
// Sync the shared header counter / done button
document.getElementById('tagged-count').textContent = `${taggedCount} / ${total} tagged`;
document.getElementById('done-btn').disabled = taggedCount < total;
}
function showCompletion() {
completion.style.display = '';
document.getElementById('m-buttons').style.display = 'none';
}
function makeCard(photo) {
const card = document.createElement('div');
card.id = 'm-card';
card.style.cssText = `
position: absolute; inset: 0;
border-radius: 16px; overflow: hidden;
background: #000;
touch-action: none;
user-select: none;
will-change: transform;
cursor: grab;
`;
const img = document.createElement('img');
img.src = `/proxy/thumb/${photo.id}`;
img.style.cssText = 'width:100%; height:100%; object-fit:cover; display:block;';
img.draggable = false;
card.appendChild(img);
// Date overlay
const dateOverlay = document.createElement('div');
dateOverlay.style.cssText = `
position: absolute; bottom: 0; left: 0; right: 0;
padding: 12px 16px;
background: linear-gradient(transparent, rgba(0,0,0,0.6));
color: #fff; font-size: 14px;
`;
dateOverlay.textContent = photo.local_datetime
? photo.local_datetime.slice(0, 16).replace('T', ' ')
: '';
card.appendChild(dateOverlay);
// Colour overlay (shown during drag)
const colorOverlay = document.createElement('div');
colorOverlay.id = 'm-color-overlay';
colorOverlay.style.cssText = `
position: absolute; inset: 0;
opacity: 0;
transition: opacity 0.1s;
pointer-events: none;
border-radius: 16px;
`;
card.appendChild(colorOverlay);
return { card, colorOverlay };
}
function showCard() {
// Remove existing card if any
const old = document.getElementById('m-card');
if (old) old.remove();
if (queue.length === 0) {
showCompletion();
updateProgress();
updateThumbStrip();
return;
}
completion.style.display = 'none';
document.getElementById('m-buttons').style.display = '';
const photo = queue[0];
const { card, colorOverlay } = makeCard(photo);
cardArea.appendChild(card);
// ── HammerJS gestures ─────────────────────────────────────────────
const hammer = new Hammer(card, { recognizers: [[Hammer.Pan, { direction: Hammer.DIRECTION_ALL, threshold: 5 }]] });
// Also enable swipe (velocity-based)
hammer.get('pan').set({ direction: Hammer.DIRECTION_ALL });
let startX = 0, startY = 0;
hammer.on('panstart', () => {
card.style.transition = 'none';
});
hammer.on('panmove', (ev) => {
const dx = ev.deltaX;
const dy = ev.deltaY;
const tilt = dx * 0.08; // degrees of rotation
card.style.transform = `translate(${dx}px, ${dy}px) rotate(${tilt}deg)`;
// Determine dominant direction for colour overlay
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDy > absDx && dy < -30) {
// swipe up → story (blue)
colorOverlay.style.background = 'rgba(56,189,248,0.35)';
colorOverlay.style.opacity = Math.min(absDy / 150, 0.8);
} else if (dx > 30) {
// swipe right → journal (green)
colorOverlay.style.background = 'rgba(74,222,128,0.35)';
colorOverlay.style.opacity = Math.min(absDx / 150, 0.8);
} else if (dx < -30) {
// swipe left → skip (grey)
colorOverlay.style.background = 'rgba(100,116,139,0.35)';
colorOverlay.style.opacity = Math.min(absDx / 150, 0.8);
} else {
colorOverlay.style.opacity = 0;
}
});
hammer.on('panend', (ev) => {
const dx = ev.deltaX;
const dy = ev.deltaY;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
const THRESHOLD = 50;
card.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
if (absDy > absDx && dy < -THRESHOLD) {
// Swipe up → story
flyOut(card, 0, -window.innerHeight, () => doTag('story'));
} else if (dx > THRESHOLD) {
// Swipe right → journal
flyOut(card, window.innerWidth, 0, () => doTag('journal'));
} else if (dx < -THRESHOLD) {
// Swipe left → skip
flyOut(card, -window.innerWidth, 0, () => doTag('skip'));
} else {
// Snap back
card.style.transform = 'translate(0,0) rotate(0deg)';
colorOverlay.style.opacity = 0;
}
});
}
function flyOut(card, toX, toY, callback) {
card.style.transform = `translate(${toX}px, ${toY}px) rotate(${toX * 0.1}deg)`;
card.style.opacity = '0';
setTimeout(() => {
callback();
}, 300);
}
// ── Tag action ───────────────────────────────────────────────────────
async function doTag(tag) {
if (queue.length === 0) return;
const photo = queue.shift();
const previousTag = photo.tag;
// Push to undo stack (max 10)
undoStack.push({ photo, previousTag });
if (undoStack.length > 10) undoStack.shift();
// Update local photo tag
photo.tag = tag;
// Increment tagged count only if previously untagged
if (previousTag === 'untagged') taggedCount++;
await fetch('/triage/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, asset_id: photo.id, tag }),
});
updateProgress();
showCard();
updateThumbStrip();
}
// ── Undo ─────────────────────────────────────────────────────────────
async function undo() {
if (undoStack.length === 0) return;
const { photo, previousTag } = undoStack.pop();
// Re-insert at front of queue
queue.unshift(photo);
// Revert tagged count
if (previousTag === 'untagged' && photo.tag !== 'untagged') taggedCount--;
photo.tag = previousTag;
await fetch('/triage/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, asset_id: photo.id, tag: previousTag }),
});
updateProgress();
showCard();
updateThumbStrip();
}
// ── Thumbnail strip ──────────────────────────────────────────────────
const thumbStrip = document.getElementById('m-thumb-strip');
function buildThumbStrip() {
thumbStrip.innerHTML = '';
photos.forEach(photo => {
const wrap = document.createElement('div');
wrap.className = 'relative flex-none cursor-pointer';
wrap.style.cssText = 'width:44px;height:44px;';
wrap.dataset.thumbId = photo.id;
wrap.addEventListener('click', () => jumpToPhoto(photo));
const img = document.createElement('img');
img.src = `/proxy/thumb/${photo.id}`;
img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:4px;border:2px solid transparent;transition:border-color 0.15s;display:block;';
img.draggable = false;
wrap.appendChild(img);
const dot = document.createElement('div');
dot.style.cssText = 'position:absolute;bottom:2px;left:2px;width:7px;height:7px;border-radius:50%;display:none;border:1px solid rgba(0,0,0,0.3);';
wrap.appendChild(dot);
thumbStrip.appendChild(wrap);
});
updateThumbStrip();
}
function updateThumbStrip() {
const currentId = queue.length > 0 ? queue[0].id : null;
photos.forEach(photo => {
const wrap = thumbStrip.querySelector(`[data-thumb-id="${photo.id}"]`);
if (!wrap) return;
const img = wrap.querySelector('img');
const dot = wrap.querySelector('div');
img.style.borderColor = photo.id === currentId ? '#fff' : 'transparent';
img.style.boxShadow = photo.id === currentId ? '0 0 0 1px rgba(0,0,0,0.4)' : 'none';
if (photo.tag === 'journal') {
dot.style.display = '';
dot.style.background = '#f59e0b';
} else if (photo.tag === 'story') {
dot.style.display = '';
dot.style.background = '#38bdf8';
} else if (photo.tag === 'skip') {
dot.style.display = '';
dot.style.background = '#64748b';
} else {
dot.style.display = 'none';
}
});
if (currentId) {
const currentWrap = thumbStrip.querySelector(`[data-thumb-id="${currentId}"]`);
if (currentWrap) currentWrap.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
}
async function jumpToPhoto(photo) {
const queueIdx = queue.findIndex(p => p.id === photo.id);
if (queueIdx !== -1) queue.splice(queueIdx, 1);
if (photo.tag !== 'untagged') taggedCount--;
const prevTag = photo.tag;
photo.tag = 'untagged';
queue.unshift(photo);
await fetch('/triage/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, asset_id: photo.id, tag: 'untagged' }),
});
updateProgress();
updateThumbStrip();
showCard();
}
// ── Public API ───────────────────────────────────────────────────────
return { doTag, undo, showCard, buildThumbStrip, updateThumbStrip };
}
// ── View switching on load ───────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
if (window.innerWidth < 768) {
document.getElementById('desktop-view').style.display = 'none';
document.getElementById('mobile-view').style.display = '';
const albumId = '{{ album_id }}';
const photos = {{ state.photos | tojson }};
mobileApp = mobileTriageApp(albumId, photos);
// Seed initial progress
const taggedCount = photos.filter(p => p.tag !== 'untagged').length;
document.getElementById('m-progress-text').textContent = `${taggedCount} / ${photos.length} tagged`;
document.getElementById('m-progress-bar').style.width =
photos.length > 0 ? `${(taggedCount / photos.length) * 100}%` : '0%';
mobileApp.buildThumbStrip();
mobileApp.showCard();
}
// Desktop: nothing extra needed — Alpine handles it
});
</script>
{% endblock %}
@@ -1,91 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="p-4 max-w-6xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Curate</h1>
<button id="done-btn" class="btn btn-primary btn-sm" onclick="done()">
Curate done &rarr;
</button>
</div>
{% for day, photos in photos_by_day.items() %}
<div class="day-group mb-6">
<h2 class="sticky top-16 bg-base-200 py-1 text-sm font-semibold opacity-70">{{ day }}</h2>
<div class="flex flex-wrap gap-2 mt-2" id="day-{{ day }}">
{% for photo in photos %}
<div class="photo-card relative w-32 h-32 rounded-lg overflow-hidden border-4
{% if photo.tag == 'story' %}border-info{% else %}border-success{% endif %}"
data-asset-id="{{ photo.id }}">
<img src="/proxy/thumb/{{ photo.id }}" class="w-full h-full object-cover" alt="">
<div class="absolute top-1 left-1 flex gap-1">
<button class="retag-btn btn btn-xs btn-ghost bg-black/40 text-white"
onclick="retag('{{ album_id }}', '{{ photo.id }}', this.closest('.photo-card'))">
{% if photo.tag == 'journal' %}&rarr;S{% else %}&rarr;J{% endif %}
</button>
<button class="remove-btn btn btn-xs btn-ghost bg-black/40 text-white"
onclick="removeFn('{{ album_id }}', '{{ photo.id }}', this.closest('.photo-card'))">
&#x2715;
</button>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"></script>
<script>
document.querySelectorAll('[id^="day-"]').forEach(function(el) {
var albumId = new URLSearchParams(location.search).get('album_id');
Sortable.create(el, {
onEnd: function(e) {
reorder(albumId, e.to);
}
});
});
async function removeFn(albumId, assetId, el) {
await fetch('/curate/remove', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, asset_id: assetId})
});
el.remove();
}
async function retag(albumId, assetId, el) {
await fetch('/curate/swap', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, asset_id: assetId})
});
el.classList.toggle('border-info');
el.classList.toggle('border-success');
}
async function reorder(albumId, container) {
var ids = Array.from(container.querySelectorAll('.photo-card')).map(function(e) {
return e.dataset.assetId;
});
var day = container.id.replace('day-', '');
await fetch('/curate/reorder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, date: day, order: ids})
});
}
async function done() {
var albumId = new URLSearchParams(location.search).get('album_id');
var res = await fetch('/curate/done', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId})
});
var data = await res.json();
if (data.redirect) window.location = data.redirect;
}
</script>
{% endblock %}
@@ -1,99 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="p-4 max-w-3xl mx-auto" x-data="groupApp('{{ album_id }}')">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Group</h1>
<button id="done-btn" class="btn btn-primary btn-sm" @click="done()">Grouping done &rarr;</button>
</div>
<div class="space-y-1">
{% for grp in groups %}
<div class="group-block border border-base-300 rounded-lg p-2 space-y-1">
{% if grp.label %}
<div class="text-xs font-semibold opacity-70 px-1">{{ grp.label }}</div>
{% endif %}
{% for photo in grp.photos %}
<div class="stream-photo flex items-center gap-3 bg-base-100 rounded p-1"
data-order="{{ photo.order }}">
<img src="/proxy/thumb/{{ photo.id }}" class="w-16 h-16 object-cover rounded">
<span class="text-xs opacity-60">{{ photo.local_datetime[11:16] }}</span>
<span class="badge badge-xs {% if photo.tag == 'story' %}badge-info{% else %}badge-success{% endif %}">
{{ photo.tag }}
</span>
</div>
{% if not loop.last %}
<div class="divider-zone group relative h-4 flex items-center cursor-pointer"
data-after-order="{{ photo.order }}">
<div class="absolute inset-x-0 h-0.5 bg-base-300 group-hover:bg-primary transition"></div>
<button class="insert-divider-btn absolute left-1/2 -translate-x-1/2 btn btn-xs btn-primary opacity-0 group-hover:opacity-100 transition z-10"
@click="addDivider({{ photo.order }})">&#x2702; cut here</button>
</div>
{% endif %}
{% endfor %}
</div>
{% if grp.divider_id %}
<div class="flex items-center gap-2 my-1 px-1">
<input class="group-label input input-sm input-bordered flex-1"
value="{{ grp.label }}"
placeholder="Label this entry&#x2026;"
@change="setLabel('{{ grp.divider_id }}', $el.value)"
@keydown.enter="$el.blur()">
<button class="remove-divider-btn btn btn-xs btn-ghost opacity-60"
@click="removeDivider('{{ grp.divider_id }}')">&#x2715;</button>
</div>
{% endif %}
{% if not loop.last and not grp.divider_id %}
<div class="divider-zone group relative h-4 flex items-center cursor-pointer"
data-after-order="{{ grp.photos[-1].order }}">
<div class="absolute inset-x-0 h-0.5 bg-base-300 group-hover:bg-primary transition"></div>
<button class="insert-divider-btn absolute left-1/2 -translate-x-1/2 btn btn-xs btn-primary opacity-0 group-hover:opacity-100 transition z-10"
@click="addDivider({{ grp.photos[-1].order }})">&#x2702; cut here</button>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function groupApp(albumId) {
return {
async addDivider(afterOrder) {
await fetch('/group/divider', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, after_order: afterOrder})
});
window.location.reload();
},
async removeDivider(dividerId) {
await fetch('/group/remove-divider', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, divider_id: dividerId})
});
window.location.reload();
},
async setLabel(dividerId, label) {
await fetch('/group/label', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, divider_id: dividerId, label: label})
});
},
async done() {
var res = await fetch('/group/done', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId})
});
var data = await res.json();
if (data.redirect) window.location = data.redirect;
},
};
}
</script>
{% endblock %}
@@ -1,202 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="p-4 max-w-6xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Write</h1>
<span class="text-sm opacity-60">{{ done_count }} / {{ total }} done</span>
</div>
{% if not group %}
<div class="alert alert-success mb-4">All groups written or skipped.</div>
<form method="post" action="/write/done">
<input type="hidden" name="album_id" value="{{ album_id }}">
<button type="submit" class="btn btn-primary">Export →</button>
</form>
{% else %}
<div class="flex gap-4">
<!-- Photos panel -->
<div class="group-photos w-64 flex-shrink-0 space-y-2 overflow-y-auto max-h-[80vh]">
{% for photo in photos %}
<img src="/proxy/thumb/{{ photo.id }}"
id="photo-{{ photo.id }}"
class="w-full rounded cursor-pointer border-4 border-transparent transition"
onclick="setHero('{{ photo.id }}')"
alt="">
{% endfor %}
</div>
<!-- Form -->
<div class="flex-1 space-y-4">
<!-- Mode switch -->
<div class="tabs">
<button id="mode-journal" class="tab tab-bordered tab-active"
onclick="setMode('journal')">Journal</button>
<button id="mode-story" class="tab tab-bordered"
onclick="setMode('story')">Story</button>
</div>
<div class="form-control">
<label class="label text-sm">Title</label>
<input id="title-field" type="text" class="input input-bordered"
oninput="scheduleAutosave()"
value="{{ group.title | e }}">
</div>
<div class="form-control">
<label class="label text-sm">Date</label>
<input id="date-field" type="text" class="input input-bordered input-sm"
oninput="scheduleAutosave()"
value="{{ group.date | e }}">
</div>
<div class="grid grid-cols-2 gap-2">
<div class="form-control">
<label class="label text-sm">City</label>
<input id="city-field" type="text" class="input input-bordered input-sm"
oninput="scheduleAutosave()"
value="{{ group.location_city | e }}">
</div>
<div class="form-control">
<label class="label text-sm">Country</label>
<input id="country-field" type="text" class="input input-bordered input-sm"
oninput="scheduleAutosave()"
value="{{ group.location_country | e }}">
</div>
</div>
<div class="form-control" id="mode-journal-fields">
<label class="label text-sm">Body</label>
<textarea id="body-field" class="textarea textarea-bordered h-40"
oninput="scheduleAutosave()">{{ group.body | e }}</textarea>
</div>
<!-- Story-only fields (hidden by default if mode is journal) -->
<div id="hero-picker" class="form-control" style="display:{% if group.entry_type == 'story' %}block{% else %}none{% endif %}">
<label class="label text-sm">Hero photo: <span id="hero-label">{{ group.hero_photo_id or 'none' }}</span></label>
<p class="text-xs opacity-60">Click a photo on the left to set it as the hero.</p>
</div>
<div id="shortcode-field-wrap" class="form-control" style="display:{% if group.entry_type == 'story' %}block{% else %}none{% endif %}">
<label class="label text-sm">Shortcode hints</label>
<input id="shortcode-field" type="text" class="input input-bordered input-sm"
oninput="scheduleAutosave()"
placeholder="e.g. gallery block, pull quote"
value="{{ group.shortcode_hints | e }}">
</div>
<div class="flex gap-2 mt-4">
{% if group_idx > 0 %}
<a href="/write?album_id={{ album_id }}&group_idx={{ group_idx - 1 }}" class="btn btn-ghost btn-sm">← Prev</a>
{% endif %}
<button id="skip-btn" class="btn btn-ghost btn-sm" onclick="skipGroup()">Skip for now</button>
<button class="btn btn-primary btn-sm ml-auto" onclick="saveAndNext()">Save &amp; next</button>
</div>
</div>
<!-- Inline notes -->
<div id="inline-notes" class="w-64 flex-shrink-0 bg-base-100 rounded p-3">
<h3 class="font-semibold text-sm mb-2">Your notes</h3>
<p class="text-xs opacity-70 whitespace-pre-wrap">{{ state.notes or 'No notes yet.' }}</p>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
{% if group %}
<script>
(function() {
var albumId = {{ album_id | tojson }};
var groupId = {{ group.id | tojson }};
var mode = {{ group.entry_type | tojson }};
var heroId = {{ group.hero_photo_id | tojson }};
var autosaveTimer = null;
window.setMode = function(m) {
mode = m;
var storyFields = ['hero-picker', 'shortcode-field-wrap'];
storyFields.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.style.display = (m === 'story') ? 'block' : 'none';
});
document.getElementById('mode-journal').classList.toggle('tab-active', m === 'journal');
document.getElementById('mode-story').classList.toggle('tab-active', m === 'story');
scheduleAutosave();
};
window.setHero = function(id) {
heroId = id;
// Update border highlight
document.querySelectorAll('.group-photos img').forEach(function(img) {
img.classList.remove('border-primary');
img.classList.add('border-transparent');
});
var el = document.getElementById('photo-' + id);
if (el) { el.classList.remove('border-transparent'); el.classList.add('border-primary'); }
var label = document.getElementById('hero-label');
if (label) label.textContent = id;
scheduleAutosave();
};
window.scheduleAutosave = function() {
clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(doAutosave, 500);
};
function getFormData() {
return {
album_id: albumId,
group_id: groupId,
entry_type: mode,
hero_photo_id: heroId,
title: document.getElementById('title-field') ? document.getElementById('title-field').value : '',
body: document.getElementById('body-field') ? document.getElementById('body-field').value : '',
location_city: document.getElementById('city-field') ? document.getElementById('city-field').value : '',
location_country: document.getElementById('country-field') ? document.getElementById('country-field').value : '',
date: document.getElementById('date-field') ? document.getElementById('date-field').value : '',
shortcode_hints: document.getElementById('shortcode-field') ? document.getElementById('shortcode-field').value : '',
};
}
function doAutosave() {
fetch('/write/autosave', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(getFormData()),
});
}
window.skipGroup = function() {
fetch('/write/skip', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId, group_id: groupId}),
}).then(function() {
window.location.reload();
});
};
window.saveAndNext = function() {
fetch('/write/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(getFormData()),
}).then(function() {
var url = new URL(window.location.href);
var idx = parseInt(url.searchParams.get('group_idx') || '0');
url.searchParams.set('group_idx', idx + 1);
window.location.href = url.toString();
});
};
// Initialize mode display
if (mode === 'story') {
document.getElementById('mode-story') && document.getElementById('mode-story').classList.add('tab-active');
document.getElementById('mode-journal') && document.getElementById('mode-journal').classList.remove('tab-active');
}
})();
</script>
{% endif %}
{% endblock %}
@@ -1,104 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="p-6 max-w-3xl mx-auto" x-data="exportApp('{{ album_id }}')">
<h1 class="text-2xl font-bold mb-4">Export</h1>
<div class="stats shadow mb-6">
<div class="stat"><div class="stat-title">Ready to export</div>
<div class="stat-value text-primary">{{ to_export | length }}</div></div>
<div class="stat"><div class="stat-title">Skipped</div>
<div class="stat-value opacity-40">{{ skipped | length }}</div></div>
</div>
<div class="space-y-2 mb-6">
{% for group in to_export %}
<div class="export-item card card-compact bg-base-100 shadow">
<div class="card-body">
<p class="font-semibold">{{ group.title }}</p>
<p class="text-xs opacity-60">{{ group.date }} · {{ group.entry_type }} · {{ group.photo_ids | length }} photos</p>
</div>
</div>
{% endfor %}
</div>
<button id="export-btn" class="btn btn-primary" @click="runExport()">
Export {{ to_export | length }} entries
</button>
<!-- Overwrite confirmation modal -->
<dialog id="overwrite-modal" class="modal">
<div class="modal-box">
<h3 class="font-bold">Destination exists</h3>
<p x-text="overwriteMsg" class="py-2 text-sm"></p>
<div class="modal-action">
<button class="btn btn-warning btn-sm" @click="confirmOverwrite()">Overwrite</button>
<button class="btn btn-ghost btn-sm" @click="cancelExport()">Cancel</button>
</div>
</div>
</dialog>
<!-- Results -->
<div x-show="successMsg !== ''" class="mt-6 alert alert-success text-sm" x-text="successMsg"></div>
<div x-show="failedCount > 0" class="mt-2 alert alert-warning text-sm"
x-text="`${failedCount} photo(s) failed to download`"></div>
<details class="mt-6">
<summary class="cursor-pointer text-sm opacity-60 skipped-list">
Skipped ({{ skipped | length }}) — not exported
</summary>
<ul class="mt-2 space-y-1 text-sm opacity-60">
{% for g in skipped %}<li>{{ g.title or g.date }}</li>{% endfor %}
</ul>
</details>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function exportApp(albumId) {
return {
successMsg: '',
failedCount: 0,
conflictPath: null,
overwriteMsg: '',
async runExport() {
const res = await fetch('/export/run', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId}),
});
const data = await res.json();
if (data.conflict) {
this.conflictPath = data.path;
this.overwriteMsg = `Destination already exists: ${data.path}`;
document.getElementById('overwrite-modal').showModal();
} else if (data.ok) {
this.successMsg = `Exported ${data.exported} entr${data.exported === 1 ? 'y' : 'ies'} successfully.`;
this.failedCount = (data.failed || []).length;
}
},
async confirmOverwrite() {
document.getElementById('overwrite-modal').close();
const res = await fetch('/export/overwrite', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId, path: this.conflictPath}),
});
const data = await res.json();
if (data.conflict) {
this.conflictPath = data.path;
this.overwriteMsg = `Destination already exists: ${data.path}`;
document.getElementById('overwrite-modal').showModal();
} else if (data.ok) {
this.successMsg = `Exported ${data.exported} entr${data.exported === 1 ? 'y' : 'ies'} successfully.`;
this.failedCount = (data.failed || []).length;
}
},
cancelExport() {
document.getElementById('overwrite-modal').close();
this.conflictPath = null;
},
};
}
</script>
{% endblock %}
-2
View File
@@ -1,2 +0,0 @@
[pytest]
pythonpath = .
@@ -1,5 +0,0 @@
flask==3.1.0
requests==2.32.3
pytest==8.3.4
pytest-playwright==0.6.2
pytest-httpserver==1.1.0
-101
View File
@@ -1,101 +0,0 @@
import json
import os
import shutil
import threading
import time
from pathlib import Path
import pytest
from werkzeug.serving import make_server
FIXTURES_DIR = Path(__file__).parent / "fixtures"
TINY_PNG = bytes.fromhex(
"89504e470d0a1a0a0000000d4948445200000001000000010806"
"0000001f15c4890000000a4944415478016360000000020001e2"
"21bc330000000049454e44ae426082"
)
MOCK_ALBUMS = [
{
"id": "album-1",
"albumName": "Central Asia 2023",
"assetCount": 3,
"albumThumbnailAssetId": "asset-1",
}
]
MOCK_ALBUM_DETAIL = {
"id": "album-1",
"albumName": "Central Asia 2023",
"assets": [
{"id": "asset-1", "originalFileName": "IMG_001.jpg",
"localDateTime": "2023-09-05T09:03:00"},
{"id": "asset-2", "originalFileName": "IMG_002.jpg",
"localDateTime": "2023-09-05T14:30:00"},
{"id": "asset-3", "originalFileName": "IMG_003.jpg",
"localDateTime": "2023-09-06T10:00:00"},
],
}
@pytest.fixture(scope="session")
def httpserver_listen_address():
return ("127.0.0.1", 8099)
@pytest.fixture(scope="session")
def mock_immich(make_httpserver):
server = make_httpserver
server.expect_request("/api/albums").respond_with_json(MOCK_ALBUMS)
server.expect_request("/api/albums/album-1").respond_with_json(MOCK_ALBUM_DETAIL)
for asset_id in ["asset-1", "asset-2", "asset-3"]:
server.expect_request(
f"/api/assets/{asset_id}/thumbnail"
).respond_with_data(TINY_PNG, content_type="image/png")
server.expect_request(
f"/api/assets/{asset_id}/original"
).respond_with_data(TINY_PNG, content_type="image/jpeg")
return server
@pytest.fixture(scope="session")
def state_dir(tmp_path_factory):
return tmp_path_factory.mktemp("state")
@pytest.fixture(scope="session")
def pages_dir(tmp_path_factory):
return tmp_path_factory.mktemp("pages")
@pytest.fixture(scope="session")
def flask_app(state_dir, pages_dir, mock_immich):
os.environ["IMMICH_URL"] = f"http://127.0.0.1:8099"
os.environ["IMMICH_API_KEY"] = "test-key"
from app import create_app
return create_app(state_dir=str(state_dir), pages_dir=str(pages_dir))
@pytest.fixture(scope="session")
def base_url(flask_app):
server = make_server("127.0.0.1", 8083, flask_app)
t = threading.Thread(target=server.serve_forever)
t.daemon = True
t.start()
time.sleep(0.2)
yield "http://127.0.0.1:8083"
server.shutdown()
@pytest.fixture()
def seed_state(state_dir):
"""Copy a fixture JSON into the state dir; return the album_id."""
def _seed(fixture_name: str) -> str:
src = FIXTURES_DIR / f"{fixture_name}.json"
with open(src) as f:
data = json.load(f)
dst = Path(state_dir) / f"{data['album_id']}.json"
shutil.copy(src, dst)
return data["album_id"]
return _seed
@@ -1,20 +0,0 @@
{
"album_id": "album-1",
"album_name": "Central Asia 2023",
"grav_trip_slug": "central-asia-2023",
"phase": "triage",
"phases_completed": [],
"phase_stale": [],
"photos": [
{"id": "asset-1", "original_filename": "IMG_001.jpg",
"local_datetime": "2023-09-05T09:03:00", "tag": "untagged", "order": 0},
{"id": "asset-2", "original_filename": "IMG_002.jpg",
"local_datetime": "2023-09-05T14:30:00", "tag": "untagged", "order": 1},
{"id": "asset-3", "original_filename": "IMG_003.jpg",
"local_datetime": "2023-09-06T10:00:00", "tag": "untagged", "order": 2}
],
"groups": [],
"notes": "",
"dividers": [],
"group_labels": {}
}
@@ -1,20 +0,0 @@
{
"album_id": "album-1",
"album_name": "Central Asia 2023",
"grav_trip_slug": "central-asia-2023",
"phase": "curate",
"phases_completed": ["triage"],
"phase_stale": [],
"photos": [
{"id": "asset-1", "original_filename": "IMG_001.jpg",
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
{"id": "asset-2", "original_filename": "IMG_002.jpg",
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1},
{"id": "asset-3", "original_filename": "IMG_003.jpg",
"local_datetime": "2023-09-06T10:00:00", "tag": "skip", "order": 2}
],
"groups": [],
"notes": "",
"dividers": [],
"group_labels": {}
}
@@ -1,18 +0,0 @@
{
"album_id": "album-1",
"album_name": "Central Asia 2023",
"grav_trip_slug": "central-asia-2023",
"phase": "group",
"phases_completed": ["triage", "curate"],
"phase_stale": [],
"photos": [
{"id": "asset-1", "original_filename": "IMG_001.jpg",
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
{"id": "asset-2", "original_filename": "IMG_002.jpg",
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
],
"groups": [],
"notes": "I remember the airport was chaos.",
"dividers": [],
"group_labels": {}
}
@@ -1,31 +0,0 @@
{
"album_id": "album-1",
"album_name": "Central Asia 2023",
"grav_trip_slug": "central-asia-2023",
"phase": "write",
"phases_completed": ["triage", "curate", "group"],
"phase_stale": [],
"photos": [
{"id": "asset-1", "original_filename": "IMG_001.jpg",
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
{"id": "asset-2", "original_filename": "IMG_002.jpg",
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
],
"groups": [
{
"id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal",
"label": "", "title": "", "body": "", "location_city": "", "location_country": "",
"date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "",
"status": "draft"
},
{
"id": "g2", "photo_ids": ["asset-2"], "entry_type": "story",
"label": "", "title": "", "body": "", "location_city": "", "location_country": "",
"date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "",
"status": "draft"
}
],
"notes": "I remember the airport was chaos.",
"dividers": [],
"group_labels": {}
}
@@ -1,33 +0,0 @@
{
"album_id": "album-1",
"album_name": "Central Asia 2023",
"grav_trip_slug": "central-asia-2023",
"phase": "export",
"phases_completed": ["triage", "curate", "group", "write"],
"phase_stale": [],
"photos": [
{"id": "asset-1", "original_filename": "IMG_001.jpg",
"local_datetime": "2023-09-05T09:03:00", "tag": "journal", "order": 0},
{"id": "asset-2", "original_filename": "IMG_002.jpg",
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
],
"groups": [
{
"id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal",
"label": "", "title": "Arrival in Almaty", "body": "Chaos at the airport.",
"location_city": "Almaty", "location_country": "Kazakhstan",
"date": "2023-09-05", "hero_photo_id": "asset-1", "shortcode_hints": "",
"status": "written"
},
{
"id": "g2", "photo_ids": ["asset-2"], "entry_type": "story",
"label": "", "title": "The Market", "body": "Colours everywhere.",
"location_city": "Almaty", "location_country": "Kazakhstan",
"date": "2023-09-05", "hero_photo_id": "asset-2", "shortcode_hints": "gallery block",
"status": "skipped"
}
],
"notes": "",
"dividers": [],
"group_labels": {}
}
@@ -1,87 +0,0 @@
import json
def test_hard_refresh_preserves_triage_state(base_url, page, seed_state, flask_app):
"""State is server-side — hard refresh must not reset it."""
album_id = seed_state("phase2_state")
page.goto(f"{base_url}/triage?album_id={album_id}")
page.locator(".photo-card").first.click()
page.keyboard.press("j")
page.wait_for_timeout(400)
page.reload()
first_card = page.locator(".photo-card").first
assert "border-success" in first_card.get_attribute("class")
def test_back_nav_from_group_to_triage_marks_curate_group_stale(base_url, page, seed_state):
album_id = seed_state("phase4_state") # completed=[triage, curate], phase=group
page.request.post(
f"{base_url}/nav/phase",
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
headers={"Content-Type": "application/json"},
)
resp = page.request.get(f"{base_url}/state/{album_id}")
state = resp.json()
assert "curate" in state["phase_stale"]
assert "group" in state["phase_stale"]
def test_stale_banner_visible_on_stale_phase(base_url, page, seed_state):
album_id = seed_state("phase4_state")
page.request.post(
f"{base_url}/nav/phase",
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
headers={"Content-Type": "application/json"},
)
# Now visit curate (which is stale)
page.goto(f"{base_url}/curate?album_id={album_id}")
assert page.locator("#stale-banner").is_visible()
def test_dismiss_stale_clears_flag(base_url, page, seed_state, flask_app):
album_id = seed_state("phase4_state")
page.request.post(
f"{base_url}/nav/phase",
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
headers={"Content-Type": "application/json"},
)
page.goto(f"{base_url}/curate?album_id={album_id}")
page.locator("#stale-banner button").click()
page.wait_for_url("**/curate**")
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
assert "curate" not in state.phase_stale
def test_exported_group_not_affected_by_back_nav(base_url, page, seed_state, flask_app):
"""Exporting then going back to triage must not touch the exported group."""
album_id = seed_state("phase6_state")
# Manually set one group to exported
with flask_app.app_context():
from app.state import load_state, save_state
state = load_state(album_id, flask_app)
state.groups[0].status = "exported"
save_state(state, flask_app)
# Navigate back
page.request.post(
f"{base_url}/nav/phase",
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
headers={"Content-Type": "application/json"},
)
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
assert state.groups[0].status == "exported"
def test_notes_autosave_survives_phase_navigation(base_url, page, seed_state, flask_app):
album_id = seed_state("phase2_state")
page.request.post(
f"{base_url}/notes/save",
data=json.dumps({"album_id": album_id, "notes": "survives navigation"}),
headers={"Content-Type": "application/json"},
)
page.goto(f"{base_url}/curate?album_id={album_id}")
resp = page.request.get(f"{base_url}/notes/{album_id}")
assert resp.json()["notes"] == "survives navigation"
@@ -1,50 +0,0 @@
import pytest
from app.immich import ImmichClient
@pytest.fixture
def client(mock_immich):
return ImmichClient(
base_url=f"http://127.0.0.1:8099",
api_key="test-key",
)
def test_list_albums(client):
albums = client.list_albums()
assert len(albums) == 1
assert albums[0]["albumName"] == "Central Asia 2023"
def test_get_album(client):
album = client.get_album("album-1")
assert len(album["assets"]) == 3
def test_get_thumbnail_returns_bytes(client):
data = client.get_thumbnail("asset-1")
assert isinstance(data, bytes)
assert len(data) > 0
def test_get_original_returns_bytes(client):
data = client.get_original("asset-1")
assert isinstance(data, bytes)
def test_list_albums_connection_error_raises(monkeypatch):
client = ImmichClient(base_url="http://127.0.0.1:1", api_key="x")
with pytest.raises(ConnectionError):
client.list_albums()
def test_proxy_thumb_route(base_url, page, seed_state):
seed_state("phase2_state")
page.goto(f"{base_url}/proxy/thumb/asset-1")
assert page.evaluate("document.contentType").startswith("image/")
def test_proxy_original_route(base_url, page, seed_state):
seed_state("phase2_state")
page.goto(f"{base_url}/proxy/original/asset-1")
assert page.evaluate("document.contentType").startswith("image/")
@@ -1,40 +0,0 @@
import json
import pytest
def test_notes_save(base_url, page, seed_state):
album_id = seed_state("phase2_state")
resp = page.request.post(
f"{base_url}/notes/save",
data=json.dumps({"album_id": album_id, "notes": "hello memory"}),
headers={"Content-Type": "application/json"},
)
assert resp.ok
assert resp.json()["ok"] is True
def test_notes_persist_after_reload(base_url, page, seed_state):
album_id = seed_state("phase2_state")
page.request.post(
f"{base_url}/notes/save",
data=json.dumps({"album_id": album_id, "notes": "persisted note"}),
headers={"Content-Type": "application/json"},
)
page.goto(f"{base_url}/triage?album_id={album_id}")
assert page.locator("#notes-panel").inner_text().__contains__("persisted note") or True
# Notes content is loaded from server state — verify via API response
resp = page.request.get(f"{base_url}/notes/{album_id}")
assert resp.json()["notes"] == "persisted note"
def test_nav_back_marks_stale(base_url, page, seed_state):
album_id = seed_state("phase4_state") # phase=group, completed=[triage,curate]
page.request.post(
f"{base_url}/nav/phase",
data=json.dumps({"album_id": album_id, "target_phase": "triage"}),
headers={"Content-Type": "application/json"},
)
resp = page.request.get(f"{base_url}/state/{album_id}")
data = resp.json()
assert "curate" in data["phase_stale"]
assert "group" in data["phase_stale"]
@@ -1,30 +0,0 @@
def test_album_list_renders(base_url, page):
page.goto(base_url)
assert page.locator(".album-card").count() == 1
assert "Central Asia 2023" in page.inner_text(".album-card")
def test_select_single_album_creates_state(base_url, page):
page.goto(base_url)
page.locator(".album-card input[type=checkbox]").first.check()
page.fill("#grav-slug", "central-asia-2023")
page.locator("button[type=submit]").click()
page.wait_for_url("**/triage**")
assert "album_id=album-1" in page.url
def test_resume_prompt_shown_for_existing_state(base_url, page, seed_state):
seed_state("phase2_state")
page.goto(base_url)
assert page.locator("[data-album-id=album-1] .resume-badge").is_visible()
def test_immich_unreachable_shows_error(base_url, page, monkeypatch):
import app.routes.albums as a
orig = a.ImmichClient
class BrokenClient:
def __init__(self, *a, **k): pass
def list_albums(self): raise ConnectionError("down")
monkeypatch.setattr(a, "ImmichClient", BrokenClient)
page.goto(base_url)
assert page.locator(".alert-error").is_visible()
@@ -1,40 +0,0 @@
import json
def test_photos_render_in_day_groups(base_url, page, seed_state):
album_id = seed_state("phase2_state")
page.goto(f"{base_url}/triage?album_id={album_id}")
assert page.locator(".day-group").count() >= 1
assert page.locator(".photo-card").count() == 3
def test_keyboard_j_tags_journal(base_url, page, seed_state):
album_id = seed_state("phase2_state")
page.goto(f"{base_url}/triage?album_id={album_id}")
page.locator(".photo-card").first.click()
page.keyboard.press("j")
page.wait_for_timeout(300)
card = page.locator(".photo-card").first
assert "border-success" in card.get_attribute("class")
def test_keyboard_s_tags_story(base_url, page, seed_state):
album_id = seed_state("phase2_state")
page.goto(f"{base_url}/triage?album_id={album_id}")
page.locator(".photo-card").first.click()
page.keyboard.press("s")
page.wait_for_timeout(300)
assert "border-info" in page.locator(".photo-card").first.get_attribute("class")
def test_done_button_disabled_until_all_tagged(base_url, page, seed_state):
album_id = seed_state("phase2_state")
page.goto(f"{base_url}/triage?album_id={album_id}")
assert page.locator("#done-btn").is_disabled()
def test_done_advances_to_curate(base_url, page, seed_state):
album_id = seed_state("phase3_state") # all tagged
page.goto(f"{base_url}/triage?album_id={album_id}")
page.locator("#done-btn").click()
page.wait_for_url("**/curate**")
@@ -1,40 +0,0 @@
import json
def test_only_kept_photos_shown(base_url, page, seed_state):
album_id = seed_state("phase3_state")
page.goto(f"{base_url}/curate?album_id={album_id}")
# phase3_state has 2 kept (journal+story) and 1 skipped
assert page.locator(".photo-card").count() == 2
def test_remove_reverts_to_skip(base_url, page, seed_state, flask_app):
album_id = seed_state("phase3_state")
page.goto(f"{base_url}/curate?album_id={album_id}")
page.locator(".remove-btn").first.click()
page.wait_for_timeout(300)
assert page.locator(".photo-card").count() == 1
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
removed = next(p for p in state.photos if p.id == "asset-1")
assert removed.tag == "skip"
def test_retag_journal_to_story(base_url, page, seed_state, flask_app):
album_id = seed_state("phase3_state")
page.goto(f"{base_url}/curate?album_id={album_id}")
page.locator(".retag-btn").first.click()
page.wait_for_timeout(300)
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
p = next(p for p in state.photos if p.id == "asset-1")
assert p.tag == "story"
def test_done_advances_to_group(base_url, page, seed_state):
album_id = seed_state("phase3_state")
page.goto(f"{base_url}/curate?album_id={album_id}")
page.locator("#done-btn").click()
page.wait_for_url("**/group**")
@@ -1,47 +0,0 @@
import json
def test_photos_shown_as_stream(base_url, page, seed_state):
album_id = seed_state("phase4_state")
page.goto(f"{base_url}/group?album_id={album_id}")
assert page.locator(".stream-photo").count() == 2
def test_insert_divider_creates_group_boundary(base_url, page, seed_state, flask_app):
album_id = seed_state("phase4_state")
page.goto(f"{base_url}/group?album_id={album_id}")
page.locator(".divider-zone").first.hover()
page.locator(".insert-divider-btn").first.click()
page.wait_for_timeout(300)
assert page.locator(".group-block").count() == 2
def test_remove_divider_merges_groups(base_url, page, seed_state):
album_id = seed_state("phase4_state")
page.goto(f"{base_url}/group?album_id={album_id}")
page.locator(".divider-zone").first.hover()
page.locator(".insert-divider-btn").first.click()
page.wait_for_timeout(200)
page.locator(".remove-divider-btn").first.click()
page.wait_for_timeout(200)
assert page.locator(".group-block").count() == 1
def test_label_edit_persists(base_url, page, seed_state, flask_app):
album_id = seed_state("phase4_state")
page.goto(f"{base_url}/group?album_id={album_id}")
page.locator(".divider-zone").first.hover()
page.locator(".insert-divider-btn").first.click()
page.wait_for_timeout(200)
page.locator(".group-label").first.fill("Morning walk")
page.locator(".group-label").first.press("Enter")
page.wait_for_timeout(300)
page.reload()
assert "Morning walk" in page.locator(".group-label").first.input_value()
def test_done_advances_to_write(base_url, page, seed_state):
album_id = seed_state("phase4_state")
page.goto(f"{base_url}/group?album_id={album_id}")
page.locator("#done-btn").click()
page.wait_for_url("**/write**")
@@ -1,44 +0,0 @@
import json
def test_first_group_shown(base_url, page, seed_state):
album_id = seed_state("phase5_state")
page.goto(f"{base_url}/write?album_id={album_id}")
assert page.locator(".group-photos img").count() >= 1
assert page.locator("#title-field").is_visible()
def test_form_autosave_on_input(base_url, page, seed_state, flask_app):
album_id = seed_state("phase5_state")
page.goto(f"{base_url}/write?album_id={album_id}")
page.fill("#title-field", "Arrival in Almaty")
page.wait_for_timeout(700)
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
assert state.groups[0].title == "Arrival in Almaty"
def test_journal_to_story_mode_switch_shows_hero_picker(base_url, page, seed_state):
album_id = seed_state("phase5_state")
page.goto(f"{base_url}/write?album_id={album_id}")
page.locator("#mode-story").click()
assert page.locator("#hero-picker").is_visible()
assert not page.locator("#mode-journal-fields").is_visible() or True
def test_skip_defers_group(base_url, page, seed_state, flask_app):
album_id = seed_state("phase5_state")
page.goto(f"{base_url}/write?album_id={album_id}")
page.locator("#skip-btn").click()
page.wait_for_timeout(400)
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
assert state.groups[0].status == "skipped"
def test_notes_shown_inline_in_write_phase(base_url, page, seed_state):
album_id = seed_state("phase5_state")
page.goto(f"{base_url}/write?album_id={album_id}")
assert page.locator("#inline-notes").is_visible()
@@ -1,74 +0,0 @@
import json
import shutil
from pathlib import Path
def test_summary_shows_written_and_skipped(base_url, page, seed_state):
album_id = seed_state("phase6_state")
page.goto(f"{base_url}/export?album_id={album_id}")
assert "1 journal" in page.inner_text("body").lower() or page.locator(".export-item").count() >= 1
assert page.locator(".skipped-list").is_visible()
def test_export_writes_entry_folder(base_url, page, seed_state, pages_dir):
album_id = seed_state("phase6_state")
page.goto(f"{base_url}/export?album_id={album_id}")
page.locator("#export-btn").click()
page.wait_for_timeout(2000)
dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies"
assert any(dest.iterdir()) if dest.exists() else True # may not exist in test env
def test_export_sets_status_exported(base_url, page, seed_state, flask_app, pages_dir):
album_id = seed_state("phase6_state")
# Ensure dest folder does not exist so export proceeds without conflict
daily_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies"
if daily_dest.exists():
shutil.rmtree(daily_dest)
res = page.request.post(
f"{base_url}/export/run",
data=json.dumps({"album_id": album_id}),
headers={"Content-Type": "application/json"},
)
data = res.json()
# Must not be a conflict — export should succeed
assert data.get("ok") is True, f"Expected ok response, got: {data}"
# The journal entry.md file must exist on disk
entry_files = list(daily_dest.glob("**/entry.md")) if daily_dest.exists() else []
assert len(entry_files) >= 1, "entry.md not written to disk"
# Status must be exported in state
with flask_app.app_context():
from app.state import load_state
state = load_state(album_id, flask_app)
written = [g for g in state.groups if g.status not in ("skipped", "exported")]
assert len(written) == 0
def test_skipped_groups_not_exported(base_url, page, seed_state, pages_dir):
album_id = seed_state("phase6_state")
# Clean dest so there's no conflict
daily_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "01.dailies"
if daily_dest.exists():
shutil.rmtree(daily_dest)
res = page.request.post(
f"{base_url}/export/run",
data=json.dumps({"album_id": album_id}),
headers={"Content-Type": "application/json"},
)
data = res.json()
# Response shape: {"ok": true, "exported": N, "failed": [...]}
# g2 "The Market" is skipped — it must not appear as an exported folder
stories_dest = Path(pages_dir) / "01.trips" / "central-asia-2023" / "04.stories"
market_dirs = list(stories_dest.glob("*the-market*")) if stories_dest.exists() else []
assert len(market_dirs) == 0, "Skipped group 'The Market' must not be exported"
# And the response must not include a conflict (only written groups are exported)
assert data.get("ok") is True, f"Expected ok response, got: {data}"
@@ -1,3 +0,0 @@
def test_health(base_url, page):
page.goto(f"{base_url}/health")
assert "ok" in page.content()
@@ -1,52 +0,0 @@
import json
import pytest
from pathlib import Path
from app.state import TripState, Photo, Group, load_state, save_state
@pytest.fixture
def app_ctx(flask_app):
with flask_app.app_context():
yield flask_app
def test_save_and_load_roundtrip(app_ctx, state_dir):
state = TripState(
album_id="test-album",
album_name="Test",
grav_trip_slug="test-trip",
photos=[Photo(id="p1", original_filename="a.jpg",
local_datetime="2023-01-01T10:00:00")],
groups=[],
)
save_state(state, app_ctx)
loaded = load_state("test-album", app_ctx)
assert loaded.album_id == "test-album"
assert loaded.photos[0].id == "p1"
def test_atomic_write_uses_tmp(app_ctx, state_dir, monkeypatch):
written_paths = []
real_rename = __import__("os").rename
def fake_rename(src, dst):
written_paths.append(src)
real_rename(src, dst)
monkeypatch.setattr("app.state.os.rename", fake_rename)
state = TripState(album_id="atomic-test", album_name="X", grav_trip_slug="x")
save_state(state, app_ctx)
assert any(str(p).endswith(".tmp") for p in written_paths)
def test_load_nonexistent_returns_none(app_ctx):
assert load_state("no-such-album", app_ctx) is None
def test_exported_status_field_preserved(app_ctx):
state = TripState(
album_id="export-test", album_name="E", grav_trip_slug="e",
groups=[Group(id="g1", photo_ids=[], entry_type="journal",
status="exported")]
)
save_state(state, app_ctx)
loaded = load_state("export-test", app_ctx)
assert loaded.groups[0].status == "exported"