Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ function succeededResult() {
rehearsalPriority: "medium",
simplification: "Keep the sustained note centered; skip the ad-lib on the first pass.",
setupNote: "Watch the breath before the last line of the verse.",
overlapWarnings: [{ targetRoleId: "keys-right", severity: "high", description: "Clash" }],
manualOverrides: [
{
field: "harmony",
Expand Down
33 changes: 25 additions & 8 deletions apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
}
};

const getPriorityColor = (priority: string) => {
if (priority === "high") return "#ff4d4f";
if (priority === "medium") return "#faad14";
return "#52c41a";
};

const getPriorityIcon = (priority: string) => {
if (priority === "high") return "🚨";
if (priority === "medium") return "⚠️";
return "✅";
};

return (
<div style={{ marginTop: "24px" }}>
<h2>{t("sectionRoadmapTitle")}</h2>
Expand Down Expand Up @@ -72,15 +84,20 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
padding: "8px",
backgroundColor: "#f9f9f9",
borderRadius: "4px",
borderLeft: role.confidence.level === "low" ? "4px solid #ff4d4f" : "4px solid #d9d9d9"
borderLeft: `4px solid ${getPriorityColor(role.rehearsalPriority)}`
}}>
<div style={{ fontWeight: "bold", fontSize: "0.9em" }}>
{role.name}
{role.confidence.level === "low" && (
<span style={{ color: "#ff4d4f", fontSize: "0.8em", marginLeft: "4px" }}>
({t("confidenceLevelLow")})
</span>
)}
<div style={{ fontWeight: "bold", fontSize: "0.9em", display: "flex", justifyContent: "space-between" }}>
<span>
{role.name}
{role.confidence.level === "low" && (
<span style={{ color: "#ff4d4f", fontSize: "0.8em", marginLeft: "4px" }}>
({t("confidenceLevelLow")})
</span>
)}
</span>
<span title={`Priority: ${role.rehearsalPriority}`}>
{getPriorityIcon(role.rehearsalPriority)}
</span>
</div>
<div style={{ fontSize: "0.9em", marginTop: "4px" }}>
Chord: <strong
Expand Down
51 changes: 48 additions & 3 deletions apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useMemo } from "react";
import type { RehearsalSong } from "@bandscope/shared-types";
import { RoleSwitcher } from "./RoleSwitcher";
import { SectionRoadmap } from "./SectionRoadmap";
import { generateCueSheetCsv, generateChartSummaryJson, sanitizeFilename } from "../../lib/export";

interface WorkspaceProps {
song: RehearsalSong;
Expand All @@ -24,11 +25,55 @@ export function Workspace({ song, onSongUpdate }: WorkspaceProps) {
return Array.from(roleMap.entries()).map(([id, name]) => ({ id, name }));
}, [song]);

const handleExportCueSheet = () => {
const csv = generateCueSheetCsv(song);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${sanitizeFilename(song.title)}_cuesheet.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};

const handleExportChart = () => {
const json = generateChartSummaryJson(song);
const blob = new Blob([json], { type: "application/json;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${sanitizeFilename(song.title)}_chart.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};

return (
<div style={{ marginTop: "32px", padding: "24px", border: "1px solid #e8e8e8", borderRadius: "12px", backgroundColor: "#fff" }}>
<header>
<h2 style={{ fontSize: "1.8em", margin: "0 0 8px 0" }}>{song.title}</h2>
<p style={{ color: "#666", margin: "0 0 16px 0" }}>{song.exportSummary.headline}</p>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "16px" }}>
<div>
<h2 style={{ fontSize: "1.8em", margin: "0 0 8px 0" }}>{song.title}</h2>
<p style={{ color: "#666", margin: "0 0 16px 0" }}>{song.exportSummary?.headline || ""}</p>
</div>
<div style={{ display: "flex", gap: "8px" }}>
<button
type="button"
onClick={handleExportCueSheet}
style={{ padding: "6px 12px", cursor: "pointer", borderRadius: "4px", backgroundColor: "#fff", border: "1px solid #d9d9d9" }}
>
Export Cue Sheet (CSV)
</button>
<button
type="button"
onClick={handleExportChart}
style={{ padding: "6px 12px", cursor: "pointer", borderRadius: "4px", backgroundColor: "#fff", border: "1px solid #d9d9d9" }}
>
Export Chart (JSON)
</button>
</div>
</header>

<RoleSwitcher
Expand Down
76 changes: 76 additions & 0 deletions apps/desktop/src/lib/export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import { sanitizeFilename, escapeCsvField, generateCueSheetCsv, generateChartSummaryJson } from "./export";
import type { RehearsalSong } from "@bandscope/shared-types";

describe("export sanitization", () => {
it("sanitizes filename correctly", () => {
expect(sanitizeFilename("My Song /: Test")).toBe("My Song __ Test");
expect(sanitizeFilename("Valid-Name_123")).toBe("Valid-Name_123");
expect(sanitizeFilename("")).toBe("export");
});

it("escapes CSV fields to prevent formula injection", () => {
expect(escapeCsvField("=1+2")).toBe("'=1+2");
expect(escapeCsvField("+SUM(A1)")).toBe("'+SUM(A1)");
expect(escapeCsvField("-100")).toBe("'-100");
expect(escapeCsvField("@cmd")).toBe("'@cmd");
expect(escapeCsvField("Normal text")).toBe("Normal text");
expect(escapeCsvField("Text, with comma")).toBe('"Text, with comma"');
expect(escapeCsvField('Text with "quotes"')).toBe('"Text with ""quotes"""');
});
});

describe("export generation", () => {
const mockSong: RehearsalSong = {
id: "test",
title: "Test",
exportSummary: { format: "cue-sheet", headline: "Headline", focusSections: [] },
sections: [
{
id: "s1",
label: "verse",
groove: "swing",
confidence: { level: "high", source: "model", notes: "" },
roles: [
{
id: "r1",
name: "Bass",
roleType: "instrument",
harmony: { chord: "=Cmaj7", functionLabel: "", source: "model" },
cue: { kind: "count", value: "1, 2, 3" },
range: { lowestNote: "C2", highestNote: "C3" },
confidence: { level: "high", source: "model", notes: "" },
rehearsalPriority: "high",
simplification: "simple",
setupNote: "setup",
manualOverrides: []
}
]
}
]
};

it("generates cue sheet CSV securely", () => {
const csv = generateCueSheetCsv(mockSong);
const lines = csv.split("\n");
expect(lines[0]).toBe("Section,Groove,Role,Harmony,Cue,Priority,Notes");
expect(lines[1]).toBe('verse,swing,Bass,\'=Cmaj7,"1, 2, 3",high,setup | simple');
});

it("generates chart summary JSON", () => {
const jsonStr = generateChartSummaryJson(mockSong);
const parsed = JSON.parse(jsonStr);
expect(parsed.title).toBe("Test");
expect(parsed.sections[0].roles[0].chord).toBe("=Cmaj7");
});

it("generates chart summary JSON when headline is missing", () => {
const mockSongNoHeadline: RehearsalSong = {
...mockSong,
exportSummary: { format: "chart-summary", headline: "", focusSections: [] }
};
const jsonStr = generateChartSummaryJson(mockSongNoHeadline);
const parsed = JSON.parse(jsonStr);
expect(parsed.headline).toBe("");
});
});
66 changes: 66 additions & 0 deletions apps/desktop/src/lib/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { RehearsalSong } from "@bandscope/shared-types";

// Security notes:
// 1. Filename sanitization to prevent directory traversal or invalid characters.
// 2. CSV formula injection prevention (fields starting with =, +, -, @ must be prefixed with a single quote).

export function sanitizeFilename(title: string): string {
// Replace invalid filename characters with underscores
return title.replace(/[^a-zA-Z0-9_\-\s]/g, "_").trim() || "export";
}

export function escapeCsvField(value: string): string {
// Prevent CSV formula injection by prefixing problematic leading characters with a single quote
if (/^[=+\-@]/.test(value)) {
return `'${value}`;
}
// Enclose in double quotes if there's a comma, newline, or double quote
if (value.includes(",") || value.includes("\n") || value.includes('"')) {
const escapedQuotes = value.replace(/"/g, '""');
return `"${escapedQuotes}"`;
}
return value;
}

export function generateCueSheetCsv(song: RehearsalSong): string {
const headers = ["Section", "Groove", "Role", "Harmony", "Cue", "Priority", "Notes"];
const rows: string[] = [headers.join(",")];

for (const section of song.sections) {
for (const role of section.roles) {
const notes = [role.setupNote, role.simplification].filter(Boolean).join(" | ");
const row = [
section.label,
section.groove,
role.name,
role.harmony.chord,
role.cue.value,
role.rehearsalPriority,
notes
].map(escapeCsvField);

rows.push(row.join(","));
}
}

return rows.join("\n");
}

export function generateChartSummaryJson(song: RehearsalSong): string {
// Just a clean JSON stringification for now, focusing on the core chart data
const summary = {
title: song.title,
headline: song.exportSummary?.headline || "",
sections: song.sections.map(s => ({
label: s.label,
groove: s.groove,
roles: s.roles.map(r => ({
name: r.name,
chord: r.harmony.chord,
cue: r.cue.value,
priority: r.rehearsalPriority
}))
}))
};
return JSON.stringify(summary, null, 2);
}
2 changes: 1 addition & 1 deletion apps/desktop/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default defineConfig({
setupFiles: ["./src/setupTests.ts"],
coverage: {
provider: "v8",
include: ["src/App.tsx"],
include: ["src/App.tsx", "src/lib/export.ts"],
thresholds: {
lines: 100,
functions: 100,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
RoleType,
SectionRoleTopology,
)
from .priority import calculate_rehearsal_priority

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -69,7 +70,7 @@ def extract(
"source": "model",
"notes": "Watch the slide into the turnaround.",
},
"rehearsalPriority": RehearsalPriority.HIGH,
"rehearsalPriority": RehearsalPriority.HIGH, # to be replaced
"simplification": "Stay on roots if the chorus entrance gets muddy.",
"setupNote": "Keep the attack short so the verse breathes.",
"manualOverrides": [],
Expand Down Expand Up @@ -97,7 +98,7 @@ def extract(
"source": "model",
"notes": "Muddy frequency range, difficult to clearly separate from bass.",
},
"rehearsalPriority": RehearsalPriority.MEDIUM,
"rehearsalPriority": RehearsalPriority.MEDIUM, # to be replaced
"simplification": "Omit if bass is covering the lower register.",
"setupNote": "Use a darker patch to avoid clashing with right hand.",
"manualOverrides": [],
Expand All @@ -123,7 +124,7 @@ def extract(
"source": "model",
"notes": "Top note voicing may need a quick ear check.",
},
"rehearsalPriority": RehearsalPriority.HIGH,
"rehearsalPriority": RehearsalPriority.HIGH, # to be replaced
"simplification": "Drop top extension if the chorus turnaround feels busy.",
"setupNote": "Keep the patch bright enough to stay over the guitars.",
"manualOverrides": [],
Expand All @@ -146,7 +147,7 @@ def extract(
"source": "user",
"notes": "Singer confirmed the pickup phrasing in rehearsal notes.",
},
"rehearsalPriority": RehearsalPriority.MEDIUM,
"rehearsalPriority": RehearsalPriority.MEDIUM, # to be replaced
"simplification": "Keep sustained note centered; skip ad-lib on first pass.",
"setupNote": "Watch the breath before the last line of the verse.",
"manualOverrides": [
Expand All @@ -163,6 +164,9 @@ def extract(
"overlapWarnings": ["Melodic overlap: competing with Keyboard 1 Right Hand."],
}

for role in [bass_role, keys_left_role, keys_role, vocal_role]:
role["rehearsalPriority"] = calculate_rehearsal_priority(role)

active_roles = [bass_role]

# Simple part graph for bass
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Priority calculation for rehearsal roles."""

from __future__ import annotations

from .model import RehearsalPriority, RehearsalRole


def calculate_rehearsal_priority(role: RehearsalRole) -> RehearsalPriority:
"""Calculate the rehearsal priority for a role heuristically.

A role gets high priority if:
- It has low confidence
- It has overlap warnings
- It has manual overrides (indicating it was tricky enough for a human to edit)

A role gets medium priority if:
- It has medium confidence
- It has a setup note or simplification suggestion

Otherwise, it is low priority.

Args:
role: A dictionary representing a rehearsal role.

Returns:
The calculated RehearsalPriority.
"""
confidence = role.get("confidence", {}).get("level", "high")
overlap_warnings = role.get("overlapWarnings", [])
manual_overrides = role.get("manualOverrides", [])

if confidence == "low" or len(overlap_warnings) > 0 or len(manual_overrides) > 0:
return RehearsalPriority.HIGH

setup_note = role.get("setupNote", "")
simplification = role.get("simplification", "")

if confidence == "medium" or bool(setup_note) or bool(simplification):
return RehearsalPriority.MEDIUM

return RehearsalPriority.LOW
Loading
Loading