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
2 changes: 1 addition & 1 deletion App/AppContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ final class AppContainer {
studIPService = studIPClient
studIPAnnouncementService = studIPClient
studIPScheduleService = studIPClient
canteenService = OpenMensaClient()
canteenService = StudierendenwerkOldenburgClient()
if inMemory {
preferences = InMemoryStudyPreferences()
studIPAccountStore = InMemoryStudIPAccountStore()
Expand Down
45 changes: 0 additions & 45 deletions Data/Mensa/OpenMensaClient.swift

This file was deleted.

55 changes: 0 additions & 55 deletions Data/Mensa/OpenMensaParser.swift

This file was deleted.

103 changes: 103 additions & 0 deletions Data/Mensa/StudierendenwerkMenuParser.swift
Original file line number Diff line number Diff line change
@@ -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'>([^<]*)<|<div class='row splMeal'"#, in: html)
for (index, marker) in markers.enumerated() {
if let heading = marker.group(1) {
category = categoryTitle(heading)
continue
}
let end = index + 1 < markers.count ? markers[index + 1].range.lowerBound : html.endIndex
let block = String(html[marker.range.lowerBound..<end])
guard let name = mealName(in: block) else { continue }
meals.append(Meal(
id: meals.count,
name: name,
category: category,
studentPrice: studentPrice(in: block),
notes: notes(in: block)
))
}
return meals
}

// MARK: Parts of a meal

/// The name in the wide layout, without the allergen codes that follow it.
static func mealName(in block: String) -> String? {
guard let raw = matches(of: #"<div class='col-md-5[^>]*>\s*<span[^>]*>(.*?)(?:<div|</span>)"#, in: block)
.first?.group(1) else { return nil }
let name = plainText(raw)
return name.isEmpty ? nil : name
}

/// First amount of „2,50 / 4,40 / 4,60 €“; „max. 2,50*“ counts as 2,50.
static func studentPrice(in block: String) -> Double? {
guard let prices = matches(of: #"<div class='preise'><span>(.*?)</span>"#, 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 }

Check warning on line 62 in Data/Mensa/StudierendenwerkMenuParser.swift

View workflow job for this annotation

GitHub Actions / Build & Tests (macOS)

call to main actor-isolated static method 'plainText' in a synchronous nonisolated context
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 = ["&nbsp;": " ", "&amp;": "&", "&euro;": "€", "&quot;": "\"", "&#39;": "'", "&lt;": "<", "&gt;": ">"]
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<String.Index>
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..<max(result.numberOfRanges, 1)).map { index in
Range(result.range(at: index), in: text).map { String(text[$0]) }
}
return Match(range: range, groups: groups)
}
}
}
70 changes: 70 additions & 0 deletions Data/Mensa/StudierendenwerkOldenburgClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation

/// Loads the menus of the University of Oldenburg's canteens from the Studierendenwerk Oldenburg.
///
/// The Studierendenwerk's menu pages (studierendenwerk-oldenburg.de/essen/speiseplaene-mensen) embed its
/// menu system, which returns a day's menu as HTML for a `POST` with `func=make_spl`. No account is needed.
struct StudierendenwerkOldenburgClient: CanteenService {
static let endpoint = URL(string: "https://sw-oldenburg-spl56.maxmanager.xyz/inc/ajax-php_konnektor.inc.php")!

/// The canteens of the university; the IDs are the menu system's location IDs.
static let universityCanteens = [
Canteen(id: 2, name: "Mensa Uhlhornsweg", city: "Oldenburg", address: "Campus Haarentor"),
Canteen(id: 3, name: "Mensa Wechloy", city: "Oldenburg", address: "Campus Wechloy"),
]

var session: URLSession = .shared
var calendar: Calendar = .current

func canteens() async throws -> [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)
}
}
2 changes: 1 addition & 1 deletion Domain/Entities/Mensa.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions Domain/Repositories/CanteenService.swift
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion Domain/Repositories/StudyPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
8 changes: 0 additions & 8 deletions Domain/UseCases/MensaUseCases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion Presentation/Mensa/MensaCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
Expand Down
16 changes: 10 additions & 6 deletions Presentation/Mensa/MensaViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ final class MensaViewModel {
}

func refresh() async {
await loadCanteens()
guard let canteenID else {
state = .off
return
Expand All @@ -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? {
Expand Down
Loading
Loading