Skip to content
Closed
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: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- 파이썬 분석 엔진에 CSV 형식의 큐시트 추출(csv_export) 기능을 추가하여, 곡 페이로드에서 구조화된 CSV 텍스트를 안정적으로 생성할 수 있도록 구현.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Compact rehearsal-artifact export builders for the analysis engine."""

from bandscope_analysis.exports.chart import build_chart_text, build_cue_sheet_rows
from bandscope_analysis.exports.csv_export import build_csv_text

__all__ = ["build_chart_text", "build_cue_sheet_rows"]
__all__ = ["build_chart_text", "build_cue_sheet_rows", "build_csv_text"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""CSV cue-sheet export builder for the analysis engine.

Turns the ``RehearsalSong`` dict built by :mod:`bandscope_analysis.api` into
a valid CSV string representation using standard Python libraries.

Security Notes:
- Pure dict-to-string transformation: no file, network, or process I/O.
- Never reads source-path fields and never emits filesystem paths.
- Mitigates CSV formula injection risks by escaping leading problematic characters.
- Safe failure: ``None``, empty, or malformed input yields a CSV with only headers.
"""

from __future__ import annotations

import csv
import io
import re
from collections.abc import Mapping

from bandscope_analysis.exports.chart import build_cue_sheet_rows

__all__ = ["build_csv_text"]

_FORMULA_INJECTION_PATTERN = re.compile(r"^[\s\uFEFF\xA0]*[=+\-@\t\r\n]")


def _escape_csv_field(value: str) -> str:
"""Escape a field to prevent CSV formula injection."""
if _FORMULA_INJECTION_PATTERN.match(value):
return f"'{value}"
return value


def build_csv_text(song: Mapping[str, object] | None) -> str:
"""Build a CSV string representation of a song's cue sheet.

Extracts rows using ``build_cue_sheet_rows`` and writes them to an in-memory
string buffer using the standard library's ``csv.writer``. Ensures robust handling
of missing data and potential injection characters.
"""
output = io.StringIO()
writer = csv.writer(output, dialect="excel")

# Write headers
writer.writerow(
[
"Section",
"Start",
"End",
"Cue",
"Roles",
]
)

rows = build_cue_sheet_rows(song)
for row in rows:
writer.writerow(
[
_escape_csv_field(row.get("section", "")),
_escape_csv_field(row.get("start", "")),
_escape_csv_field(row.get("end", "")),
_escape_csv_field(row.get("cue", "")),
_escape_csv_field(", ".join(row.get("roles", []))),
]
Comment on lines +58 to +64
)

return output.getvalue()
103 changes: 103 additions & 0 deletions services/analysis-engine/tests/test_csv_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Tests for the CSV cue-sheet export builder."""

from __future__ import annotations

import csv
import io

from bandscope_analysis.exports import build_csv_text


class TestCSVExport:
"""Tests for ``build_csv_text``."""

def test_build_csv_text_success(self) -> None:
"""Valid inputs produce correct CSV text."""
song = {
"title": "Test Song",
"sections": [
{
"label": "intro",
"timeRange": {"start": 0, "end": 10},
"roles": [
{
"id": "role1",
"name": "Guitar",
"cue": {"value": "Play riff"},
},
{
"id": "role2",
"name": "Bass",
"cue": {"value": "Root notes"},
},
],
"partGraph": [
{"role_id": "role1", "is_active": True},
{"role_id": "role2", "is_active": True},
],
}
],
}

csv_text = build_csv_text(song)
reader = csv.reader(io.StringIO(csv_text))
rows = list(reader)

assert len(rows) == 2
assert rows[0] == ["Section", "Start", "End", "Cue", "Roles"]
assert rows[1] == ["intro", "00:00", "00:10", "Play riff; Root notes", "Guitar, Bass"]

def test_build_csv_text_empty_input(self) -> None:
"""None or empty mapping yields a CSV with just headers."""
csv_text_none = build_csv_text(None)
csv_text_empty = build_csv_text({})

expected = "Section,Start,End,Cue,Roles\r\n"
assert csv_text_none == expected
assert csv_text_empty == expected

def test_build_csv_text_injection_mitigation(self) -> None:
"""Formula injection characters are escaped."""
song = {
"sections": [
{
"label": "=cmd|' /C calc'!A0",
"timeRange": {"start": 5, "end": 15},
"roles": [
{
"id": "r1",
"name": "+hack",
"cue": {"value": "-injection"},
}
],
"partGraph": [
{"role_id": "r1", "is_active": True},
],
}
]
}
csv_text = build_csv_text(song)
reader = csv.reader(io.StringIO(csv_text))
rows = list(reader)

assert len(rows) == 2
assert rows[1][0] == "'=cmd|' /C calc'!A0"
assert rows[1][3] == "'-injection"
assert rows[1][4] == "'+hack"

def test_build_csv_text_missing_time_range(self) -> None:
"""Sections without a valid time range are omitted."""
song = {
"sections": [
{
"label": "intro",
"timeRange": "invalid",
}
]
}
csv_text = build_csv_text(song)
reader = csv.reader(io.StringIO(csv_text))
rows = list(reader)

assert len(rows) == 1
assert rows[0] == ["Section", "Start", "End", "Cue", "Roles"]
Loading