feat: Phase 4 grouping with entry-break dividers

Add group.py route, phase4.html template, and supporting state changes.
Photos are shown as a flat stream; clicking divider zones inserts
entry-break boundaries that split photos into labelled groups. Labels
persist via group_labels dict. Done materialises groups into state.groups
and advances to write phase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 16:44:54 +02:00
parent 23b68d845b
commit b5c90a1e81
10 changed files with 286 additions and 10 deletions
+2 -1
View File
@@ -8,13 +8,14 @@ def create_app(state_dir=None, pages_dir=None):
app.config["IMMICH_URL"] = os.environ.get("IMMICH_URL", "") app.config["IMMICH_URL"] = os.environ.get("IMMICH_URL", "")
app.config["IMMICH_API_KEY"] = os.environ.get("IMMICH_API_KEY", "") app.config["IMMICH_API_KEY"] = os.environ.get("IMMICH_API_KEY", "")
from .routes import albums, triage, proxy, notes, nav, curate from .routes import albums, triage, proxy, notes, nav, curate, group
app.register_blueprint(albums.bp) app.register_blueprint(albums.bp)
app.register_blueprint(triage.bp) app.register_blueprint(triage.bp)
app.register_blueprint(proxy.bp) app.register_blueprint(proxy.bp)
app.register_blueprint(notes.bp) app.register_blueprint(notes.bp)
app.register_blueprint(nav.bp) app.register_blueprint(nav.bp)
app.register_blueprint(curate.bp) app.register_blueprint(curate.bp)
app.register_blueprint(group.bp)
@app.get("/health") @app.get("/health")
def health(): def health():
@@ -0,0 +1,116 @@
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})
+3
View File
@@ -21,6 +21,7 @@ class Group:
id: str id: str
photo_ids: list = field(default_factory=list) photo_ids: list = field(default_factory=list)
entry_type: str = "journal" # journal | story entry_type: str = "journal" # journal | story
label: str = ""
title: str = "" title: str = ""
body: str = "" body: str = ""
location_city: str = "" location_city: str = ""
@@ -42,6 +43,8 @@ class TripState:
photos: list = field(default_factory=list) photos: list = field(default_factory=list)
groups: list = field(default_factory=list) groups: list = field(default_factory=list)
notes: str = "" 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: def _state_path(album_id: str, app) -> Path:
@@ -0,0 +1,99 @@
{% 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 %}
+3 -1
View File
@@ -14,5 +14,7 @@
"local_datetime": "2023-09-06T10:00:00", "tag": "untagged", "order": 2} "local_datetime": "2023-09-06T10:00:00", "tag": "untagged", "order": 2}
], ],
"groups": [], "groups": [],
"notes": "" "notes": "",
"dividers": [],
"group_labels": {}
} }
+3 -1
View File
@@ -14,5 +14,7 @@
"local_datetime": "2023-09-06T10:00:00", "tag": "skip", "order": 2} "local_datetime": "2023-09-06T10:00:00", "tag": "skip", "order": 2}
], ],
"groups": [], "groups": [],
"notes": "" "notes": "",
"dividers": [],
"group_labels": {}
} }
+3 -1
View File
@@ -12,5 +12,7 @@
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1} "local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
], ],
"groups": [], "groups": [],
"notes": "I remember the airport was chaos." "notes": "I remember the airport was chaos.",
"dividers": [],
"group_labels": {}
} }
+5 -3
View File
@@ -14,16 +14,18 @@
"groups": [ "groups": [
{ {
"id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal", "id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal",
"title": "", "body": "", "location_city": "", "location_country": "", "label": "", "title": "", "body": "", "location_city": "", "location_country": "",
"date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "", "date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "",
"status": "draft" "status": "draft"
}, },
{ {
"id": "g2", "photo_ids": ["asset-2"], "entry_type": "story", "id": "g2", "photo_ids": ["asset-2"], "entry_type": "story",
"title": "", "body": "", "location_city": "", "location_country": "", "label": "", "title": "", "body": "", "location_city": "", "location_country": "",
"date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "", "date": "2023-09-05", "hero_photo_id": null, "shortcode_hints": "",
"status": "draft" "status": "draft"
} }
], ],
"notes": "I remember the airport was chaos." "notes": "I remember the airport was chaos.",
"dividers": [],
"group_labels": {}
} }
+5 -3
View File
@@ -14,18 +14,20 @@
"groups": [ "groups": [
{ {
"id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal", "id": "g1", "photo_ids": ["asset-1"], "entry_type": "journal",
"title": "Arrival in Almaty", "body": "Chaos at the airport.", "label": "", "title": "Arrival in Almaty", "body": "Chaos at the airport.",
"location_city": "Almaty", "location_country": "Kazakhstan", "location_city": "Almaty", "location_country": "Kazakhstan",
"date": "2023-09-05", "hero_photo_id": "asset-1", "shortcode_hints": "", "date": "2023-09-05", "hero_photo_id": "asset-1", "shortcode_hints": "",
"status": "written" "status": "written"
}, },
{ {
"id": "g2", "photo_ids": ["asset-2"], "entry_type": "story", "id": "g2", "photo_ids": ["asset-2"], "entry_type": "story",
"title": "The Market", "body": "Colours everywhere.", "label": "", "title": "The Market", "body": "Colours everywhere.",
"location_city": "Almaty", "location_country": "Kazakhstan", "location_city": "Almaty", "location_country": "Kazakhstan",
"date": "2023-09-05", "hero_photo_id": "asset-2", "shortcode_hints": "gallery block", "date": "2023-09-05", "hero_photo_id": "asset-2", "shortcode_hints": "gallery block",
"status": "skipped" "status": "skipped"
} }
], ],
"notes": "" "notes": "",
"dividers": [],
"group_labels": {}
} }
@@ -0,0 +1,47 @@
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**")