Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion App/AppContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -51,6 +52,7 @@ final class AppContainer {
let studIPClient = StudIPJSONAPIClient()
studIPService = studIPClient
studIPAnnouncementService = studIPClient
studIPScheduleService = studIPClient
if inMemory {
preferences = InMemoryStudyPreferences()
studIPAccountStore = InMemoryStudIPAccountStore()
Expand Down Expand Up @@ -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
)
}

Expand Down
1 change: 1 addition & 0 deletions App/StudyAppApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions Data/Preferences/UserDefaultsStudyPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)" }
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions Data/StudIP/StudIPJSONAPIClient+Schedule.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
19 changes: 19 additions & 0 deletions Data/StudIP/StudIPJSONAPIParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
2 changes: 2 additions & 0 deletions Domain/Entities/CalendarEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions Domain/Repositories/StudIPScheduleService.swift
Original file line number Diff line number Diff line change
@@ -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]
}
3 changes: 3 additions & 0 deletions Domain/Repositories/StudyPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand All @@ -44,6 +46,7 @@ final class InMemoryStudyPreferences: StudySettingsStore, ReadingProgressStore {
var ignoredCourseKeys: Set<String> = []
var lastAutoOpenedLecture: String?
var automaticBackups = true
var importsStudIPTimetable = false
var notificationSettings = NotificationSettings()
private var pages: [UUID: Int] = [:]
private var documents: [UUID: UUID] = [:]
Expand Down
79 changes: 79 additions & 0 deletions Domain/UseCases/StudIPTimetableUseCases.swift
Original file line number Diff line number Diff line change
@@ -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<UUID> = []
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
)
}
}
10 changes: 9 additions & 1 deletion Presentation/Calendar/CalendarSourcesSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions Presentation/Settings/ScheduleSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
}
}
21 changes: 21 additions & 0 deletions Presentation/StudIP/StudIPViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Void, Never>?
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
}
Expand Down
Loading
Loading