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:
@@ -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_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(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.get("/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})
|
||||
@@ -21,6 +21,7 @@ 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 = ""
|
||||
@@ -42,6 +43,8 @@ class TripState:
|
||||
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:
|
||||
|
||||
@@ -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 →</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 }})">✂ 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…"
|
||||
@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 }}')">✕</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 }})">✂ 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 %}
|
||||
@@ -14,5 +14,7 @@
|
||||
"local_datetime": "2023-09-06T10:00:00", "tag": "untagged", "order": 2}
|
||||
],
|
||||
"groups": [],
|
||||
"notes": ""
|
||||
"notes": "",
|
||||
"dividers": [],
|
||||
"group_labels": {}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,7 @@
|
||||
"local_datetime": "2023-09-06T10:00:00", "tag": "skip", "order": 2}
|
||||
],
|
||||
"groups": [],
|
||||
"notes": ""
|
||||
"notes": "",
|
||||
"dividers": [],
|
||||
"group_labels": {}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,7 @@
|
||||
"local_datetime": "2023-09-05T14:30:00", "tag": "story", "order": 1}
|
||||
],
|
||||
"groups": [],
|
||||
"notes": "I remember the airport was chaos."
|
||||
"notes": "I remember the airport was chaos.",
|
||||
"dividers": [],
|
||||
"group_labels": {}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,18 @@
|
||||
"groups": [
|
||||
{
|
||||
"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": "",
|
||||
"status": "draft"
|
||||
},
|
||||
{
|
||||
"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": "",
|
||||
"status": "draft"
|
||||
}
|
||||
],
|
||||
"notes": "I remember the airport was chaos."
|
||||
"notes": "I remember the airport was chaos.",
|
||||
"dividers": [],
|
||||
"group_labels": {}
|
||||
}
|
||||
|
||||
@@ -14,18 +14,20 @@
|
||||
"groups": [
|
||||
{
|
||||
"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",
|
||||
"date": "2023-09-05", "hero_photo_id": "asset-1", "shortcode_hints": "",
|
||||
"status": "written"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"date": "2023-09-05", "hero_photo_id": "asset-2", "shortcode_hints": "gallery block",
|
||||
"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**")
|
||||
Reference in New Issue
Block a user