diff --git a/App/AppContainer.swift b/App/AppContainer.swift index e6ffbb4..fa33554 100644 --- a/App/AppContainer.swift +++ b/App/AppContainer.swift @@ -56,7 +56,7 @@ final class AppContainer { studIPService = studIPClient studIPAnnouncementService = studIPClient studIPScheduleService = studIPClient - canteenService = OpenMensaClient() + canteenService = StudierendenwerkOldenburgClient() if inMemory { preferences = InMemoryStudyPreferences() studIPAccountStore = InMemoryStudIPAccountStore() diff --git a/Data/Mensa/OpenMensaClient.swift b/Data/Mensa/OpenMensaClient.swift deleted file mode 100644 index c0c359d..0000000 --- a/Data/Mensa/OpenMensaClient.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -/// Loads canteens and menus from the OpenMensa API v2. No account is needed. -struct OpenMensaClient: CanteenService { - static let baseURL = URL(string: "https://openmensa.org/api/v2/")! - - var session: URLSession = .shared - var calendar: Calendar = .current - - func canteens(nearLatitude latitude: Double, longitude: Double, radius: Double) async throws -> [Canteen] { - let data = try await get("canteens", query: [ - URLQueryItem(name: "near[lat]", value: String(latitude)), - URLQueryItem(name: "near[lng]", value: String(longitude)), - URLQueryItem(name: "near[dist]", value: String(radius)), - ]) - return try OpenMensaParser.canteens(data) - } - - func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu { - let day = OpenMensaParser.dayString(date, calendar: calendar) - let isClosed = try OpenMensaParser.isClosed(try await get("canteens/\(canteenID)/days/\(day)")) - let meals = isClosed ? [] : try OpenMensaParser.meals(try await get("canteens/\(canteenID)/days/\(day)/meals")) - return CanteenMenu(canteenID: canteenID, date: date, isClosed: isClosed, meals: meals) - } - - private func get(_ path: String, query: [URLQueryItem] = []) async throws -> Data { - var components = URLComponents(url: Self.baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false) - if !query.isEmpty { components?.queryItems = query } - guard let url = components?.url else { throw MensaError.unavailable } - var request = URLRequest(url: url) - request.timeoutInterval = 20 - request.setValue("application/json", forHTTPHeaderField: "Accept") - let data: Data - let response: URLResponse - do { - (data, response) = try await session.data(for: request) - } catch { - throw MensaError.offline - } - guard let http = response as? HTTPURLResponse else { throw MensaError.unavailable } - // OpenMensa answers 404 for days without a published menu. - guard (200...299).contains(http.statusCode) else { throw MensaError.unavailable } - return data - } -} diff --git a/Data/Mensa/OpenMensaParser.swift b/Data/Mensa/OpenMensaParser.swift deleted file mode 100644 index 43ced60..0000000 --- a/Data/Mensa/OpenMensaParser.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation - -/// Reads OpenMensa API v2 responses (https://openmensa.org/api/v2). -enum OpenMensaParser { - private struct CanteenDTO: Decodable { - let id: Int - let name: String - let city: String? - let address: String? - } - - private struct DayDTO: Decodable { - let closed: Bool - } - - private struct MealDTO: Decodable { - struct Prices: Decodable { - let students: Double? - } - - let id: Int - let name: String - let category: String? - let prices: Prices? - let notes: [String]? - } - - static func canteens(_ data: Data) throws -> [Canteen] { - try JSONDecoder().decode([CanteenDTO].self, from: data).map { - Canteen(id: $0.id, name: $0.name, city: $0.city ?? "", address: $0.address ?? "") - } - } - - static func isClosed(_ data: Data) throws -> Bool { - try JSONDecoder().decode(DayDTO.self, from: data).closed - } - - static func meals(_ data: Data) throws -> [Meal] { - try JSONDecoder().decode([MealDTO].self, from: data).map { - Meal( - id: $0.id, - name: $0.name, - category: $0.category ?? "Essen", - studentPrice: $0.prices?.students, - notes: $0.notes ?? [] - ) - } - } - - /// OpenMensa addresses days as `yyyy-MM-dd` in the canteen's (German) time zone. - static func dayString(_ date: Date, calendar: Calendar) -> String { - let components = calendar.dateComponents([.year, .month, .day], from: date) - return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) - } -} diff --git a/Data/Mensa/StudierendenwerkMenuParser.swift b/Data/Mensa/StudierendenwerkMenuParser.swift new file mode 100644 index 0000000..a7494c6 --- /dev/null +++ b/Data/Mensa/StudierendenwerkMenuParser.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Reads the menu HTML of the Studierendenwerk Oldenburg (`func=make_spl` of its menu system). +/// +/// The page lists a heading per serving counter (`.gruppenname`, e.g. „AUSGABE EINS“, „SUPPEN“) +/// followed by its meals (`.splMeal`). Each meal shows its name, the prices +/// „Studierende / Bedienstete / Gäste“ and icons such as „vegan“. A closed day is a single `.nodata` row. +enum StudierendenwerkMenuParser { + /// Heading for meals listed before the first counter heading. + static let defaultCategory = "Gerichte" + + static func isClosed(_ html: String) -> Bool { + html.contains("class='row nodata'") || html.contains("class=\"row nodata\"") + } + + static func meals(_ html: String) -> [Meal] { + var meals: [Meal] = [] + var category = defaultCategory + // Headings and meals in page order; each meal's HTML runs up to the next marker. + let markers = matches(of: #"col-xs-4 gruppenname'>([^<]*)<|
String? { + guard let raw = matches(of: #"
(.*?)"#, in: block).first?.group(1), + let amount = matches(of: #"(\d+),(\d{2})"#, in: prices).first, + let euros = amount.group(1), let cents = amount.group(2) else { return nil } + return Double("\(euros).\(cents)") + } + + /// Icon titles such as „vegan“ or „mit Rindfleisch“, each once. + static func notes(in block: String) -> [String] { + var notes: [String] = [] + for match in matches(of: #"class='iconLarge' title='([^']*)'"#, in: block) { + guard let title = match.group(1).map(plainText), !title.isEmpty, !notes.contains(title) else { continue } + notes.append(title) + } + return notes + } + + /// „AUSGABE EINS“ → „Ausgabe Eins“; an empty heading keeps the default. + static func categoryTitle(_ heading: String) -> String { + let text = plainText(heading) + return text.isEmpty ? defaultCategory : text.lowercased().capitalized + } + + // MARK: Helpers + + private static func plainText(_ html: String) -> String { + var text = html.replacingOccurrences(of: #"<[^>]+>"#, with: "", options: .regularExpression) + let entities = [" ": " ", "&": "&", "€": "€", """: "\"", "'": "'", "<": "<", ">": ">"] + for (entity, replacement) in entities { + text = text.replacingOccurrences(of: entity, with: replacement) + } + return text.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private struct Match { + let range: Range + let groups: [String?] + + func group(_ index: Int) -> String? { index <= groups.count ? groups[index - 1] : nil } + } + + private static func matches(of pattern: String, in text: String) -> [Match] { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) else { return [] } + return regex.matches(in: text, range: NSRange(text.startIndex..., in: text)).compactMap { result in + guard let range = Range(result.range, in: text) else { return nil } + let groups = (1.. [Canteen] { + Self.universityCanteens + } + + func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu { + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.timeoutInterval = 20 + request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type") + request.httpBody = Data(Self.formBody(locationID: canteenID, date: date, calendar: calendar).utf8) + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw MensaError.offline + } + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode), + let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) + else { throw MensaError.unavailable } + let isClosed = StudierendenwerkMenuParser.isClosed(html) + let meals = isClosed ? [] : StudierendenwerkMenuParser.meals(html) + return CanteenMenu(canteenID: canteenID, date: date, isClosed: isClosed, meals: meals) + } + + /// The menu system also needs the Mondays of the day's week and of the following week. + static func formBody(locationID: Int, date: Date, calendar: Calendar) -> String { + let monday = weekStart(of: date, calendar: calendar) + let nextMonday = calendar.date(byAdding: .day, value: 7, to: monday) ?? monday + return [ + "func=make_spl", + "locId=\(locationID)", + "date=\(dayString(date, calendar: calendar))", + "lang=de", + "startThisWeek=\(dayString(monday, calendar: calendar))", + "startNextWeek=\(dayString(nextMonday, calendar: calendar))", + ].joined(separator: "&") + } + + /// Monday of the week containing `date`, also for Sundays. + static func weekStart(of date: Date, calendar: Calendar) -> Date { + var mondayFirst = calendar + mondayFirst.firstWeekday = 2 + return mondayFirst.dateInterval(of: .weekOfYear, for: date)?.start ?? calendar.startOfDay(for: date) + } + + /// `yyyy-MM-dd` in the canteen's (German) time zone. + static func dayString(_ date: Date, calendar: Calendar) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } +} diff --git a/Domain/Entities/Mensa.swift b/Domain/Entities/Mensa.swift index 2a6c24b..df2c788 100644 --- a/Domain/Entities/Mensa.swift +++ b/Domain/Entities/Mensa.swift @@ -1,6 +1,6 @@ import Foundation -/// A canteen listed by OpenMensa. +/// A canteen whose menu the app can show. struct Canteen: Identifiable, Hashable, Codable, Sendable { let id: Int var name: String diff --git a/Domain/Repositories/CanteenService.swift b/Domain/Repositories/CanteenService.swift index 155dd78..43914b4 100644 --- a/Domain/Repositories/CanteenService.swift +++ b/Domain/Repositories/CanteenService.swift @@ -1,10 +1,10 @@ import Foundation -/// Port to a canteen menu source (OpenMensa). +/// Port to a canteen menu source (the Studierendenwerk Oldenburg). @MainActor protocol CanteenService { - /// Canteens within `radius` kilometres of a coordinate. - func canteens(nearLatitude latitude: Double, longitude: Double, radius: Double) async throws -> [Canteen] + /// The canteens the source offers menus for, the default one first. + func canteens() async throws -> [Canteen] /// The menu of a canteen on a day. Throws `MensaError.unavailable` when the canteen publishes nothing for it. func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu } diff --git a/Domain/Repositories/StudyPreferences.swift b/Domain/Repositories/StudyPreferences.swift index 7a31a52..ad15f48 100644 --- a/Domain/Repositories/StudyPreferences.swift +++ b/Domain/Repositories/StudyPreferences.swift @@ -20,7 +20,7 @@ protocol StudySettingsStore: AnyObject { 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 } - /// OpenMensa canteen shown on „Heute“; `nil` hides the menu. + /// Canteen shown on „Heute“; `nil` hides the menu. var canteenID: Int? { get set } var notificationSettings: NotificationSettings { get set } var studyTimerSettings: StudyTimerSettings { get set } diff --git a/Domain/UseCases/MensaUseCases.swift b/Domain/UseCases/MensaUseCases.swift index d3e0c7d..7a23b37 100644 --- a/Domain/UseCases/MensaUseCases.swift +++ b/Domain/UseCases/MensaUseCases.swift @@ -12,14 +12,6 @@ enum MensaSchedule { } } -/// Canteens of the Studierendenwerk Oldenburg area, nearest first. -enum MensaLocation { - /// University of Oldenburg, campus Haarentor. - static let oldenburgLatitude = 53.1478 - static let oldenburgLongitude = 8.1814 - static let searchRadius = 15.0 -} - /// Loads a menu and keeps it for the rest of the day, so „Heute“ does not ask the server on every visit. @MainActor final class LoadCanteenMenuUseCase { diff --git a/Presentation/Mensa/MensaCard.swift b/Presentation/Mensa/MensaCard.swift index 60927a1..17ce63b 100644 --- a/Presentation/Mensa/MensaCard.swift +++ b/Presentation/Mensa/MensaCard.swift @@ -106,7 +106,7 @@ struct MensaSettingsSection: View { } header: { Text("Mensa") } footer: { - Text("Der Speiseplan kommt von OpenMensa. Nach dem Mittagessen zeigt „Heute“ schon den Plan für morgen.") + Text("Der Speiseplan kommt vom Studierendenwerk Oldenburg. Nach dem Mittagessen zeigt „Heute“ schon den Plan für morgen.") } .task { await viewModel.loadCanteens() } } diff --git a/Presentation/Mensa/MensaViewModel.swift b/Presentation/Mensa/MensaViewModel.swift index 476637e..f002882 100644 --- a/Presentation/Mensa/MensaViewModel.swift +++ b/Presentation/Mensa/MensaViewModel.swift @@ -53,6 +53,7 @@ final class MensaViewModel { } func refresh() async { + await loadCanteens() guard let canteenID else { state = .off return @@ -68,16 +69,19 @@ final class MensaViewModel { } } - /// Canteens around Oldenburg for the settings, nearest first. + /// The canteens for the settings, the default one first. func loadCanteens() async { guard canteens.isEmpty, !isLoadingCanteens else { return } isLoadingCanteens = true defer { isLoadingCanteens = false } - canteens = (try? await service.canteens( - nearLatitude: MensaLocation.oldenburgLatitude, - longitude: MensaLocation.oldenburgLongitude, - radius: MensaLocation.searchRadius - )) ?? [] + canteens = (try? await service.canteens()) ?? [] + adoptSupportedCanteen() + } + + /// A canteen chosen from an earlier source (OpenMensa) is no longer offered; the default one replaces it. + private func adoptSupportedCanteen() { + guard let canteenID, let fallback = canteens.first, !canteens.contains(where: { $0.id == canteenID }) else { return } + settings.canteenID = fallback.id } static func priceText(_ price: Double?) -> String? { diff --git a/StudyAppTests/MensaTests.swift b/StudyAppTests/MensaTests.swift index 9d28e74..140bacc 100644 --- a/StudyAppTests/MensaTests.swift +++ b/StudyAppTests/MensaTests.swift @@ -2,31 +2,21 @@ import Foundation import Testing @testable import StudyApp -struct OpenMensaParserTests { - @Test func readsCanteens() throws { - let json = #"[{"id":1084,"name":"Oldenburg, Mensa Uhlhornsweg","city":"Oldenburg","address":"Uhlhornsweg 49-55, 26129 Oldenburg","coordinates":[53.14,8.18]}]"# - let canteens = try OpenMensaParser.canteens(Data(json.utf8)) - #expect(canteens == [Canteen(id: 1084, name: "Oldenburg, Mensa Uhlhornsweg", city: "Oldenburg", address: "Uhlhornsweg 49-55, 26129 Oldenburg")]) - } - - @Test func readsMealsWithStudentPriceAndNotes() throws { - let json = #""" - [{"id":1,"name":"Spaghetti Bolognese","category":"Pasta","prices":{"students":2.5,"employees":4.1,"pupils":null,"others":5.0},"notes":["Rind"]}, - {"id":2,"name":"Gemüsecurry","category":"Vegan","prices":{"students":null},"notes":[]}, - {"id":3,"name":"Salatbar","prices":null}] - """# - let meals = try OpenMensaParser.meals(Data(json.utf8)) - #expect(meals.map(\.studentPrice) == [2.5, nil, nil]) - #expect(meals.map(\.category) == ["Pasta", "Vegan", "Essen"]) - #expect(meals.first?.notes == ["Rind"]) +@MainActor +struct StudierendenwerkOldenburgClientTests { + @Test func offersTheUniversityCanteensWithUhlhornswegFirst() { + #expect(StudierendenwerkOldenburgClient.universityCanteens.map(\.name) == ["Mensa Uhlhornsweg", "Mensa Wechloy"]) + #expect(StudierendenwerkOldenburgClient.universityCanteens.map(\.id) == [2, 3]) } - @Test func readsClosedDaysAndFormatsDates() throws { - #expect(try OpenMensaParser.isClosed(Data(#"{"date":"2026-10-28","closed":true}"#.utf8))) + @Test func asksForTheDayWithTheMondaysOfItsWeekAndTheNext() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(identifier: "Europe/Berlin")) - let date = try #require(calendar.date(from: DateComponents(year: 2026, month: 3, day: 9, hour: 23, minute: 30))) - #expect(OpenMensaParser.dayString(date, calendar: calendar) == "2026-03-09") + let sunday = try #require(calendar.date(from: DateComponents(year: 2026, month: 11, day: 1, hour: 23, minute: 30))) + + let body = StudierendenwerkOldenburgClient.formBody(locationID: 3, date: sunday, calendar: calendar) + + #expect(body == "func=make_spl&locId=3&date=2026-11-01&lang=de&startThisWeek=2026-10-26&startNextWeek=2026-11-02") } } @@ -36,7 +26,9 @@ final class FakeCanteenService: CanteenService { var error: MensaError? private(set) var requests = 0 - func canteens(nearLatitude latitude: Double, longitude: Double, radius: Double) async throws -> [Canteen] { [] } + var canteenList: [Canteen] = [] + + func canteens() async throws -> [Canteen] { canteenList } func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu { requests += 1 @@ -118,6 +110,22 @@ struct MensaViewModelTests { #expect(viewModel.state == .unavailable(MensaError.offline.localizedDescription)) } + @Test func replacesACanteenThatIsNoLongerOffered() async throws { + service.canteenList = [ + Canteen(id: 2, name: "Mensa Uhlhornsweg", city: "Oldenburg", address: ""), + Canteen(id: 3, name: "Mensa Wechloy", city: "Oldenburg", address: ""), + ] + settings.canteenID = 1084 // chosen from OpenMensa before + let viewModel = try makeViewModel() + + await viewModel.refresh() + #expect(settings.canteenID == 2) + + settings.canteenID = 3 + await viewModel.refresh() + #expect(settings.canteenID == 3) + } + @Test func formatsStudentPrices() { // The currency style separates the amount with a no-break space. #expect(MensaViewModel.priceText(2.5)?.replacingOccurrences(of: "\u{00A0}", with: " ") == "2,50 €") diff --git a/StudyAppTests/StudierendenwerkMenuTests.swift b/StudyAppTests/StudierendenwerkMenuTests.swift new file mode 100644 index 0000000..0a3cc58 --- /dev/null +++ b/StudyAppTests/StudierendenwerkMenuTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing +@testable import StudyApp + +@MainActor +struct StudierendenwerkMenuParserTests { + /// Excerpt of the real answer for Mensa Wechloy: meals before the first heading, then „SUPPEN“. + static let wechloy = #""" +
+
+
PREIS STUDIERENDE | BEDIENSTETE | GÄSTE
+
+
+
+
+ +
+ Zwiebelquiche
(Mi, We)
+
 
+
+ +
+ + +
+
+ Zwiebelquiche
(Mi, We)
+
+
3,30 / 5,20 / 5,40 €
+
icon
+
+ +
+

icon
+
+
+
PREIS STUDIERENDE | BEDIENSTETE | GÄSTE
+
3,30 / 5,20 / 5,40 €
+
+ +
+
 
+
+
SUPPEN
+
PREIS STUDIERENDE | BEDIENSTETE | GÄSTE
+
+
+
SUPPEN
+
+ +
+ Mexikanische Maiscremesuppe
(2, Kn, Sl)
+
 
+
+ +
+ + +
+
+ Mexikanische Maiscremesuppe
(2, Kn, Sl)
+
+
0,60 / 1,25 / 1,35 €
+
icon
+
+ +
+

icon
+
+
+
PREIS STUDIERENDE | BEDIENSTETE | GÄSTE
+
0,60 / 1,25 / 1,35 €
+
+ +
+
 
+
+ """# + + @Test func readsMealsWithCategoryPriceAndNotes() { + let meals = StudierendenwerkMenuParser.meals(Self.wechloy) + + #expect(meals.map(\.name) == ["Zwiebelquiche", "Mexikanische Maiscremesuppe"]) + #expect(meals.map(\.category) == ["Gerichte", "Suppen"]) + #expect(meals.map(\.studentPrice) == [3.3, 0.6]) + #expect(meals.map(\.id) == [0, 1]) + #expect(!StudierendenwerkMenuParser.isClosed(Self.wechloy)) + } + + @Test func readsCounterHeadingsMaximumPricesAndSeveralIcons() { + let html = """ +
AUSGABE DREI
+
+
+ Chili con Carne, Fladenbrot
(Kn, We)
+
max. 2,50* / 5,00 / 5,20 €
+
icon\ + icon
+ icon
+
+ """ + + let meal = StudierendenwerkMenuParser.meals(html).first + + #expect(meal?.name == "Chili con Carne, Fladenbrot") + #expect(meal?.category == "Ausgabe Drei") + #expect(meal?.studentPrice == 2.5) + #expect(meal?.notes == ["mit Rindfleisch"]) + } + + @Test func recognisesClosedDays() { + let html = """ +
+
Die Mensa hat an diesem Tag geschlossen.
+ """ + + #expect(StudierendenwerkMenuParser.isClosed(html)) + #expect(StudierendenwerkMenuParser.meals(html).isEmpty) + } +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 902d000..9c7b738 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -102,9 +102,11 @@ Code: `App/Intents/`, `Domain/UseCases/QuickInfoUseCases.swift` ## Mensa -Settings → Allgemein → Mensa picks a canteen near the University of Oldenburg (list from OpenMensa, nearest first). „Heute“ then shows its menu grouped by category with the student price; after 15:00 it shows tomorrow's menu. Closed days and missing menus are shown as such; the menu of a day is cached, and the app works without it offline. +Settings → Allgemein → Mensa picks one of the university's canteens: **Mensa Uhlhornsweg** (Campus Haarentor) or **Mensa Wechloy**. „Heute“ then shows its menu grouped by counter with the student price and hints such as „vegan“; after 15:00 it shows tomorrow's menu. Closed days and missing menus are shown as such; the menu of a day is cached, and the app works without it offline. -Code: `Domain/UseCases/MensaUseCases.swift`, `Data/Mensa/` (OpenMensa API v2), `Presentation/Mensa/` +The menu comes from the Studierendenwerk Oldenburg (studierendenwerk-oldenburg.de/essen/speiseplaene-mensen). Its pages embed a menu system that returns a day as HTML (`POST inc/ajax-php_konnektor.inc.php`, `func=make_spl`, location 2 = Uhlhornsweg, 3 = Wechloy); `StudierendenwerkMenuParser` reads counters, meals, prices and icons from it. A canteen chosen from OpenMensa in an earlier version is replaced by Mensa Uhlhornsweg. + +Code: `Domain/UseCases/MensaUseCases.swift`, `Data/Mensa/` (Studierendenwerk Oldenburg), `Presentation/Mensa/` ## Spotlight