From 692a56cadc431e455a783c9f4877c46ea7d194f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:42:28 +0000 Subject: [PATCH 1/4] feat(studip): add the timetable import to the domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A StudIPScheduleService port delivers the dates of a course and a new calendar source kind „studIP“ holds them. SyncStudIPTimetableUseCase imports the dates of linked active modules (30 days back, 240 ahead), links them to their module, marks cancelled dates and gives stable IDs so refreshes update instead of duplicating. A module that fails to load keeps its stored dates. Refs #42 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- Domain/Entities/CalendarEvent.swift | 2 + .../Repositories/StudIPScheduleService.swift | 18 +++ Domain/UseCases/StudIPTimetableUseCases.swift | 79 +++++++++++++ StudyAppTests/StudIPTimetableTests.swift | 104 ++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 Domain/Repositories/StudIPScheduleService.swift create mode 100644 Domain/UseCases/StudIPTimetableUseCases.swift create mode 100644 StudyAppTests/StudIPTimetableTests.swift 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/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/StudyAppTests/StudIPTimetableTests.swift b/StudyAppTests/StudIPTimetableTests.swift new file mode 100644 index 0000000..8dc3bea --- /dev/null +++ b/StudyAppTests/StudIPTimetableTests.swift @@ -0,0 +1,104 @@ +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) + } + } +} From 7accba1e708f7c564bfd97944d348de7bbcb4609 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:44:09 +0000 Subject: [PATCH 2/4] feat(studip): load course dates from the JSON:API The client reads courses/{id}/events and conforms to the new StudIPScheduleService port; the parser accepts start/end or begin and location or room, and marks cancelled dates. HTML descriptions become plain text through StudIPHTML. The calendar sources sheet shows the Stud.IP source with its own symbol. Refs #42 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- Data/StudIP/StudIPHTML.swift | 22 +++++++++++++ .../StudIP/StudIPJSONAPIClient+Schedule.swift | 8 +++++ Data/StudIP/StudIPJSONAPIClient.swift | 3 +- Data/StudIP/StudIPJSONAPIParser.swift | 19 +++++++++++ .../Calendar/CalendarSourcesSheet.swift | 10 +++++- StudyAppTests/StudIPHTMLTests.swift | 11 +++++++ StudyAppTests/StudIPTimetableTests.swift | 32 +++++++++++++++++++ 7 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 Data/StudIP/StudIPHTML.swift create mode 100644 Data/StudIP/StudIPJSONAPIClient+Schedule.swift create mode 100644 StudyAppTests/StudIPHTMLTests.swift diff --git a/Data/StudIP/StudIPHTML.swift b/Data/StudIP/StudIPHTML.swift new file mode 100644 index 0000000..9bf98b4 --- /dev/null +++ b/Data/StudIP/StudIPHTML.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Stud.IP delivers announcement and date descriptions as HTML. +enum StudIPHTML { + /// Reduces HTML to readable text: line breaks for block elements, bullets for list items, no tags, + /// common entities decoded. + static func plainText(_ html: String) -> String { + var text = html.replacingOccurrences( + of: #"<\s*(br|/p|/div|/li|/h[1-6])\s*/?>"#, with: "\n", options: [.regularExpression, .caseInsensitive] + ) + text = text.replacingOccurrences(of: #"<\s*(ul|ol)[^>]*>"#, with: "\n", options: [.regularExpression, .caseInsensitive]) + text = text.replacingOccurrences(of: #"<\s*li[^>]*>"#, with: "• ", options: [.regularExpression, .caseInsensitive]) + text = text.replacingOccurrences(of: #"<[^>]+>"#, with: "", options: .regularExpression) + let entities = [" ": " ", "&": "&", "<": "<", ">": ">", """: "\"", "'": "'", "'": "'"] + for (entity, replacement) in entities { + text = text.replacingOccurrences(of: entity, with: replacement) + } + text = text.replacingOccurrences(of: #"[ \t]+\n"#, with: "\n", options: .regularExpression) + text = text.replacingOccurrences(of: #"\n{3,}"#, with: "\n\n", options: .regularExpression) + return text.trimmed + } +} 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/StudIPJSONAPIClient.swift b/Data/StudIP/StudIPJSONAPIClient.swift index 1380af9..0a17c8f 100644 --- a/Data/StudIP/StudIPJSONAPIClient.swift +++ b/Data/StudIP/StudIPJSONAPIClient.swift @@ -39,7 +39,8 @@ struct StudIPJSONAPIClient: StudIPService { // MARK: HTTP - private func allResources(_ path: String, _ credentials: StudIPCredentials) async throws -> [StudIPJSONAPIParser.Resource] { + /// Follows `links.next` through all pages of a collection. + func allResources(_ path: String, _ credentials: StudIPCredentials) async throws -> [StudIPJSONAPIParser.Resource] { var url: URL? = try endpoint(path, credentials, query: [URLQueryItem(name: "page[limit]", value: "100")]) var resources: [StudIPJSONAPIParser.Resource] = [] var pages = 0 diff --git a/Data/StudIP/StudIPJSONAPIParser.swift b/Data/StudIP/StudIPJSONAPIParser.swift index a33f065..e765f98 100644 --- a/Data/StudIP/StudIPJSONAPIParser.swift +++ b/Data/StudIP/StudIPJSONAPIParser.swift @@ -105,6 +105,25 @@ enum StudIPJSONAPIParser { return paths } + /// 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/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/StudyAppTests/StudIPHTMLTests.swift b/StudyAppTests/StudIPHTMLTests.swift new file mode 100644 index 0000000..699ac22 --- /dev/null +++ b/StudyAppTests/StudIPHTMLTests.swift @@ -0,0 +1,11 @@ +import Testing +@testable import StudyApp + +struct StudIPHTMLTests { + @Test func keepsParagraphsListsAndLineBreaks() { + let text = StudIPHTML.plainText( + "

Bitte mitbringen:

  • Taschenrechner
  • Ausweis
Zeile
zwei & Ende" + ) + #expect(text == "Bitte mitbringen:\n\n• Taschenrechner\n• Ausweis\nZeile\nzwei & Ende") + } +} diff --git a/StudyAppTests/StudIPTimetableTests.swift b/StudyAppTests/StudIPTimetableTests.swift index 8dc3bea..4d98b18 100644 --- a/StudyAppTests/StudIPTimetableTests.swift +++ b/StudyAppTests/StudIPTimetableTests.swift @@ -102,3 +102,35 @@ struct SyncStudIPTimetableTests { } } } + +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) + } +} From ee6d234ed8162e1b8084a22f38d4f1c35dacf118 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:47:43 +0000 Subject: [PATCH 3/4] feat(studip): import the timetable during the Stud.IP sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New setting „Termine verknüpfter Stud.IP-Veranstaltungen übernehmen“ (Stundenplan, off by default so subscribed Stud.IP calendars are not doubled). When it is on, every full Stud.IP sync imports the dates of linked modules into the calendar source „Stud.IP“ and the calendar reloads. Refs #42 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- App/AppContainer.swift | 10 +++- App/StudyAppApp.swift | 1 + .../UserDefaultsStudyPreferences.swift | 6 +++ Domain/Repositories/StudyPreferences.swift | 3 ++ .../Settings/ScheduleSettingsPane.swift | 5 ++ Presentation/StudIP/StudIPViewModel.swift | 25 +++++++++- StudyAppTests/StudIPTimetableTests.swift | 50 +++++++++++++++++++ 7 files changed, 96 insertions(+), 4 deletions(-) diff --git a/App/AppContainer.swift b/App/AppContainer.swift index 9d944fc..a2b8ac8 100644 --- a/App/AppContainer.swift +++ b/App/AppContainer.swift @@ -17,6 +17,7 @@ final class AppContainer { let fileStorage: any FileStorage let pdfInspector: any PDFInspector let studIPService: any StudIPService + let studIPScheduleService: any StudIPScheduleService let studIPAccountStore: any StudIPAccountStore let courseFileRepository: any CourseFileRepository let backupArchiver: any BackupArchiving @@ -44,7 +45,10 @@ final class AppContainer { calendarFeedLoader = URLSessionCalendarFeedLoader() calendarEventParser = ICalendarFeedParser() pdfInspector = PDFKitInspector() - studIPService = StudIPJSONAPIClient() + // One client serves every Stud.IP port; features depend only on the port they use. + let studIPClient = StudIPJSONAPIClient() + studIPService = studIPClient + studIPScheduleService = studIPClient if inMemory { preferences = InMemoryStudyPreferences() studIPAccountStore = InMemoryStudIPAccountStore() @@ -150,7 +154,9 @@ final class AppContainer { slideRepository: slideRepository, importer: ImportSlideDocumentUseCase(repository: slideRepository, storage: fileStorage, inspector: pdfInspector), fileRepository: courseFileRepository, - storage: fileStorage + storage: fileStorage, + timetableSync: SyncStudIPTimetableUseCase(service: studIPScheduleService, repository: studyCalendarRepository), + settings: preferences ) } diff --git a/App/StudyAppApp.swift b/App/StudyAppApp.swift index d894132..ecba19f 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -29,6 +29,7 @@ struct StudyAppApp: App { let exams = container.makeExamsViewModel() let navigation = AppNavigation() let studIP = container.makeStudIPViewModel() + 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/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/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 6a5ac1a..1ce1c14 100644 --- a/Presentation/StudIP/StudIPViewModel.swift +++ b/Presentation/StudIP/StudIPViewModel.swift @@ -37,12 +37,16 @@ final class StudIPViewModel { @ObservationIgnored var onSlidesChanged: (() -> Void)? /// Called when an automatic sync brought new material, e.g. to post a notification. @ObservationIgnored var onBackgroundSyncChanges: ((StudIPSyncSummary) -> 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 private let connectUseCase: ConnectStudIPUseCase private let linkCourse: LinkStudIPCourseUseCase private let syncCourses: SyncStudIPCoursesUseCase + private let syncTimetable: SyncStudIPTimetableUseCase? + private let settings: (any StudySettingsStore)? private let service: any StudIPService private let now: () -> Date @ObservationIgnored private var statusTask: Task? @@ -55,6 +59,8 @@ final class StudIPViewModel { importer: ImportSlideDocumentUseCase, fileRepository: (any CourseFileRepository)? = nil, storage: (any FileStorage)? = nil, + timetableSync: SyncStudIPTimetableUseCase? = nil, + settings: (any StudySettingsStore)? = nil, now: @escaping () -> Date = { .now } ) { self.service = service @@ -71,6 +77,8 @@ final class StudIPViewModel { slideRepository: slideRepository, service: service, importer: importer, fileRepository: fileRepository, storage: storage ) + syncTimetable = timetableSync + self.settings = settings account = store.load()?.account lastSync = store.lastSlideSync autoSyncSlides = store.autoSyncSlides @@ -173,8 +181,9 @@ final class StudIPViewModel { defer { isSyncing = false } do { let modules = try onlyModules ?? moduleRepository.all() - let summary = try await syncCourses(modules: modules, credentials: credentials) + var summary = try await syncCourses(modules: modules, credentials: credentials) if onlyModules == nil { + try await importTimetable(into: &summary, modules: modules, credentials: credentials) lastSync = now() store.lastSlideSync = lastSync } @@ -201,9 +210,10 @@ final class StudIPViewModel { // Auch ein fehlgeschlagener Versuch zählt, sonst würde ohne Netz ständig neu versucht. lastSync = now() store.lastSlideSync = lastSync - let summary: StudIPSyncSummary + var summary: StudIPSyncSummary do { summary = try await syncCourses(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) @@ -216,6 +226,17 @@ final class StudIPViewModel { report(summary) } + /// 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 index 4d98b18..1d6a130 100644 --- a/StudyAppTests/StudIPTimetableTests.swift +++ b/StudyAppTests/StudIPTimetableTests.swift @@ -134,3 +134,53 @@ struct StudIPCourseDateParsingTests { #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) + } +} From 5b0fadad886d6ddce8f58395b7377eda0ed7bc25 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:47:43 +0000 Subject: [PATCH 4/4] docs(studip): describe the timetable import Refs #42 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- docs/STUDIP.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/STUDIP.md b/docs/STUDIP.md index 8f47490..fc8c4d1 100644 --- a/docs/STUDIP.md +++ b/docs/STUDIP.md @@ -23,6 +23,7 @@ StudyApp talks to the JSON:API of a Stud.IP installation (`/jsonapi.php/ | Files of a course (flat) | `GET courses/{id}/file-refs` | | Folder tree | `GET courses/{id}/folders` (relationship `parent`) | | Download | `GET file-refs/{id}/content` | +| 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. @@ -37,6 +38,10 @@ Lists are paged with `page[limit]=100` and followed through `links.next` (absolu Downloaded files are stored under `Slides/StudIP///` in the app container. +## 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 |