import 'dart:convert'; /// CSV 파일에서 이벤트 데이터를 파싱하는 유틸리티 클래스 /// 반환 형식은 EventExcelParser와 동일하게 맞춥니다. class EventCsvParser { /// CSV 바이트에서 이벤트 데이터를 추출합니다. /// 헤더는 첫 줄로 가정하며, 필수 컬럼은 title, startDate 입니다. /// 반환값 키: /// - processedRows, validEvents, invalidRows, invalidRowIndices, error static Map parseCsvBytes(List bytes) { final content = utf8.decode(bytes, allowMalformed: true); final lines = _splitLines(content) .where((e) => e.trim().isNotEmpty) .toList(); if (lines.isEmpty) { return { 'processedRows': 0, 'validEvents': >[], 'invalidRows': 0, 'invalidRowIndices': [], 'error': 'CSV 파일에 데이터가 없습니다.' }; } final headers = _parseCsvLine(lines.first) .map((e) => e.trim().toLowerCase()) .toList(); if (!headers.contains('title') || !headers.contains('startdate')) { return { 'processedRows': 0, 'validEvents': >[], 'invalidRows': 0, 'invalidRowIndices': [], 'error': '필수 필드(title, startDate)가 없습니다.' }; } final events = >[]; int processedRows = 0; int invalidRows = 0; final invalidRowIndices = []; final invalidRowReasons = {}; for (var i = 1; i < lines.length; i++) { final row = _parseCsvLine(lines[i]); if (row.isEmpty || row.every((c) => c.trim().isEmpty)) { // 빈 행은 스킵 continue; } processedRows++; final event = {}; for (var j = 0; j < headers.length && j < row.length; j++) { final header = headers[j]; var value = row[j].trim(); // 헤더 정규화 var normalizedHeader = header; if (header == 'maxparticipants') normalizedHeader = 'maxParticipants'; if (header == 'startdate') normalizedHeader = 'startDate'; if (header == 'enddate') normalizedHeader = 'endDate'; if (normalizedHeader == 'startDate' || normalizedHeader == 'endDate') { // 날짜 문자열은 그대로 전달하고, 화면/서버 측에서 ISO 변환/검증 처리 // 비어있으면 startDate는 필수, endDate는 null if (normalizedHeader == 'startDate') { event['startDate'] = value.isEmpty ? DateTime.now().toIso8601String() : value; } else { event['endDate'] = value.isEmpty ? null : value; } } else { event[normalizedHeader] = value.isEmpty ? null : value; } } final titleVal = (event['title'] ?? '').toString().trim(); final startVal = (event['startDate'] ?? '').toString().trim(); if (titleVal.isNotEmpty && startVal.isNotEmpty) { events.add(event); } else { invalidRows++; final rowNo = i + 1; invalidRowIndices.add(rowNo); // 헤더가 1행이므로 +1 final reasons = []; if (titleVal.isEmpty) reasons.add('title 누락'); if (startVal.isEmpty) reasons.add('startDate 누락'); invalidRowReasons[rowNo] = reasons.join(', '); } } return { 'processedRows': processedRows, 'validEvents': events, 'invalidRows': invalidRows, 'invalidRowIndices': invalidRowIndices, 'invalidRowReasons': invalidRowReasons, 'error': events.isEmpty ? '유효한 이벤트 데이터가 없습니다.' : null }; } // 간단한 CSV 라인 파서: 따옴표 포함 필드와 이스케이프된 따옴표("") 처리 static List _parseCsvLine(String line) { final result = []; final buf = StringBuffer(); bool inQuotes = false; for (int i = 0; i < line.length; i++) { final ch = line[i]; if (inQuotes) { if (ch == '"') { // 이스케이프된 따옴표 if (i + 1 < line.length && line[i + 1] == '"') { buf.write('"'); i++; } else { inQuotes = false; } } else { buf.write(ch); } } else { if (ch == ',') { result.add(buf.toString()); buf.clear(); } else if (ch == '"') { inQuotes = true; } else { buf.write(ch); } } } result.add(buf.toString()); return result; } static List _splitLines(String content) { return content.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n'); } }