Further improvements

This commit is contained in:
2026-02-22 15:32:45 +02:00
parent c5ef4406f6
commit ef31852fd8
6 changed files with 179 additions and 28 deletions

View File

@@ -95,7 +95,7 @@ async def download_binary(
raise HTTPException(status_code=404, detail="Binary not built yet for this melody") raise HTTPException(status_code=404, detail="Binary not built yet for this melody")
melody = await service.get_built_melody(melody_id) melody = await service.get_built_melody(melody_id)
filename = f"{melody.name}.bsm" filename = f"{melody.pid or melody.name}.bsm"
return FileResponse( return FileResponse(
path=str(path), path=str(path),

View File

@@ -130,7 +130,20 @@ async def get_built_melody(melody_id: str) -> BuiltMelodyInDB:
return _row_to_built_melody(row) return _row_to_built_melody(row)
async def _check_unique(name: str, pid: str, exclude_id: Optional[str] = None) -> None:
"""Raise 409 if name or PID is already taken by another archetype."""
rows = await db.list_built_melodies()
for row in rows:
if exclude_id and row["id"] == exclude_id:
continue
if row["name"].lower() == name.lower():
raise HTTPException(status_code=409, detail=f"An archetype with the name '{name}' already exists.")
if pid and row.get("pid") and row["pid"].lower() == pid.lower():
raise HTTPException(status_code=409, detail=f"An archetype with the PID '{pid}' already exists.")
async def create_built_melody(data: BuiltMelodyCreate) -> BuiltMelodyInDB: async def create_built_melody(data: BuiltMelodyCreate) -> BuiltMelodyInDB:
await _check_unique(data.name, data.pid or "")
melody_id = str(uuid.uuid4()) melody_id = str(uuid.uuid4())
await db.insert_built_melody( await db.insert_built_melody(
melody_id=melody_id, melody_id=melody_id,
@@ -150,6 +163,8 @@ async def update_built_melody(melody_id: str, data: BuiltMelodyUpdate) -> BuiltM
new_pid = data.pid if data.pid is not None else row["pid"] new_pid = data.pid if data.pid is not None else row["pid"]
new_steps = data.steps if data.steps is not None else row["steps"] new_steps = data.steps if data.steps is not None else row["steps"]
await _check_unique(new_name, new_pid or "", exclude_id=melody_id)
await db.update_built_melody(melody_id, name=new_name, pid=new_pid, steps=new_steps) await db.update_built_melody(melody_id, name=new_name, pid=new_pid, steps=new_steps)
return await get_built_melody(melody_id) return await get_built_melody(melody_id)

View File

@@ -93,13 +93,17 @@ select {
background-color: var(--bg-input) !important; background-color: var(--bg-input) !important;
border-color: var(--border-input) !important; border-color: var(--border-input) !important;
color: var(--text-primary) !important; color: var(--text-primary) !important;
-webkit-appearance: none !important;
appearance: none !important;
}
/* Only select elements get the dropdown arrow */
select {
padding-right: 2rem !important; padding-right: 2rem !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") !important; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") !important;
background-repeat: no-repeat !important; background-repeat: no-repeat !important;
background-position: right 0.6rem center !important; background-position: right 0.6rem center !important;
background-size: 12px !important; background-size: 12px !important;
-webkit-appearance: none !important;
appearance: none !important;
} }
input::placeholder, input::placeholder,
textarea::placeholder { textarea::placeholder {

View File

@@ -383,17 +383,40 @@ export default function MelodyDetail() {
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Files</h2> <h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Files</h2>
<dl className="space-y-4"> <dl className="space-y-4">
<Field label="Binary File"> <Field label="Binary File">
{files.binary_url ? ( {files.binary_url ? (() => {
const binaryPid = builtMelody?.pid || melody.pid || "binary";
const binaryFilename = `${binaryPid}.bsm`;
const handleDownload = async (e) => {
e.preventDefault();
try {
const token = localStorage.getItem("access_token");
const res = await fetch(files.binary_url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = binaryFilename;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
// surface error in page error state if possible
console.error(err);
}
};
return (
<a <a
href={files.binary_url} href={files.binary_url}
target="_blank" onClick={handleDownload}
rel="noopener noreferrer"
className="underline" className="underline"
style={{ color: "var(--accent)" }} style={{ color: "var(--accent)" }}
> >
Download binary {binaryFilename}
</a> </a>
) : ( );
})() : (
<span style={{ color: "var(--text-muted)" }}>Not uploaded</span> <span style={{ color: "var(--text-muted)" }}>Not uploaded</span>
)} )}
</Field> </Field>

View File

@@ -23,6 +23,29 @@ function computeStepsAndNotes(stepsStr) {
return { steps: tokens.length, totalNotes: bellSet.size || 1 }; return { steps: tokens.length, totalNotes: bellSet.size || 1 };
} }
/**
* Validate steps string. Each comma-separated token must be:
* - "0" (silence)
* - a number 1-16
* - multiple of the above joined by "+"
* Returns null if valid, or an error string if invalid.
*/
function validateSteps(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return "Steps are required.";
const tokens = stepsStr.trim().split(",");
for (const token of tokens) {
const parts = token.split("+");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed === "") return `Invalid token near: "${token.trim()}" — empty part found.`;
const n = parseInt(trimmed, 10);
if (isNaN(n)) return `Invalid step value: "${trimmed}" — must be a number 016.`;
if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (must be 016).`;
}
}
return null;
}
export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defaultName, defaultPid, onClose, onSuccess }) { export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defaultName, defaultPid, onClose, onSuccess }) {
const [name, setName] = useState(defaultName || ""); const [name, setName] = useState(defaultName || "");
const [pid, setPid] = useState(defaultPid || ""); const [pid, setPid] = useState(defaultPid || "");
@@ -34,14 +57,39 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defa
if (!open) return null; if (!open) return null;
const handleBuildAndUpload = async () => { const handleBuildAndUpload = async () => {
// Mandatory field checks
if (!name.trim()) { setError("Name is required."); return; } if (!name.trim()) { setError("Name is required."); return; }
if (!pid.trim()) { setError("PID is required."); return; }
if (!steps.trim()) { setError("Steps are required."); return; } if (!steps.trim()) { setError("Steps are required."); return; }
// Steps format validation
const stepsError = validateSteps(steps);
if (stepsError) { setError(stepsError); return; }
setBuilding(true); setBuilding(true);
setError(""); setError("");
setStatusMsg(""); setStatusMsg("");
let builtId = null;
try { try {
// Uniqueness check: fetch all existing archetypes
setStatusMsg("Checking for conflicts...");
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.name.toLowerCase() === name.trim().toLowerCase());
if (dupName) {
setError(`An archetype with the name "${name.trim()}" already exists.`);
setBuilding(false);
setStatusMsg("");
return;
}
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
if (dupPid) {
setError(`An archetype with the PID "${pid.trim()}" already exists.`);
setBuilding(false);
setStatusMsg("");
return;
}
// Step 1: Create the built melody record // Step 1: Create the built melody record
setStatusMsg("Creating melody record..."); setStatusMsg("Creating melody record...");
const created = await api.post("/builder/melodies", { const created = await api.post("/builder/melodies", {
@@ -49,7 +97,7 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defa
pid: pid.trim(), pid: pid.trim(),
steps: steps.trim(), steps: steps.trim(),
}); });
builtId = created.id; const builtId = created.id;
// Step 2: Build the binary // Step 2: Build the binary
setStatusMsg("Building binary..."); setStatusMsg("Building binary...");
@@ -63,7 +111,8 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defa
}); });
if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`); if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`);
const blob = await res.blob(); const blob = await res.blob();
const file = new File([blob], `${name.trim()}.bsm`, { type: "application/octet-stream" }); // File is named by PID, not friendly name
const file = new File([blob], `${pid.trim()}.bsm`, { type: "application/octet-stream" });
await api.upload(`/melodies/${melodyId}/upload/binary`, file); await api.upload(`/melodies/${melodyId}/upload/binary`, file);
// Step 4: Assign to this melody // Step 4: Assign to this melody
@@ -134,7 +183,7 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defa
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} disabled={building} /> <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} disabled={building} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID</label> <label className="block text-sm font-medium mb-1" style={labelStyle}>PID *</label>
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} disabled={building} /> <input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} disabled={building} />
</div> </div>
</div> </div>

View File

@@ -30,6 +30,29 @@ function countSteps(stepsStr) {
return stepsStr.trim().split(",").length; return stepsStr.trim().split(",").length;
} }
/**
* Validate steps string. Each comma-separated token must be:
* - "0" (silence)
* - a number 1-16
* - multiple of the above joined by "+"
* Returns null if valid, or an error string if invalid.
*/
function validateSteps(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return "Steps are required.";
const tokens = stepsStr.trim().split(",");
for (const token of tokens) {
const parts = token.split("+");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed === "") return `Invalid token near: "${token.trim()}" — empty part found.`;
const n = parseInt(trimmed, 10);
if (isNaN(n)) return `Invalid step value: "${trimmed}" — must be a number 016.`;
if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (must be 016).`;
}
}
return null;
}
async function downloadBinary(binaryUrl, filename) { async function downloadBinary(binaryUrl, filename) {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
const res = await fetch(`/api${binaryUrl}`, { const res = await fetch(`/api${binaryUrl}`, {
@@ -50,10 +73,14 @@ export default function BuilderForm() {
const isEdit = Boolean(id); const isEdit = Boolean(id);
const navigate = useNavigate(); const navigate = useNavigate();
// Form state (what the user is editing)
const [name, setName] = useState(""); const [name, setName] = useState("");
const [pid, setPid] = useState(""); const [pid, setPid] = useState("");
const [steps, setSteps] = useState(""); const [steps, setSteps] = useState("");
// Saved state (what's actually stored — build actions use this)
const [savedPid, setSavedPid] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [buildingBinary, setBuildingBinary] = useState(false); const [buildingBinary, setBuildingBinary] = useState(false);
@@ -66,6 +93,9 @@ export default function BuilderForm() {
const [progmemCode, setProgmemCode] = useState(""); const [progmemCode, setProgmemCode] = useState("");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// Track whether the form has unsaved changes relative to what's built
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const codeRef = useRef(null); const codeRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -79,9 +109,11 @@ export default function BuilderForm() {
setName(data.name || ""); setName(data.name || "");
setPid(data.pid || ""); setPid(data.pid || "");
setSteps(data.steps || ""); setSteps(data.steps || "");
setSavedPid(data.pid || "");
setBinaryBuilt(Boolean(data.binary_path)); setBinaryBuilt(Boolean(data.binary_path));
setBinaryUrl(data.binary_url || null); setBinaryUrl(data.binary_url || null);
setProgmemCode(data.progmem_code || ""); setProgmemCode(data.progmem_code || "");
setHasUnsavedChanges(false);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -89,16 +121,37 @@ export default function BuilderForm() {
} }
}; };
// Track form dirtiness whenever name/pid/steps change after initial load
const handleNameChange = (v) => { setName(v); setHasUnsavedChanges(true); };
const handlePidChange = (v) => { setPid(v); setHasUnsavedChanges(true); };
const handleStepsChange = (v) => { setSteps(v); setHasUnsavedChanges(true); };
const handleSave = async () => { const handleSave = async () => {
if (!name.trim()) { setError("Name is required."); return; } if (!name.trim()) { setError("Name is required."); return; }
if (!steps.trim()) { setError("Steps are required."); return; } if (!pid.trim()) { setError("PID is required."); return; }
const stepsError = validateSteps(steps);
if (stepsError) { setError(stepsError); return; }
setSaving(true); setSaving(true);
setError(""); setError("");
setSuccessMsg(""); setSuccessMsg("");
try { try {
// Uniqueness check
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.id !== id && m.name.toLowerCase() === name.trim().toLowerCase());
if (dupName) { setError(`An archetype with the name "${name.trim()}" already exists.`); return; }
const dupPid = list.find((m) => m.id !== id && m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
if (dupPid) { setError(`An archetype with the PID "${pid.trim()}" already exists.`); return; }
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim() }; const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim() };
if (isEdit) { if (isEdit) {
await api.put(`/builder/melodies/${id}`, body); await api.put(`/builder/melodies/${id}`, body);
setSavedPid(pid.trim());
setHasUnsavedChanges(false);
setSuccessMsg("Saved."); setSuccessMsg("Saved.");
} else { } else {
const created = await api.post("/builder/melodies", body); const created = await api.post("/builder/melodies", body);
@@ -113,6 +166,7 @@ export default function BuilderForm() {
const handleBuildBinary = async () => { const handleBuildBinary = async () => {
if (!isEdit) { setError("Save the melody first before building."); return; } if (!isEdit) { setError("Save the melody first before building."); return; }
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before rebuilding."); return; }
setBuildingBinary(true); setBuildingBinary(true);
setError(""); setError("");
setSuccessMsg(""); setSuccessMsg("");
@@ -130,6 +184,7 @@ export default function BuilderForm() {
const handleBuildBuiltin = async () => { const handleBuildBuiltin = async () => {
if (!isEdit) { setError("Save the melody first before building."); return; } if (!isEdit) { setError("Save the melody first before building."); return; }
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before regenerating."); return; }
setBuildingBuiltin(true); setBuildingBuiltin(true);
setError(""); setError("");
setSuccessMsg(""); setSuccessMsg("");
@@ -201,12 +256,12 @@ export default function BuilderForm() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Name *</label> <label className="block text-sm font-medium mb-1" style={labelStyle}>Name *</label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} /> <input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID)</label> <label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID) *</label>
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} /> <input type="text" value={pid} onChange={(e) => handlePidChange(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
<p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier</p> <p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier. Must be unique.</p>
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
@@ -215,7 +270,7 @@ export default function BuilderForm() {
</div> </div>
<textarea <textarea
value={steps} value={steps}
onChange={(e) => setSteps(e.target.value)} onChange={(e) => handleStepsChange(e.target.value)}
rows={5} rows={5}
placeholder={"e.g. 1,2,2+1,1,2,3+1,0,3\n\n• Comma-separated values\n• Single bell: 1, 2, 3...\n• Multiple bells: 2+3+1\n• Silence: 0"} placeholder={"e.g. 1,2,2+1,1,2,3+1,0,3\n\n• Comma-separated values\n• Single bell: 1, 2, 3...\n• Multiple bells: 2+3+1\n• Silence: 0"}
className={inputClass} className={inputClass}
@@ -234,6 +289,9 @@ export default function BuilderForm() {
<h2 className="text-lg font-semibold mb-1" style={{ color: "var(--text-heading)" }}>Build</h2> <h2 className="text-lg font-semibold mb-1" style={{ color: "var(--text-heading)" }}>Build</h2>
<p className="text-sm mb-4" style={mutedStyle}> <p className="text-sm mb-4" style={mutedStyle}>
Save any changes above before building. Rebuilding will overwrite previous output. Save any changes above before building. Rebuilding will overwrite previous output.
{hasUnsavedChanges && (
<span style={{ color: "var(--warning, #f59e0b)", fontWeight: 600 }}> You have unsaved changes.</span>
)}
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -252,20 +310,21 @@ export default function BuilderForm() {
</div> </div>
<button <button
onClick={handleBuildBinary} onClick={handleBuildBinary}
disabled={buildingBinary} disabled={buildingBinary || hasUnsavedChanges}
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium" className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
title={hasUnsavedChanges ? "Save changes first" : undefined}
> >
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"} {buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
</button> </button>
{binaryUrl && ( {binaryUrl && (
<button <button
type="button" type="button"
onClick={() => downloadBinary(binaryUrl, `${name}.bsm`).catch((e) => setError(e.message))} onClick={() => downloadBinary(binaryUrl, `${savedPid}.bsm`).catch((e) => setError(e.message))}
className="block w-full text-center text-xs underline cursor-pointer" className="block w-full text-center text-xs underline cursor-pointer"
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }} style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
> >
Download {name}.bsm Download {savedPid}.bsm
</button> </button>
)} )}
</div> </div>
@@ -285,9 +344,10 @@ export default function BuilderForm() {
</div> </div>
<button <button
onClick={handleBuildBuiltin} onClick={handleBuildBuiltin}
disabled={buildingBuiltin} disabled={buildingBuiltin || hasUnsavedChanges}
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium" className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }} style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
title={hasUnsavedChanges ? "Save changes first" : undefined}
> >
{buildingBuiltin ? "Generating..." : progmemCode ? "Regenerate Code" : "Build Builtin Code"} {buildingBuiltin ? "Generating..." : progmemCode ? "Regenerate Code" : "Build Builtin Code"}
</button> </button>