getSyllabus method

  1. @override
Future<SyllabusDto?> getSyllabus({
  1. required String courseNumber,
  2. required String teacherId,
  3. required SyllabusLanguage language,
})
override

Fetches the detailed syllabus for a course offering in language.

Returns every source-page section in order, or null when the authoring teacher hasn't submitted one yet (the page shows 尚未登錄).

The courseNumber should be a course offering number (e.g., "346774"), and teacherId is the authoring teacher's ID (one of the teachers IDs of a ScheduleDto).

Throws an Exception if the syllabus tables are not found.

Implementation

@override
Future<SyllabusDto?> getSyllabus({
  required String courseNumber,
  required String teacherId,
  required SyllabusLanguage language,
}) async {
  final response = await _courseDio.get(
    switch (language) {
      .zhTw => 'tw/ShowSyllabus.jsp',
      .enUs => 'en/ShowSyllabus.jsp',
    },
    queryParameters: {'snum': courseNumber, 'code': teacherId},
  );

  final document = parse(response.data);

  // An unsubmitted syllabus is a successful empty result in either page
  // language, not a parser failure.
  const notSubmittedMarkers = {
    '尚未登錄',
    'Not registered',
    'Not submitted',
  };
  final notSubmitted = document
      .querySelectorAll('font')
      .any((font) => notSubmittedMarkers.contains(font.text.trim()));
  if (notSubmitted) return null;

  final tables = document.querySelectorAll('table');
  if (tables.length < 2) {
    throw Exception('Syllabus tables not found.');
  }

  // Table 0: Header table (課程基本資料). Pair labels with values so
  // inserted columns cannot shift the metadata fields.
  final headerRows = tables[0].querySelectorAll('tr');
  final headerLabels = _directTableCells(headerRows[0]);
  final headerValues = _directTableCells(headerRows[1]);
  final header = <String, String?>{};
  for (
    var index = 0;
    index < headerLabels.length && index < headerValues.length;
    index++
  ) {
    final label = headerLabels[index].text.trim();
    if (label.isNotEmpty) {
      header[label] = _parseCellText(headerValues[index]);
    }
  }

  final labels = switch (language) {
    .zhTw => (type: '修', enrolled: '人', withdrawn: '撤'),
    .enUs => (
      type: 'Required/Elective',
      enrolled: 'Student NO.',
      withdrawn: 'Withdraw',
    ),
  };
  final typeSymbol = header[labels.type];
  final type = CourseType.values.firstWhereOrNull(
    (type) => type.symbol == typeSymbol,
  );
  final enrolled = int.tryParse(header[labels.enrolled] ?? '');
  final withdrawn = int.tryParse(header[labels.withdrawn] ?? '');

  // Table 1: Syllabus table (教學大綱與進度).
  String? email;
  DateTime? lastUpdated;
  final sections = <SyllabusSectionDto>[];
  for (final row in _directTableRows(tables[1])) {
    final cells = _directTableCells(row);
    if (cells.length < 2) continue;

    final title = cells[0].text.trim();
    if (title.isEmpty) continue;

    final content = _parseCellText(cells[1]);
    if (const {'教師姓名', 'Instructor'}.contains(title)) continue;

    final lowerTitle = title.toLowerCase();
    if (lowerTitle == 'email' || lowerTitle == 'e-mail') {
      email = content;
      continue;
    }
    if (const {'最後更新時間', 'Last Updated'}.contains(title)) {
      lastUpdated = DateTime.tryParse(content ?? '');
      continue;
    }

    final nestedTables = cells[1].children.where(
      (element) => element.localName == 'table',
    );
    if (nestedTables.isEmpty) {
      sections.add((title: title, content: content));
    } else {
      for (final table in nestedTables) {
        sections.addAll(_flattenSyllabusTable(table, parentTitle: title));
      }
    }
  }

  // Empty forms retain static boilerplate such as the materials copyright
  // notice, but only submitted syllabuses have a last-updated timestamp.
  if (lastUpdated == null) return null;

  return (
    type: type,
    enrolled: enrolled,
    withdrawn: withdrawn,
    email: email,
    lastUpdated: lastUpdated,
    sections: sections,
  );
}