Compare commits

...

4 Commits

Author SHA1 Message Date
m038 7b7810cc59 feat: add remote-fetch-content to pull user content repo on server
Separate from remote-fetch (main repo) since content sync is handled
by the git-sync plugin once configured; this is for one-off manual pulls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 16:26:03 +02:00
m038 32775ef83f fix: remote-fetch switches to main branch before pulling
Server clone was tracking experimental-polar-steps; checkout main
ensures it follows the correct branch going forward.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-21 16:25:12 +02:00
m038 39d19cf2f8 feat: Phase 1 album selection with resume/start-over
Implements GET / listing Immich albums with resume badge, POST /select
creating TripState and redirecting to /triage; graceful error display
when Immich is unreachable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 16:18:38 +02:00
m038 c9c1a50103 fix: correct Alpine scope for notes panel, tojson escaping, remove dead code
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 16:11:41 +02:00
6 changed files with 162 additions and 8 deletions
+4 -1
View File
@@ -105,7 +105,10 @@ remote-install:
# ── Remote: ongoing maintenance ────────────────────────────────────────────────
remote-fetch:
$(SSH) "git -C $(SITE_CONFIG_DIR) pull"
$(SSH) "git -C $(SITE_CONFIG_DIR) checkout main && git -C $(SITE_CONFIG_DIR) pull"
remote-fetch-content:
$(SSH) "git -C $(WEBROOT)/user checkout main && git -C $(WEBROOT)/user pull"
remote-install-plugins:
$(SSH) "cd $(WEBROOT) && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y"
+70 -2
View File
@@ -1,9 +1,77 @@
from flask import Blueprint, current_app, render_template, request
from app.state import load_state
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 _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 = request.form["grav_trip_slug"].strip()
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}")
# TODO(task-6): replace this stub with the real triage route
@bp.get("/triage")
def triage():
album_id = request.args.get("album_id", "")
@@ -3,8 +3,6 @@ from app.state import load_state, save_state
bp = Blueprint("notes", __name__)
PHASE_ORDER = ["triage", "curate", "group", "write", "export"]
@bp.post("/notes/save")
def save_notes():
@@ -7,7 +7,7 @@
<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">
<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">
@@ -27,7 +27,7 @@
</div>
<div class="navbar-end px-4">
{% if album_id %}
<button class="btn btn-ghost btn-sm" @click="open = !open" x-data>📝 Notes</button>
<button class="btn btn-ghost btn-sm" @click="open = !open">📝 Notes</button>
{% endif %}
</div>
</div>
@@ -45,7 +45,7 @@
{% endif %}
<!-- Body with notes drawer -->
<div class="flex relative" x-data="notesApp('{{ notes_content | e }}', '{{ album_id }}')">
<div class="flex relative">
<div class="flex-1 min-w-0 transition-all" :class="open ? 'mr-80' : ''">
{% block content %}{% endblock %}
</div>
@@ -0,0 +1,55 @@
{% 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 %}
@@ -0,0 +1,30 @@
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()