diff --git a/App/AppContainer.swift b/App/AppContainer.swift index c41ebc9..77501eb 100644 --- a/App/AppContainer.swift +++ b/App/AppContainer.swift @@ -19,6 +19,7 @@ final class AppContainer { let studIPService: any StudIPService let studIPAnnouncementService: any StudIPAnnouncementService let announcementRepository: any AnnouncementRepository + let studIPScheduleService: any StudIPScheduleService let studIPAccountStore: any StudIPAccountStore let courseFileRepository: any CourseFileRepository let backupArchiver: any BackupArchiving @@ -51,6 +52,7 @@ final class AppContainer { let studIPClient = StudIPJSONAPIClient() studIPService = studIPClient studIPAnnouncementService = studIPClient + studIPScheduleService = studIPClient if inMemory { preferences = InMemoryStudyPreferences() studIPAccountStore = InMemoryStudIPAccountStore() @@ -161,7 +163,9 @@ final class AppContainer { importer: ImportSlideDocumentUseCase(repository: slideRepository, storage: fileStorage, inspector: pdfInspector), fileRepository: courseFileRepository, storage: fileStorage, - announcementSync: SyncAnnouncementsUseCase(service: studIPAnnouncementService, repository: announcementRepository) + announcementSync: SyncAnnouncementsUseCase(service: studIPAnnouncementService, repository: announcementRepository), + timetableSync: SyncStudIPTimetableUseCase(service: studIPScheduleService, repository: studyCalendarRepository), + settings: preferences ) } diff --git a/App/StudyAppApp.swift b/App/StudyAppApp.swift index 64c29eb..356b5f0 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -32,6 +32,7 @@ struct StudyAppApp: App { let studIP = container.makeStudIPViewModel() let announcements = container.makeAnnouncementsViewModel() studIP.onAnnouncementsSynced = { announcements.load() } + studIP.onTimetableSynced = { calendar.load() } // Neu verknüpfte Veranstaltungen und geladene Folien sofort in Seitenleiste und Startseite zeigen. studIP.onSlidesChanged = { modules.load() diff --git a/Data/Preferences/UserDefaultsStudyPreferences.swift b/Data/Preferences/UserDefaultsStudyPreferences.swift index cf5e41f..bd76d46 100644 --- a/Data/Preferences/UserDefaultsStudyPreferences.swift +++ b/Data/Preferences/UserDefaultsStudyPreferences.swift @@ -12,6 +12,7 @@ final class UserDefaultsStudyPreferences: StudySettingsStore, ReadingProgressSto static let ignoredCourseKeys = "moduleDetection.ignoredKeys" static let lastAutoOpenedLecture = "autopilot.lastHandledLecture" static let automaticBackups = "backup.automatic" + static let importsStudIPTimetable = "studip.importsTimetable" static let notificationSettings = "notifications.settings" static func lastPage(_ documentID: UUID) -> String { "reading.page.\(documentID.uuidString)" } static func lastDocument(_ moduleID: UUID) -> String { "reading.document.\(moduleID.uuidString)" } @@ -60,6 +61,11 @@ final class UserDefaultsStudyPreferences: StudySettingsStore, ReadingProgressSto set { defaults.set(newValue, forKey: Key.automaticBackups) } } + var importsStudIPTimetable: Bool { + get { bool(Key.importsStudIPTimetable, default: false) } + set { defaults.set(newValue, forKey: Key.importsStudIPTimetable) } + } + /// Stored as JSON; unreadable or missing data falls back to the defaults. var notificationSettings: NotificationSettings { get { diff --git a/Data/StudIP/StudIPJSONAPIClient+Schedule.swift b/Data/StudIP/StudIPJSONAPIClient+Schedule.swift new file mode 100644 index 0000000..1228bee --- /dev/null +++ b/Data/StudIP/StudIPJSONAPIClient+Schedule.swift @@ -0,0 +1,8 @@ +import Foundation + +extension StudIPJSONAPIClient: StudIPScheduleService { + func courseDates(inCourse courseID: String, _ credentials: StudIPCredentials) async throws -> [StudIPCourseDate] { + try await allResources("courses/\(courseID)/events", credentials) + .compactMap(StudIPJSONAPIParser.courseDate) + } +} diff --git a/Data/StudIP/StudIPJSONAPIParser.swift b/Data/StudIP/StudIPJSONAPIParser.swift index 8c3262f..b14e54d 100644 --- a/Data/StudIP/StudIPJSONAPIParser.swift +++ b/Data/StudIP/StudIPJSONAPIParser.swift @@ -121,6 +121,25 @@ enum StudIPJSONAPIParser { ) } + /// A course date (`course-events` resource). Older Stud.IP versions call the times `begin`/`end` + /// or deliver them as Unix timestamps; the location may be `location` or `room`. + static func courseDate(_ resource: Resource) -> StudIPCourseDate? { + guard let id = identifier(resource) else { return nil } + let attributes = resource["attributes"] as? [String: Any] ?? [:] + guard let start = date(attributes["start"]) ?? date(attributes["begin"]) else { return nil } + let end = date(attributes["end"]) ?? start + let categories = string(attributes["categories"]) ?? "" + return StudIPCourseDate( + id: id, + title: string(attributes["title"]) ?? categories, + details: StudIPHTML.plainText(string(attributes["description"]) ?? ""), + location: string(attributes["location"]) ?? string(attributes["room"]) ?? "", + start: start, + end: end, + isCancelled: (attributes["is-cancelled"] as? Bool) ?? false + ) + } + // MARK: Hilfen private static func object(_ data: Data) throws -> [String: Any] { diff --git a/Domain/Entities/CalendarEvent.swift b/Domain/Entities/CalendarEvent.swift index d6a2595..47e8805 100644 --- a/Domain/Entities/CalendarEvent.swift +++ b/Domain/Entities/CalendarEvent.swift @@ -25,6 +25,8 @@ struct CalendarSource: Identifiable, Hashable, Codable, Sendable { enum CalendarSourceKind: String, Codable, Sendable { case file case subscription + /// Dates of linked courses, loaded from the Stud.IP JSON:API during a Stud.IP sync. + case studIP } // MARK: - Zeitlogik diff --git a/Domain/Repositories/StudIPScheduleService.swift b/Domain/Repositories/StudIPScheduleService.swift new file mode 100644 index 0000000..05206af --- /dev/null +++ b/Domain/Repositories/StudIPScheduleService.swift @@ -0,0 +1,18 @@ +import Foundation + +/// A date of a Stud.IP course (lecture, exercise, exam …). +struct StudIPCourseDate: Hashable, Sendable { + let id: String + var title: String + var details: String + var location: String + var start: Date + var end: Date + var isCancelled: Bool +} + +/// Port for reading the dates of a course from Stud.IP. +@MainActor +protocol StudIPScheduleService { + func courseDates(inCourse courseID: String, _ credentials: StudIPCredentials) async throws -> [StudIPCourseDate] +} diff --git a/Domain/Repositories/StudyPreferences.swift b/Domain/Repositories/StudyPreferences.swift index daf86ff..659cd58 100644 --- a/Domain/Repositories/StudyPreferences.swift +++ b/Domain/Repositories/StudyPreferences.swift @@ -18,6 +18,8 @@ protocol StudySettingsStore: AnyObject { var lastAutoOpenedLecture: String? { get set } /// Create a weekly backup in the app's backup folder. var automaticBackups: Bool { get set } + /// Import the dates of linked Stud.IP courses into the calendar during a Stud.IP sync. + var importsStudIPTimetable: Bool { get set } var notificationSettings: NotificationSettings { get set } } @@ -44,6 +46,7 @@ final class InMemoryStudyPreferences: StudySettingsStore, ReadingProgressStore { var ignoredCourseKeys: Set = [] var lastAutoOpenedLecture: String? var automaticBackups = true + var importsStudIPTimetable = false var notificationSettings = NotificationSettings() private var pages: [UUID: Int] = [:] private var documents: [UUID: UUID] = [:] diff --git a/Domain/UseCases/StudIPTimetableUseCases.swift b/Domain/UseCases/StudIPTimetableUseCases.swift new file mode 100644 index 0000000..3a9eded --- /dev/null +++ b/Domain/UseCases/StudIPTimetableUseCases.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Imports the dates of all linked, active modules into one calendar source „Stud.IP“, so the timetable +/// works without a calendar link. +/// +/// Events are linked to their module right away and get stable IDs from the Stud.IP date ID, so a +/// refresh updates moved dates and removes deleted ones instead of duplicating them. Only dates from +/// `pastWindow` ago to `futureWindow` ahead are kept. +@MainActor +struct SyncStudIPTimetableUseCase { + static let sourceName = "Stud.IP" + static let pastWindow: TimeInterval = 30 * 86_400 + static let futureWindow: TimeInterval = 240 * 86_400 + + let service: any StudIPScheduleService + let repository: any StudyCalendarRepository + var now: () -> Date = { .now } + + /// - Returns: Modules whose dates could not be loaded; the others are still imported. + @discardableResult + func callAsFunction(modules: [StudyModule], credentials: StudIPCredentials) async throws -> [String] { + let linked = modules.filter { $0.studIPCourseID != nil && !$0.isArchived } + let existing = try repository.sources().first { $0.kind == .studIP } + guard !linked.isEmpty || existing != nil else { return [] } + + let reference = now() + let window = reference.addingTimeInterval(-Self.pastWindow)...reference.addingTimeInterval(Self.futureWindow) + let source = CalendarSource( + id: existing?.id ?? UUID(), + name: Self.sourceName, + url: nil, + lastSyncedAt: reference, + kind: .studIP + ) + var events: [StudyCalendarEvent] = [] + var failures: [String] = [] + var failedModules: Set = [] + for module in linked { + guard let courseID = module.studIPCourseID else { continue } + do { + let dates = try await service.courseDates(inCourse: courseID, credentials) + events += dates + .filter { window.contains($0.start) } + .map { Self.event(from: $0, module: module, sourceID: source.id) } + } catch StudIPError.sessionExpired { + throw StudIPError.sessionExpired + } catch { + failures.append("\(module.name): \(error.localizedDescription)") + failedModules.insert(module.id) + } + } + // A failing module must not wipe its dates: keep the stored ones of those modules. + if !failedModules.isEmpty { + events += try repository.events().filter { event in + event.sourceID == source.id && event.moduleID.map(failedModules.contains) == true + } + } + try repository.save(source: source) + try repository.replaceEvents(forSource: source.id, with: events) + return failures + } + + static func event(from date: StudIPCourseDate, module: StudyModule, sourceID: UUID) -> StudyCalendarEvent { + let key = "studip:\(date.id)" + var title = date.title.isEmpty || date.title == module.name ? module.name : "\(module.name) – \(date.title)" + if date.isCancelled { title = "Entfällt: " + title } + return StudyCalendarEvent( + id: UUID.stable(from: key), + externalID: key, + sourceID: sourceID, + title: title, + details: date.details, + location: date.location, + startDate: date.start, + endDate: max(date.end, date.start), + moduleID: module.id + ) + } +} diff --git a/Presentation/Calendar/CalendarSourcesSheet.swift b/Presentation/Calendar/CalendarSourcesSheet.swift index f97accd..62cd209 100644 --- a/Presentation/Calendar/CalendarSourcesSheet.swift +++ b/Presentation/Calendar/CalendarSourcesSheet.swift @@ -112,9 +112,17 @@ struct CalendarSourcesSheet: View { } } + private static func symbol(for kind: CalendarSourceKind) -> String { + switch kind { + case .file: "doc.text" + case .subscription: "link" + case .studIP: "building.columns" + } + } + private func sourceRow(_ source: CalendarSource) -> some View { HStack(spacing: 10) { - Image(systemName: source.kind == .subscription ? "link" : "doc.text") + Image(systemName: Self.symbol(for: source.kind)) .foregroundStyle(Theme.textSecondary) .frame(width: 20) VStack(alignment: .leading, spacing: 2) { diff --git a/Presentation/Settings/ScheduleSettingsPane.swift b/Presentation/Settings/ScheduleSettingsPane.swift index e4adff4..7a58721 100644 --- a/Presentation/Settings/ScheduleSettingsPane.swift +++ b/Presentation/Settings/ScheduleSettingsPane.swift @@ -7,12 +7,15 @@ struct ScheduleSettingsPane: View { @State private var autoCreateModules = true @State private var autoRefreshCalendars = true @State private var ignoredCount = 0 + @State private var importsStudIPTimetable = false var body: some View { SettingsForm(pane: .schedule) { Section { Toggle("Module automatisch aus dem Kalender anlegen", isOn: $autoCreateModules) Toggle("Abonnierte Kalender automatisch aktualisieren (stündlich)", isOn: $autoRefreshCalendars) + Toggle("Termine verknüpfter Stud.IP-Veranstaltungen übernehmen", isOn: $importsStudIPTimetable) + .help("Lädt die Termine beim Stud.IP-Abgleich in die Kalenderquelle „Stud.IP“. Hast du den Stud.IP-Kalender schon als Link abonniert, entferne das Abo, sonst erscheinen Termine doppelt.") Button("Abgelehnte Veranstaltungen wieder vorschlagen (\(ignoredCount))") { settings.ignoredCourseKeys = [] ignoredCount = 0 @@ -28,8 +31,10 @@ struct ScheduleSettingsPane: View { autoCreateModules = settings.autoCreateModules autoRefreshCalendars = settings.autoRefreshCalendars ignoredCount = settings.ignoredCourseKeys.count + importsStudIPTimetable = settings.importsStudIPTimetable } .onChange(of: autoCreateModules) { _, value in settings.autoCreateModules = value } .onChange(of: autoRefreshCalendars) { _, value in settings.autoRefreshCalendars = value } + .onChange(of: importsStudIPTimetable) { _, value in settings.importsStudIPTimetable = value } } } diff --git a/Presentation/StudIP/StudIPViewModel.swift b/Presentation/StudIP/StudIPViewModel.swift index 1b89977..d0e607f 100644 --- a/Presentation/StudIP/StudIPViewModel.swift +++ b/Presentation/StudIP/StudIPViewModel.swift @@ -39,6 +39,8 @@ final class StudIPViewModel { @ObservationIgnored var onBackgroundSyncChanges: ((StudIPSyncSummary) -> Void)? /// Called after announcements were synced, so lists showing them can reload. @ObservationIgnored var onAnnouncementsSynced: (() -> Void)? + /// Called after the Stud.IP timetable was imported, so the calendar can reload. + @ObservationIgnored var onTimetableSynced: (() -> Void)? private let store: any StudIPAccountStore private let moduleRepository: any ModuleRepository @@ -47,6 +49,8 @@ final class StudIPViewModel { private let linkCourse: LinkStudIPCourseUseCase private let syncCourses: SyncStudIPCoursesUseCase private let syncAnnouncements: SyncAnnouncementsUseCase? + private let syncTimetable: SyncStudIPTimetableUseCase? + private let settings: (any StudySettingsStore)? private let service: any StudIPService private let now: () -> Date @ObservationIgnored private var statusTask: Task? @@ -63,6 +67,8 @@ final class StudIPViewModel { fileRepository: (any CourseFileRepository)? = nil, storage: (any FileStorage)? = nil, announcementSync: SyncAnnouncementsUseCase? = nil, + timetableSync: SyncStudIPTimetableUseCase? = nil, + settings: (any StudySettingsStore)? = nil, now: @escaping () -> Date = { .now } ) { self.service = service @@ -81,6 +87,8 @@ final class StudIPViewModel { fileRepository: fileRepository, storage: storage ) syncAnnouncements = announcementSync + syncTimetable = timetableSync + self.settings = settings account = store.load()?.account lastSync = store.lastSlideSync autoSyncSlides = store.autoSyncSlides @@ -204,6 +212,7 @@ final class StudIPViewModel { var summary = try await syncCourses(modules: modules, credentials: credentials) try await addAnnouncements(to: &summary, modules: modules, credentials: credentials) if onlyModules == nil { + try await importTimetable(into: &summary, modules: modules, credentials: credentials) lastSync = now() store.lastSlideSync = lastSync } @@ -234,6 +243,7 @@ final class StudIPViewModel { do { summary = try await syncCourses(modules: modules, credentials: credentials) try await addAnnouncements(to: &summary, modules: modules, credentials: credentials) + try await importTimetable(into: &summary, modules: modules, credentials: credentials) } catch { // Im Hintergrund still bleiben; die Einstellungen zeigen „Sitzung abgelaufen“. noteSessionExpiry(error) @@ -261,6 +271,17 @@ final class StudIPViewModel { onAnnouncementsSynced?() } + /// Imports the dates of linked courses when the setting is on; failing modules only add to the failures. + private func importTimetable( + into summary: inout StudIPSyncSummary, + modules: [StudyModule], + credentials: StudIPCredentials + ) async throws { + guard let syncTimetable, settings?.importsStudIPTimetable == true else { return } + summary.failures += try await syncTimetable(modules: modules, credentials: credentials) + onTimetableSynced?() + } + private func noteSessionExpiry(_ error: any Error) { if (error as? StudIPError) == .sessionExpired { sessionExpired = true } } diff --git a/StudyAppTests/StudIPTimetableTests.swift b/StudyAppTests/StudIPTimetableTests.swift new file mode 100644 index 0000000..1d6a130 --- /dev/null +++ b/StudyAppTests/StudIPTimetableTests.swift @@ -0,0 +1,186 @@ +import Foundation +import Testing +@testable import StudyApp + +@MainActor +final class FakeScheduleService: StudIPScheduleService { + var byCourse: [String: [StudIPCourseDate]] = [:] + var failingCourses: [String: StudIPError] = [:] + + func courseDates(inCourse courseID: String, _ credentials: StudIPCredentials) async throws -> [StudIPCourseDate] { + if let error = failingCourses[courseID] { throw error } + return byCourse[courseID] ?? [] + } +} + +@MainActor +struct SyncStudIPTimetableTests { + let service = FakeScheduleService() + let repository = InMemoryCalendarRepository() + let now = Date(timeIntervalSince1970: 1_760_000_000) + let credentials = StudIPCredentials( + account: StudIPAccount(serverURL: URL(string: "https://elearning.uni-oldenburg.de")!, username: "abcd1234"), + password: "geheim" + ) + + private var sync: SyncStudIPTimetableUseCase { + SyncStudIPTimetableUseCase(service: service, repository: repository, now: { [now] in now }) + } + + private func module(_ name: String, course: String?) -> StudyModule { + StudyModule(id: UUID(), name: name, code: "", color: .blue, createdAt: now, studIPCourseID: course) + } + + private func date(_ id: String, inDays days: Double, title: String = "Vorlesung", cancelled: Bool = false) -> StudIPCourseDate { + let start = now.addingTimeInterval(days * 86_400) + return StudIPCourseDate( + id: id, title: title, details: "", location: "HS 1", + start: start, end: start.addingTimeInterval(5_400), isCancelled: cancelled + ) + } + + @Test func importsDatesIntoOneLinkedSource() async throws { + let analysis = module("Analysis I", course: "c1") + service.byCourse["c1"] = [date("d1", inDays: 1), date("d2", inDays: 8, title: "Analysis I")] + + try await sync(modules: [analysis], credentials: credentials) + + let source = try #require(repository.storedSources.first) + #expect(source.kind == .studIP) + #expect(source.name == "Stud.IP") + let events = repository.storedEvents.sorted { $0.startDate < $1.startDate } + #expect(events.map(\.title) == ["Analysis I – Vorlesung", "Analysis I"]) + #expect(events.allSatisfy { $0.moduleID == analysis.id && $0.location == "HS 1" }) + } + + @Test func refreshKeepsIDsAndRemovesDeletedDates() async throws { + let analysis = module("Analysis I", course: "c1") + service.byCourse["c1"] = [date("d1", inDays: 1), date("d2", inDays: 2)] + try await sync(modules: [analysis], credentials: credentials) + let firstID = try #require(repository.storedEvents.first { $0.externalID == "studip:d1" }?.id) + + service.byCourse["c1"] = [date("d1", inDays: 3)] + try await sync(modules: [analysis], credentials: credentials) + + #expect(repository.storedSources.count == 1) + #expect(repository.storedEvents.map(\.id) == [firstID]) + } + + @Test func marksCancelledDatesAndSkipsDatesOutsideTheWindow() async throws { + let analysis = module("Analysis I", course: "c1") + service.byCourse["c1"] = [date("old", inDays: -60), date("far", inDays: 400), date("off", inDays: 2, cancelled: true)] + + try await sync(modules: [analysis], credentials: credentials) + + #expect(repository.storedEvents.map(\.title) == ["Entfällt: Analysis I – Vorlesung"]) + } + + @Test func failingModuleKeepsItsStoredDates() async throws { + let analysis = module("Analysis I", course: "c1") + let algebra = module("Lineare Algebra", course: "c2") + service.byCourse["c1"] = [date("a", inDays: 1)] + service.byCourse["c2"] = [date("b", inDays: 1)] + try await sync(modules: [analysis, algebra], credentials: credentials) + + service.failingCourses["c1"] = .notFound + service.byCourse["c2"] = [] + let failures = try await sync(modules: [analysis, algebra], credentials: credentials) + + #expect(failures.count == 1) + #expect(repository.storedEvents.map(\.externalID) == ["studip:a"]) + } + + @Test func createsNoSourceWithoutLinkedModules() async throws { + try await sync(modules: [module("Ohne Kurs", course: nil)], credentials: credentials) + #expect(repository.storedSources.isEmpty) + } + + @Test func expiredSessionStopsTheSync() async throws { + service.failingCourses["c1"] = .sessionExpired + await #expect(throws: StudIPError.sessionExpired) { + try await sync(modules: [module("Analysis I", course: "c1")], credentials: credentials) + } + } +} + +struct StudIPCourseDateParsingTests { + private func resource(_ attributes: [String: Any]) -> StudIPJSONAPIParser.Resource { + ["type": "course-events", "id": "e1", "attributes": attributes] + } + + @Test func readsTimesLocationAndCancellation() throws { + let date = try #require(StudIPJSONAPIParser.courseDate(resource([ + "title": "Vorlesung", + "description": "

Kapitel 3

", + "start": "2026-10-28T10:15:00+01:00", + "end": "2026-10-28T11:45:00+01:00", + "location": "HS 1", + "is-cancelled": true, + ]))) + + #expect(date.id == "e1") + #expect(date.title == "Vorlesung") + #expect(date.details == "Kapitel 3") + #expect(date.location == "HS 1") + #expect(date.isCancelled) + #expect(date.end.timeIntervalSince(date.start) == 5_400) + } + + @Test func acceptsOlderAttributeNamesAndSkipsDatesWithoutStart() { + let date = StudIPJSONAPIParser.courseDate(resource(["categories": "Übung", "begin": 1_760_000_000, "room": "SR 3"])) + #expect(date?.title == "Übung") + #expect(date?.location == "SR 3") + #expect(date?.end == date?.start) + #expect(StudIPJSONAPIParser.courseDate(resource(["title": "ohne Zeit"])) == nil) + } +} + +@MainActor +struct StudIPTimetableSyncSettingTests { + let service = FakeStudIPService() + let schedule = FakeScheduleService() + let store = InMemoryStudIPAccountStore() + let modules = FakeModuleRepository() + let slides = FakeSlideDocumentRepository() + let calendar = InMemoryCalendarRepository() + let settings = InMemoryStudyPreferences() + + private func makeViewModel() -> StudIPViewModel { + StudIPViewModel( + service: service, + store: store, + moduleRepository: modules, + slideRepository: slides, + importer: ImportSlideDocumentUseCase(repository: slides, storage: FakeFileStorage(), inspector: FakeInspector(pages: 3)), + timetableSync: SyncStudIPTimetableUseCase(service: schedule, repository: calendar), + settings: settings + ) + } + + private func prepare() throws { + try store.save(StudIPCredentials( + account: StudIPAccount(serverURL: URL(string: "https://elearning.uni-oldenburg.de")!, username: "abcd1234"), + password: "geheim" + )) + try modules.add(StudyModule(id: UUID(), name: "Analysis I", code: "", color: .blue, createdAt: .now, studIPCourseID: "c1")) + let start = Date.now.addingTimeInterval(86_400) + schedule.byCourse["c1"] = [ + StudIPCourseDate(id: "d1", title: "Vorlesung", details: "", location: "", start: start, end: start, isCancelled: false), + ] + } + + @Test func importsTheTimetableOnlyWhenTurnedOn() async throws { + try prepare() + let viewModel = makeViewModel() + var reloads = 0 + viewModel.onTimetableSynced = { reloads += 1 } + + await viewModel.syncNow() + #expect(calendar.storedEvents.isEmpty) + + settings.importsStudIPTimetable = true + await viewModel.syncNow() + #expect(calendar.storedEvents.map(\.externalID) == ["studip:d1"]) + #expect(reloads == 1) + } +} diff --git a/docs/STUDIP.md b/docs/STUDIP.md index 396eed2..77ef32a 100644 --- a/docs/STUDIP.md +++ b/docs/STUDIP.md @@ -25,6 +25,7 @@ StudyApp talks to the JSON:API of a Stud.IP installation (`/jsonapi.php/ | Folder tree | `GET courses/{id}/folders` (relationship `parent`) | | Download | `GET file-refs/{id}/content` | | Announcements | `GET courses/{id}/news` (`title`, HTML `content`, `publication-start`) | +| Course dates | `GET courses/{id}/events` (`title`, `start`/`end` or `begin`, `location`/`room`, `is-cancelled`) | Lists are paged with `page[limit]=100` and followed through `links.next` (absolute or relative). Parsing is deliberately lenient (`StudIPJSONAPIParser`): missing attributes never fail a sync. @@ -47,6 +48,10 @@ Unread announcements appear on „Heute“, the latest three in the module overv Code: `Domain/UseCases/AnnouncementUseCases.swift`, `Data/StudIP/StudIPJSONAPIClient+Announcements.swift`, `Presentation/Announcements/` +## Timetable + +With Settings → Stundenplan → „Termine verknüpfter Stud.IP-Veranstaltungen übernehmen“ every full Stud.IP sync imports the dates of linked, active modules (30 days back, 240 days ahead) into the calendar source „Stud.IP“ (`SyncStudIPTimetableUseCase`, port `StudIPScheduleService`). Events are linked to their module, cancelled dates are titled „Entfällt: …“, and IDs are derived from the Stud.IP date ID, so a refresh updates moved dates and removes deleted ones. A module that fails to load keeps its stored dates. The option is off by default because many users already subscribe to the Stud.IP calendar link; with both, dates would appear twice. The source can be removed under Kalender → Quellen. + ## Errors | Error | Meaning | Effect |