Merge branch 'worktree-playwright-tests'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 17:12:37 +02:00
21 changed files with 1656 additions and 160 deletions
+85 -94
View File
@@ -45,12 +45,12 @@ def export_view():
def run_export():
body = request.get_json()
album_id = body["album_id"]
overwrite_ids = set(body.get("overwrite_ids", []))
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}
results = []
exported = 0
all_failed = []
for group in state.groups:
if group.status != "written":
@@ -65,22 +65,12 @@ def run_export():
else:
folder_name = f"{title_slug}.story"
dest = pages_dir / "01.trips" / state.grav_trip_slug / "04.stories" / folder_name
md_file = "entry.md"
md_file = "story.md"
template = "story"
if dest.exists() and group.id not in overwrite_ids:
results.append({
"group_id": group.id,
"needs_overwrite": True,
"title": group.title,
"dest": str(dest),
})
# Mark as exported since destination already exists
group.status = "exported"
continue
if dest.exists():
shutil.rmtree(dest)
save_state(state, current_app)
return jsonify({"conflict": True, "path": str(dest)})
dest.mkdir(parents=True, exist_ok=True)
@@ -101,7 +91,7 @@ def run_export():
photo_num += 1
except Exception as e:
current_app.logger.warning("Failed to download asset %s: %s", pid, e)
failed.append({"asset_id": pid, "error": str(e)})
failed.append(pid)
# Build frontmatter
date_str = (group.date + " 12:00") if group.date else ""
@@ -134,101 +124,102 @@ def run_export():
(dest / md_file).write_text(frontmatter + "\n" + body_text)
group.status = "exported"
results.append({
"group_id": group.id,
"title": group.title,
"dest": str(dest),
"failed_photos": failed,
})
exported += 1
all_failed.extend(failed)
save_state(state, current_app)
return jsonify({"ok": True, "results": results})
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"]
group_id = body["group_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}
group = next((g for g in state.groups if g.id == group_id), None)
if group is None:
return jsonify({"ok": False, "error": "group not found"}), 404
# Remove the conflicting folder so the run loop can proceed past it
if conflict_path.exists():
shutil.rmtree(conflict_path)
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 = "entry.md"
template = "story"
exported = 0
all_failed = []
if dest.exists():
shutil.rmtree(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:
for group in state.groups:
if group.status != "written":
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({"asset_id": pid, "error": str(e)})
date_str = (group.date + " 12:00") if group.date else ""
if group.entry_type == "journal":
frontmatter = (
f"---\n"
f"title: '{group.title}'\n"
f"date: '{date_str}'\n"
f"template: {template}\n"
f"published: true\n"
f"location_city: '{group.location_city}'\n"
f"location_country: '{group.location_country}'\n"
f"hero_image: {hero_filename or ''}\n"
f"---\n"
)
else:
frontmatter = (
f"---\n"
f"title: '{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"
)
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"
body_text = group.body or ""
if group.shortcode_hints:
body_text += f"\n<!-- shortcode hints:\n{group.shortcode_hints}\n-->"
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: '{group.title}'\n"
f"date: '{date_str}'\n"
f"template: {template}\n"
f"published: true\n"
f"location_city: '{group.location_city}'\n"
f"location_country: '{group.location_country}'\n"
f"hero_image: {hero_filename or ''}\n"
f"---\n"
)
else:
frontmatter = (
f"---\n"
f"title: '{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)
(dest / md_file).write_text(frontmatter + "\n" + body_text)
group.status = "exported"
save_state(state, current_app)
return jsonify({
"ok": True,
"exported": 1,
"title": group.title,
"dest": str(dest),
"failed_photos": failed,
})
return jsonify({"ok": True, "exported": exported, "failed": all_failed})
@@ -31,22 +31,15 @@
<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="skipOverwrite()">Skip this entry</button>
<button class="btn btn-ghost btn-sm" @click="cancelExport()">Cancel</button>
</div>
</div>
</dialog>
<!-- Results -->
<div x-show="results.length > 0" class="mt-6 space-y-1">
<template x-for="r in results" :key="r.group_id">
<div class="text-sm" :class="r.needs_overwrite ? 'text-warning' : 'text-success'">
<span x-text="r.needs_overwrite ? '⚠ ' + r.title + ' — exists' : '✓ ' + r.title"></span>
<template x-if="r.failed_photos && r.failed_photos.length">
<span class="text-error ml-2" x-text="`(${r.failed_photos.length} photo(s) failed)`"></span>
</template>
</div>
</template>
</div>
<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">
@@ -63,44 +56,47 @@
<script>
function exportApp(albumId) {
return {
results: [],
pendingOverwrites: [],
currentOverwrite: null,
successMsg: '',
failedCount: 0,
conflictPath: null,
overwriteMsg: '',
confirmedIds: [],
async runExport(extraOverwrites = []) {
async runExport() {
const res = await fetch('/export/run', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({album_id: albumId, overwrite_ids: [...this.confirmedIds, ...extraOverwrites]}),
body: JSON.stringify({album_id: albumId}),
});
const data = await res.json();
const needsOverwrite = data.results.filter(r => r.needs_overwrite);
const done = data.results.filter(r => !r.needs_overwrite);
this.results.push(...done);
if (needsOverwrite.length > 0) {
this.pendingOverwrites = needsOverwrite;
this.showNextOverwrite();
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;
}
},
showNextOverwrite() {
if (this.pendingOverwrites.length === 0) return;
this.currentOverwrite = this.pendingOverwrites.shift();
this.overwriteMsg = `"${this.currentOverwrite.title}" already exists at ${this.currentOverwrite.dest}`;
document.getElementById('overwrite-modal').showModal();
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;
}
},
confirmOverwrite() {
cancelExport() {
document.getElementById('overwrite-modal').close();
this.confirmedIds.push(this.currentOverwrite.group_id);
this.runExport();
},
skipOverwrite() {
document.getElementById('overwrite-modal').close();
this.results.push({...this.currentOverwrite, skipped: true});
this.showNextOverwrite();
this.conflictPath = null;
},
};
}
+36 -6
View File
@@ -1,4 +1,5 @@
import json
import shutil
from pathlib import Path
@@ -18,13 +19,29 @@ def test_export_writes_entry_folder(base_url, page, seed_state, pages_dir):
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):
def test_export_sets_status_exported(base_url, page, seed_state, flask_app, pages_dir):
album_id = seed_state("phase6_state")
page.request.post(
# 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, "overwrite_ids": []}),
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)
@@ -34,11 +51,24 @@ def test_export_sets_status_exported(base_url, page, seed_state, flask_app):
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, "overwrite_ids": []}),
data=json.dumps({"album_id": album_id}),
headers={"Content-Type": "application/json"},
)
data = res.json()
exported_titles = [r.get("title") for r in data.get("results", [])]
assert "The Market" not in exported_titles # g2 is skipped in fixture
# 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}"