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 headersRaw = _parseCsvLine(lines.first); final headers = headersRaw .map((e) => e.trim()) .toList(); // 한글 헤더를 영문 키로 정규화하기 위한 매핑 String _normalizeHeader(String raw) { final v = raw.trim().toLowerCase(); // 공백 제거 버전도 별도로 준비 final vn = v.replaceAll(' ', ''); // 한국어 -> 영문 매핑 if (v == '제목') return 'title'; if (v == '시작일') return 'startDate'; if (v == '종료일') return 'endDate'; if (v == '설명') return 'description'; if (v == '장소') return 'location'; if (v == '상태') return 'status'; if (v == '유형') return 'type'; if (v == '게임 수' || vn == '게임수') return 'gameCount'; if (v == '참가비') return 'participantFee'; if (v == '신청마감' || v == '신청 마감') return 'registrationDeadline'; if (v == '최대 참가자' || vn == '최대참가자') return 'maxParticipants'; // 기존 영문 키들 정규화 if (vn == 'maxparticipants') return 'maxParticipants'; if (vn == 'startdate') return 'startDate'; if (vn == 'enddate') return 'endDate'; return raw.trim(); } final normalizedHeaders = headers.map(_normalizeHeader).toList(); if (!normalizedHeaders.contains('title') || !normalizedHeaders.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 < normalizedHeaders.length && j < row.length; j++) { final header = normalizedHeaders[j]; var value = row[j].trim(); if (header == 'startDate' || header == 'endDate') { // 날짜 문자열은 그대로 전달하고, 화면/서버 측에서 ISO 변환/검증 처리 // 비어있으면 startDate는 필수, endDate는 null if (header == 'startDate') { event['startDate'] = value.isEmpty ? DateTime.now().toIso8601String() : value; } else { event['endDate'] = value.isEmpty ? null : value; } } else { event[header] = 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'); } }