import 'app_settings.dart'; import 'course.dart'; import 'semester.dart'; import 'exam_schedule.dart'; import 'school_term.dart'; import 'school_identity.dart'; class ImportMetadata { const ImportMetadata({ required this.source, required this.importedAt, required this.parserVersion, }); final CourseSource source; final DateTime importedAt; final String parserVersion; } class AppSnapshot { AppSnapshot({ AppSettings? settings, List courses = const [], this.importMetadata, this.examSchedule, SchoolTerm? schoolTerm, this.calendarCleanupPending = false, SupportedSchool? selectedSchool, Map calendarEventIds = const {}, }) : settings = settings ?? AppSettings(), schoolTerm = schoolTerm ?? _legacyTerm(courses, examSchedule), selectedSchool = selectedSchool ?? schoolTerm?.school ?? _legacyTerm(courses, examSchedule)?.school ?? examSchedule?.school, courses = List.unmodifiable(courses), calendarEventIds = Map.unmodifiable(calendarEventIds); final AppSettings settings; final List courses; final ImportMetadata? importMetadata; final ExamSchedule? examSchedule; final SchoolTerm? schoolTerm; final Map calendarEventIds; final bool calendarCleanupPending; final SupportedSchool? selectedSchool; static void validateCourseSource(List courses, SchoolTerm? term) { if (term?.school != SupportedSchool.uestcUndergraduate && courses.any((c) => c.endPeriod > 11)) { throw const FormatException('第12节需要本科课表来源'); } for (final course in courses) { final under = RegExp(r'^uestc-undergraduate-(\d+)-') .firstMatch(course.id); if (under != null && (term?.school != SupportedSchool.uestcUndergraduate || under[1] != term?.id.toString())) { throw const FormatException('本科课程学校或学期不一致'); } if (term?.school == SupportedSchool.uestcUndergraduate && RegExp(r'^uestc-\d+-').hasMatch(course.id)) { throw const FormatException('本科课表包含研究生课程标识'); } } } static SchoolTerm? _legacyTerm(List courses, ExamSchedule? exams) { final ids = courses .map( (course) => RegExp(r'^uestc-(\d+)-').firstMatch(course.id)?.group(1), ) .whereType() .toSet(); if (exams?.school == SupportedSchool.uestcGraduate) ids.add(exams!.termId); if (ids.length != 1) return null; final id = int.tryParse(ids.single); return id != null && id >= 1 && id <= 9999 ? SchoolTerm(id) : null; } AppSnapshot copyWith({ AppSettings? settings, List? courses, ImportMetadata? importMetadata, Map? calendarEventIds, bool clearMetadata = false, ExamSchedule? examSchedule, bool clearExams = false, SchoolTerm? schoolTerm, bool? calendarCleanupPending, SupportedSchool? selectedSchool, }) => AppSnapshot( settings: settings ?? this.settings, courses: courses ?? this.courses, importMetadata: clearMetadata ? null : importMetadata ?? this.importMetadata, examSchedule: clearExams ? null : examSchedule ?? this.examSchedule, schoolTerm: schoolTerm ?? this.schoolTerm, calendarEventIds: calendarEventIds ?? this.calendarEventIds, calendarCleanupPending: calendarCleanupPending ?? this.calendarCleanupPending, selectedSchool: selectedSchool ?? this.selectedSchool, ); Map toJson() => { 'schemaVersion': 1, 'examSchedule': examSchedule?.toJson(), 'schoolTerm': schoolTerm?.toJson(), 'selectedSchoolId': selectedSchool?.id, 'courses': courses.map((c) => c.toJson()).toList(), 'settings': { 'semester': settings.semester == null ? null : { 'startDate': settings.semester!.startDate .toIso8601String() .substring(0, 10), 'totalWeeks': settings.semester!.totalWeeks, }, 'remindersEnabled': settings.remindersEnabled, 'reminderMinutes': settings.reminderMinutes, 'reminderOffsets': settings.reminderOffsets, 'colorMode': settings.colorMode.name, 'singleColorArgb': settings.singleColorArgb, 'calendarEnabled': settings.calendarEnabled, 'calendarId': settings.calendarId, 'courseHeightScale': settings.courseHeightScale, }, 'importMetadata': importMetadata == null ? null : { 'source': importMetadata!.source.name, 'importedAt': importMetadata!.importedAt.toUtc().toIso8601String(), 'parserVersion': importMetadata!.parserVersion, }, 'calendarEventIds': calendarEventIds, 'calendarCleanupPending': calendarCleanupPending, }; static AppSnapshot fromJson(Map json) { try { if (json['schemaVersion'] != 1) throw const FormatException('不支持的保存版本'); final values = (json['courses'] as List) .map((c) => Course.fromJson(Map.from(c as Map))) .toList(); if (values.map((c) => c.id).toSet().length != values.length || values.any((c) => c.source == CourseSource.demo)) { throw const FormatException('保存课表无效'); } final s = Map.from(json['settings'] as Map); final rawOffsets = s.containsKey('reminderOffsets') ? s['reminderOffsets'] : [s['reminderMinutes']]; if (rawOffsets is! List || rawOffsets.isEmpty || rawOffsets.any( (v) => v is! int || !AppSettings.allowedReminderMinutes.contains(v), )) { throw const FormatException('提醒时间无效'); } final offsets = rawOffsets.cast(); final color = s['singleColorArgb'] as int; if (color < 0 || color > 0xFFFFFFFF) throw const FormatException('颜色无效'); final rawHeight = s.containsKey('courseHeightScale') ? s['courseHeightScale'] : AppSettings.defaultCourseHeightScale; if (rawHeight is! num || !rawHeight.isFinite || rawHeight < AppSettings.minCourseHeightScale || rawHeight > AppSettings.maxCourseHeightScale) { throw const FormatException('课程条长度无效'); } final sem = s['semester']; final term = sem == null ? null : Semester( startDate: DateTime.parse(sem['startDate'] as String), totalWeeks: sem['totalWeeks'] as int, ); final metadata = json['importMetadata']; final snapshot = AppSnapshot( selectedSchool: json['selectedSchoolId'] == null ? null : SupportedSchool.fromId(json['selectedSchoolId'] as String), schoolTerm: json['schoolTerm'] == null ? null : SchoolTerm.fromJson( Map.from(json['schoolTerm'] as Map), ), examSchedule: json['examSchedule'] == null ? null : ExamSchedule.fromJson( Map.from(json['examSchedule'] as Map), ), calendarCleanupPending: (json['calendarCleanupPending'] ?? false) as bool, courses: values, settings: AppSettings( semester: term, remindersEnabled: s['remindersEnabled'] as bool, reminderOffsets: offsets, colorMode: ColorMode.values.byName(s['colorMode'] as String), singleColorArgb: color, calendarEnabled: s['calendarEnabled'] as bool, calendarId: s['calendarId'] as String?, courseHeightScale: rawHeight.toDouble(), ), importMetadata: metadata == null ? null : ImportMetadata( source: CourseSource.values.byName( metadata['source'] as String, ), importedAt: DateTime.parse(metadata['importedAt'] as String), parserVersion: metadata['parserVersion'] as String, ), calendarEventIds: (json['calendarEventIds'] as Map).map( (k, v) => MapEntry(k as String, v as String), ), ); validateCourseSource(values, snapshot.schoolTerm); return snapshot; } catch (_) { throw const FormatException('保存文件内容无效,未覆盖已有数据'); } } }