diff --git a/CHANGELOG.md b/CHANGELOG.md index eea69689..d080b2c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/package-lock.json b/package-lock.json index 1bd6edd6..fd86f87f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4422,9 +4422,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/services/analysis-engine/src/bandscope_analysis/exports/__init__.py b/services/analysis-engine/src/bandscope_analysis/exports/__init__.py index e239e5fe..1978df33 100644 --- a/services/analysis-engine/src/bandscope_analysis/exports/__init__.py +++ b/services/analysis-engine/src/bandscope_analysis/exports/__init__.py @@ -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"] diff --git a/services/analysis-engine/src/bandscope_analysis/exports/csv_export.py b/services/analysis-engine/src/bandscope_analysis/exports/csv_export.py new file mode 100644 index 00000000..de9612ef --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/exports/csv_export.py @@ -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", []))), + ] + ) + + return output.getvalue() diff --git a/services/analysis-engine/tests/test_csv_export.py b/services/analysis-engine/tests/test_csv_export.py new file mode 100644 index 00000000..bb6870b0 --- /dev/null +++ b/services/analysis-engine/tests/test_csv_export.py @@ -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"]