getAcademicPerformance method

Future<List<SemesterScoreDto>> getAcademicPerformance()

Fetches academic performance (scores) for all semesters.

Scans the academic performance page for semester buttons and their corresponding score tables.

Parsing Strategy: Uses a sequential scan of both input (semester labels) and table (score data) elements to maintain correct association. This prevents offsets in cases where a semester button exists but its score table is missing (e.g., early in a new term).

Throws an Exception if the response contains "應用系統已中斷連線", indicating session expiry.

Implementation

Future<List<SemesterScoreDto>> getAcademicPerformance() async {
  final response = await _studentQueryDio.get(
    'QryScore.jsp',
    queryParameters: {'format': '-2'},
  );

  // Safeguard: Detect session termination to trigger auto-reauthentication
  if (response.data.toString().contains('應用系統已中斷連線')) {
    throw Exception('SessionExpired');
  }

  final document = parse(response.data);

  // Matches semester labels e.g., "114 學年度 第 1 學期"
  final semesterPattern = RegExp(r'(\d+)\s*學年度\s*第\s*(\d+)\s*學期');

  // Query both inputs and tables to preserve document order
  final elements = document.querySelectorAll("input[type='submit'], table");

  final results = <SemesterScoreDto>[];
  SemesterDto? currentSemester;
  bool hasAddedTableForCurrentSemester = false;

  for (final el in elements) {
    if (el.localName == 'input') {
      // Found a semester switcher: update current context
      final value = el.attributes['value'] ?? '';
      final match = semesterPattern.firstMatch(value);
      if (match != null) {
        currentSemester = (
          year: int.parse(match.group(1)!),
          term: int.parse(match.group(2)!),
        );
        hasAddedTableForCurrentSemester = false;
      }
    } else if (el.localName == 'table') {
      // Found a data table: verify context and prevent duplicate processing
      if (currentSemester == null || hasAddedTableForCurrentSemester) {
        continue;
      }

      final rows = el.querySelectorAll('tr');
      final scores = <ScoreDto>[];
      double? average;
      double? conduct;
      double? totalCredits;
      double? creditsPassed;
      String? note;

      bool isDataParsed = false;

      // Row 0 is the header; data rows have 9+ columns, summary rows have 2
      for (final row in rows.skip(1)) {
        final cells = row.querySelectorAll('th, td');

        if (cells.length >= 9) {
          isDataParsed = true;
          final scoreText = _parseCellText(cells[7]);
          final (scoreValue, status) = _parseScore(scoreText);
          scores.add((
            number: _parseCellText(cells[0]),
            courseCode: _parseCellText(cells[4]),
            score: scoreValue,
            status: status,
          ));
        } else if (cells.length == 2) {
          final label = cells[0].text;
          final value = _parseCellText(cells[1]);

          if (label.contains('Average')) {
            isDataParsed = true;
            average = double.tryParse(value ?? '');
          } else if (label.contains('Conduct')) {
            isDataParsed = true;
            conduct = double.tryParse(value ?? '');
          } else if (label.contains('Total Credits')) {
            isDataParsed = true;
            totalCredits = double.tryParse(value ?? '');
          } else if (label.contains('Credits Passed')) {
            isDataParsed = true;
            creditsPassed = double.tryParse(value ?? '');
          } else if (label.contains('Note')) {
            isDataParsed = true;
            note = value;
          }
        }
      }

      if (isDataParsed) {
        results.add((
          semester: currentSemester,
          scores: scores,
          average: average,
          conduct: conduct,
          totalCredits: totalCredits,
          creditsPassed: creditsPassed,
          note: note,
        ));
        hasAddedTableForCurrentSemester = true;
      }
    }
  }

  return results;
}