getSyllabus method
- required String courseNumber,
- required String teacherId,
- 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,
);
}