CODEX - Added Warning sign if Archetype is missing

This commit is contained in:
2026-02-23 13:21:42 +02:00
parent d49a9636e5
commit d390bdac0d
6 changed files with 119 additions and 26 deletions

View File

@@ -43,7 +43,7 @@ async def create_melody(
publish: bool = Query(False), publish: bool = Query(False),
_user: TokenPayload = Depends(require_permission("melodies", "add")), _user: TokenPayload = Depends(require_permission("melodies", "add")),
): ):
return await service.create_melody(body, publish=publish) return await service.create_melody(body, publish=publish, actor_name=_user.name)
@router.put("/{melody_id}", response_model=MelodyInDB) @router.put("/{melody_id}", response_model=MelodyInDB)
@@ -52,7 +52,7 @@ async def update_melody(
body: MelodyUpdate, body: MelodyUpdate,
_user: TokenPayload = Depends(require_permission("melodies", "edit")), _user: TokenPayload = Depends(require_permission("melodies", "edit")),
): ):
return await service.update_melody(melody_id, body) return await service.update_melody(melody_id, body, actor_name=_user.name)
@router.delete("/{melody_id}", status_code=204) @router.delete("/{melody_id}", status_code=204)
@@ -108,9 +108,9 @@ async def upload_file(
name=melody.information.name, name=melody.information.name,
previewURL=url, previewURL=url,
) )
)) ), actor_name=_user.name)
elif file_type == "binary": elif file_type == "binary":
await service.update_melody(melody_id, MelodyUpdate(url=url)) await service.update_melody(melody_id, MelodyUpdate(url=url), actor_name=_user.name)
return {"url": url, "file_type": file_type} return {"url": url, "file_type": file_type}

View File

@@ -1,6 +1,7 @@
import json import json
import uuid import uuid
import logging import logging
from datetime import datetime
from shared.firebase import get_db as get_firestore, get_bucket from shared.firebase import get_db as get_firestore, get_bucket
from shared.exceptions import NotFoundError from shared.exceptions import NotFoundError
@@ -93,10 +94,44 @@ async def get_melody(melody_id: str) -> MelodyInDB:
raise NotFoundError("Melody") raise NotFoundError("Melody")
async def create_melody(data: MelodyCreate, publish: bool = False) -> MelodyInDB: def _sanitize_metadata_for_create(existing: dict | None, actor_name: str | None) -> dict:
now = datetime.utcnow().isoformat() + "Z"
metadata = dict(existing or {})
creator = metadata.get("createdBy") or actor_name or "Unknown"
created_at = metadata.get("dateCreated") or now
metadata["createdBy"] = creator
metadata["dateCreated"] = created_at
metadata["lastEditedBy"] = actor_name or metadata.get("lastEditedBy") or creator
metadata["dateEdited"] = now
if "adminNotes" not in metadata:
metadata["adminNotes"] = []
return metadata
def _sanitize_metadata_for_update(existing: dict | None, incoming: dict | None, actor_name: str | None) -> dict:
now = datetime.utcnow().isoformat() + "Z"
existing_meta = dict(existing or {})
incoming_meta = dict(incoming or {})
# Created fields are immutable after first set.
created_by = existing_meta.get("createdBy") or incoming_meta.get("createdBy") or actor_name or "Unknown"
date_created = existing_meta.get("dateCreated") or incoming_meta.get("dateCreated") or now
merged = {**existing_meta, **incoming_meta}
merged["createdBy"] = created_by
merged["dateCreated"] = date_created
merged["lastEditedBy"] = actor_name or incoming_meta.get("lastEditedBy") or existing_meta.get("lastEditedBy") or created_by
merged["dateEdited"] = now
if "adminNotes" not in merged:
merged["adminNotes"] = existing_meta.get("adminNotes", [])
return merged
async def create_melody(data: MelodyCreate, publish: bool = False, actor_name: str | None = None) -> MelodyInDB:
"""Create a new melody. If publish=True, also push to Firestore.""" """Create a new melody. If publish=True, also push to Firestore."""
melody_id = str(uuid.uuid4()) melody_id = str(uuid.uuid4())
doc_data = data.model_dump() doc_data = data.model_dump()
doc_data["metadata"] = _sanitize_metadata_for_create(doc_data.get("metadata"), actor_name)
status = "published" if publish else "draft" status = "published" if publish else "draft"
# Always save to SQLite # Always save to SQLite
@@ -110,7 +145,7 @@ async def create_melody(data: MelodyCreate, publish: bool = False) -> MelodyInDB
return MelodyInDB(id=melody_id, status=status, **doc_data) return MelodyInDB(id=melody_id, status=status, **doc_data)
async def update_melody(melody_id: str, data: MelodyUpdate) -> MelodyInDB: async def update_melody(melody_id: str, data: MelodyUpdate, actor_name: str | None = None) -> MelodyInDB:
"""Update an existing melody. If published, also update Firestore.""" """Update an existing melody. If published, also update Firestore."""
row = await melody_db.get_melody(melody_id) row = await melody_db.get_melody(melody_id)
if not row: if not row:
@@ -124,6 +159,12 @@ async def update_melody(melody_id: str, data: MelodyUpdate) -> MelodyInDB:
if key in update_data and key in existing_data: if key in update_data and key in existing_data:
merged = {**existing_data[key], **update_data[key]} merged = {**existing_data[key], **update_data[key]}
update_data[key] = merged update_data[key] = merged
if "metadata" in update_data or "metadata" in existing_data:
update_data["metadata"] = _sanitize_metadata_for_update(
existing_data.get("metadata"),
update_data.get("metadata"),
actor_name,
)
merged_data = {**existing_data, **update_data} merged_data = {**existing_data, **update_data}

View File

@@ -249,6 +249,7 @@ export default function MelodyDetail() {
const speedBpm = formatBpm(speedMs); const speedBpm = formatBpm(speedMs);
const minBpm = formatBpm(info.minSpeed); const minBpm = formatBpm(info.minSpeed);
const maxBpm = formatBpm(info.maxSpeed); const maxBpm = formatBpm(info.maxSpeed);
const missingArchetype = Boolean(melody.pid) && !builtMelody?.id;
const languages = melodySettings?.available_languages || ["en"]; const languages = melodySettings?.available_languages || ["en"];
const displayName = getLocalizedValue(info.name, displayLang, "Untitled Melody"); const displayName = getLocalizedValue(info.name, displayLang, "Untitled Melody");
@@ -556,10 +557,6 @@ export default function MelodyDetail() {
const handleDownload = async (e) => { const handleDownload = async (e) => {
e.preventDefault(); e.preventDefault();
if (binaryUrl.startsWith("http")) {
window.open(binaryUrl, "_blank", "noopener,noreferrer");
return;
}
try { try {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
let res = null; let res = null;
@@ -586,7 +583,13 @@ export default function MelodyDetail() {
a.click(); a.click();
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
} catch (err) { } catch (err) {
window.open(binaryUrl, "_blank", "noopener,noreferrer"); const a = document.createElement("a");
a.href = binaryUrl;
a.download = downloadName;
a.rel = "noopener noreferrer";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} }
}; };
@@ -600,6 +603,15 @@ export default function MelodyDetail() {
{downloadName} {downloadName}
</span> </span>
)} )}
{missingArchetype && (
<span
className="text-xs cursor-help"
style={{ color: "#f59e0b" }}
title="This binary does not exist in the Archetypes"
>
</span>
)}
<button <button
type="button" type="button"
onClick={handleDownload} onClick={handleDownload}

View File

@@ -257,7 +257,13 @@ export default function MelodyForm() {
a.click(); a.click();
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
} catch (err) { } catch (err) {
window.open(fileUrl, "_blank", "noopener,noreferrer"); const a = document.createElement("a");
a.href = fileUrl;
a.download = fallbackName || "download.file";
a.rel = "noopener noreferrer";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} }
}; };
@@ -764,11 +770,21 @@ export default function MelodyForm() {
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm)</label> <label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm)</label>
{(() => { {(() => {
const { effectiveUrl: binaryUrl, effectiveName: binaryName } = getEffectiveBinary(); const { effectiveUrl: binaryUrl, effectiveName: binaryName } = getEffectiveBinary();
const missingArchetype = Boolean(pid) && !builtMelody?.id;
return ( return (
<div className="text-xs mb-2" style={{ color: "var(--text-muted)" }}> <div className="text-xs mb-2" style={{ color: "var(--text-muted)" }}>
{binaryUrl ? ( {binaryUrl ? (
<span className="text-sm mr-4" style={{ color: "var(--text-secondary)" }}> <span className="inline-flex items-center gap-2 text-sm mr-4" style={{ color: "var(--text-secondary)" }}>
{binaryName} <span>{binaryName}</span>
{missingArchetype && (
<span
className="text-xs cursor-help"
style={{ color: "#f59e0b" }}
title="This binary does not exist in the Archetypes"
>
</span>
)}
</span> </span>
) : ( ) : (
<span>No binary uploaded</span> <span>No binary uploaded</span>

View File

@@ -375,6 +375,7 @@ export default function MelodyList() {
: `/api/${candidate}`) : `/api/${candidate}`)
: null; : null;
const source = built?.binary_url ? "Archetype" : (url ? "Melody URL" : null); const source = built?.binary_url ? "Archetype" : (url ? "Melody URL" : null);
const missingArchetype = Boolean(row?.pid) && !built?.id;
const filename = (() => { const filename = (() => {
if (built?.pid) return `${built.pid}.bsm`; if (built?.pid) return `${built.pid}.bsm`;
if (row?.pid) return `${row.pid}.bsm`; if (row?.pid) return `${row.pid}.bsm`;
@@ -391,7 +392,7 @@ export default function MelodyList() {
} }
return "melody.bsm"; return "melody.bsm";
})(); })();
return { url, filename, source, built }; return { url, filename, source, built, missingArchetype };
}; };
const handleDelete = async () => { const handleDelete = async () => {
@@ -466,9 +467,16 @@ export default function MelodyList() {
a.click(); a.click();
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
} catch (err) { } catch (err) {
const fallbackUrl = resolveEffectiveBinary(row).url; const fallback = resolveEffectiveBinary(row);
const fallbackUrl = fallback.url;
if (fallbackUrl) { if (fallbackUrl) {
window.open(fallbackUrl, "_blank", "noopener,noreferrer"); const a = document.createElement("a");
a.href = fallbackUrl;
a.download = fallback.filename || "melody.bsm";
a.rel = "noopener noreferrer";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} else { } else {
setError(err.message); setError(err.message);
} }
@@ -876,9 +884,13 @@ export default function MelodyList() {
<span className="inline-flex flex-col"> <span className="inline-flex flex-col">
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>{filename || "binary.bsm"}</span> <span className="text-xs" style={{ color: "var(--text-secondary)" }}>{filename || "binary.bsm"}</span>
{resolved.source && ( {resolved.missingArchetype && (
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ color: "var(--text-muted)", backgroundColor: "var(--bg-card-hover)" }}> <span
{resolved.source} className="text-xs cursor-help"
style={{ color: "#f59e0b" }}
title="This binary does not exist in the Archetypes"
>
</span> </span>
)} )}
</span> </span>

View File

@@ -110,6 +110,14 @@ function applyNoteAssignments(rawStepValue, noteAssignments) {
return result; return result;
} }
function bellDotColor(assignedBell) {
const bell = Number(assignedBell || 0);
if (bell <= 0) return "rgba(148,163,184,0.7)";
const t = Math.min(1, Math.max(0, (bell - 1) / 15));
const light = 62 - t * 40; // bright green -> very dark green
return `hsl(132, 74%, ${light}%)`;
}
const mutedStyle = { color: "var(--text-muted)" }; const mutedStyle = { color: "var(--text-muted)" };
const labelStyle = { color: "var(--text-secondary)" }; const labelStyle = { color: "var(--text-secondary)" };
const NOTE_LABELS = "ABCDEFGHIJKLMNOP"; const NOTE_LABELS = "ABCDEFGHIJKLMNOP";
@@ -460,7 +468,9 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
const enabled = Boolean(stepValue & (1 << noteIdx)); const enabled = Boolean(stepValue & (1 << noteIdx));
const isCurrent = currentStep === stepIdx; const isCurrent = currentStep === stepIdx;
const assignedBell = Number(noteAssignments[noteIdx] || 0); const assignedBell = Number(noteAssignments[noteIdx] || 0);
const dotLabel = assignedBell > 0 ? assignedBell : noteIdx + 1; const dotLabel = assignedBell > 0 ? assignedBell : "";
const isUnassigned = assignedBell <= 0;
const dotVisible = enabled || (isUnassigned && Boolean(stepValue & (1 << noteIdx)));
return ( return (
<td <td
key={`${noteIdx}-${stepIdx}`} key={`${noteIdx}-${stepIdx}`}
@@ -479,15 +489,17 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
width: "68%", width: "68%",
height: "68%", height: "68%",
borderRadius: "9999px", borderRadius: "9999px",
backgroundColor: "var(--btn-primary)", backgroundColor: isUnassigned ? "rgba(100,116,139,0.7)" : bellDotColor(assignedBell),
color: "var(--text-white)", color: "var(--text-white)",
opacity: enabled ? 1 : 0, opacity: dotVisible ? 1 : 0,
transform: enabled ? "scale(1)" : "scale(0.4)", transform: dotVisible ? "scale(1)" : "scale(0.4)",
boxShadow: enabled ? "0 0 10px 3px rgba(116, 184, 22, 0.5)" : "none", boxShadow: dotVisible
? (isUnassigned ? "0 0 8px 2px rgba(100,116,139,0.35)" : `0 0 10px 3px ${bellDotColor(assignedBell)}66`)
: "none",
transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease", transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease",
}} }}
> >
{enabled ? dotLabel : ""} {dotVisible ? dotLabel : ""}
</span> </span>
</span> </span>
</td> </td>