// // Parsers.swift // RIT Dining // // Created by Campbell on 9/19/25. // import Foundation import SwiftSoup func parseOpenStatus(openTime: Date, closeTime: Date) -> OpenStatus { // This can probably be done a little cleaner but it's okay for now. If the location is open but the close date is within the next // 30 minutes, label it as closing soon, and do the opposite if it's closed but the open date is within the next 30 minutes. let calendar = Calendar.current let now = Date() var openStatus: OpenStatus = .closed if now >= openTime && now <= closeTime { // This is basically just for Bytes, it checks the case where the open and close times are exactly 24 hours apart, which is // only true for 24-hour locations. if closeTime == calendar.date(byAdding: .day, value: 1, to: openTime)! { openStatus = .open } else if closeTime < calendar.date(byAdding: .minute, value: 30, to: now)! { openStatus = .closingSoon } else { openStatus = .open } } else if openTime <= calendar.date(byAdding: .minute, value: 30, to: now)! && closeTime > now { openStatus = .openingSoon } else { openStatus = .closed } return openStatus } func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> DiningLocation { print("beginning parse for \(location.name)") // The descriptions sometimes have HTML
tags despite also having \n. Those need to be removed. let desc = location.description.replacingOccurrences(of: "
", with: "") // Early return if there are no events, good for things like the food trucks which can very easily have no openings in a week. if location.events.isEmpty { return DiningLocation( id: location.id, mdoId: location.mdoId, name: location.name, summary: location.summary, desc: desc, mapsUrl: location.mapsUrl, date: forDate ?? Date(), diningTimes: nil, open: .closed, visitingChefs: nil, dailySpecials: nil) } var openStrings: [String] = [] var closeStrings: [String] = [] // Dining locations have a regular schedule, but then they also have exceptions listed for days like weekends or holidays. If there // are exceptions, use those times for the day, otherwise we can just use the default times. Also check for repeats! The response data // can include those somtimes, for reasons:tm: for event in location.events { if let exceptions = event.exceptions, !exceptions.isEmpty { // Only save the exception times if the location is actually open during those times, and if these times aren't a repeat. // I've seen repeats for Brick City Cafe specifically, where both the breakfast and lunch standard open periods had // exceptions listing the same singluar brunch period. That feels like a stupid choice but oh well. if exceptions[0].open, !openStrings.contains(exceptions[0].startTime), !closeStrings.contains(exceptions[0].endTime) { openStrings.append(exceptions[0].startTime) closeStrings.append(exceptions[0].endTime) } } else { if !openStrings.contains(event.startTime), !closeStrings.contains(event.endTime) { // Verify that the current weekday falls within the schedule. The regular event schedule specifies which days of the // week it applies to, and if the current day isn't in that list and there are no exceptions, that means there are no // hours for this location. if event.daysOfWeek.contains(weekdayFromDate.string(from: forDate ?? Date()).uppercased()) { openStrings.append(event.startTime) closeStrings.append(event.endTime) } } } } // Early return if there are no valid opening times, most likely because the day's exceptions dictate that the location is closed. // Mostly comes into play on holidays. if openStrings.isEmpty || closeStrings.isEmpty { return DiningLocation( id: location.id, mdoId: location.mdoId, name: location.name, summary: location.summary, desc: desc, mapsUrl: location.mapsUrl, date: forDate ?? Date(), diningTimes: nil, open: .closed, visitingChefs: nil, dailySpecials: nil) } // I hate all of this date component nonsense. var openDates: [Date] = [] var closeDates: [Date] = [] let calendar = Calendar.current let now = Date() for i in 0.. 1 ? String(splitString[1]) : "").replacingOccurrences(of: ")", with: ""))) } } visitingChefs = chefs dailySpecials = specials } else { visitingChefs = nil dailySpecials = nil } return DiningLocation( id: location.id, mdoId: location.mdoId, name: location.name, summary: location.summary, desc: desc, mapsUrl: location.mapsUrl, date: forDate ?? Date(), diningTimes: diningTimes, open: openStatus, visitingChefs: visitingChefs, dailySpecials: dailySpecials) } extension DiningLocation { // Updates the open status of a location and of its visiting chefs, so that the labels in the UI update automatically as // time progresses and locations open/close/etc. mutating func updateOpenStatus() { var openStatus: OpenStatus = .closed if let diningTimes = diningTimes, !diningTimes.isEmpty { for i in diningTimes.indices { openStatus = parseOpenStatus(openTime: diningTimes[i].openTime, closeTime: diningTimes[i].closeTime) // If the first event pass came back closed, loop again in case a later event has a different status. This is mostly to // accurately catch Gracie's multiple open periods each day. if openStatus != .closed { break } } self.open = openStatus } else { self.open = .closed } if let visitingChefs = visitingChefs, !visitingChefs.isEmpty { let now = Date() for i in visitingChefs.indices { self.visitingChefs![i].status = switch parseOpenStatus( openTime: visitingChefs[i].openTime, closeTime: visitingChefs[i].closeTime) { case .open: .hereNow case .closed: if now < visitingChefs[i].openTime { .arrivingLater } else { .gone } case .openingSoon: .arrivingSoon case .closingSoon: .leavingSoon } } } } } // This code is actually miserable and might break sometimes. Sorry. Parse the HTML of the RIT food trucks web page and build // a list of food trucks that are going to be there the next time they're there. This is not a good way to get this data but it's // unfortunately the best way that I think I could make it happen. Sorry again for both my later self and anyone else who tries to // work on this code. func parseWeekendFoodTrucks(htmlString: String) -> [FoodTruckEvent] { do { let doc = try SwiftSoup.parse(htmlString) var events: [FoodTruckEvent] = [] let now = Date() let calendar = Calendar.current let paragraphs = try doc.select("p:has(strong)") for p in paragraphs { let text = try p.text() let parts = text.components(separatedBy: .whitespaces).joined(separator: " ") let dateRegex = /(?:(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s+[A-Za-z]+\s+\d+)/ let date = parts.firstMatch(of: dateRegex).map { String($0.0) } ?? "" if date.isEmpty { continue } let timeRegex = /(\d{1,2}(:\d{2})?\s*[-–]\s*\d{1,2}(:\d{2})?\s*p\.m\.)/ let time = parts.firstMatch(of: timeRegex).map { String($0.0) } ?? "" let locationRegex = /A-Z Lot/ let location = parts.firstMatch(of: locationRegex).map { String($0.0) } ?? "" let year = Calendar.current.component(.year, from: Date()) let fullDateString = "\(date) \(year)" let formatter = DateFormatter() formatter.dateFormat = "EEEE, MMMM d yyyy" formatter.locale = Locale(identifier: "en_US_POSIX") let dateParsed = formatter.date(from: fullDateString) ?? now let timeStrings = time.split(separator: "-", maxSplits: 1) print("raw open range: \(timeStrings)") var openTime = Date() var closeTime = Date() if let openString = timeStrings.first?.trimmingCharacters(in: .whitespaces) { // If the time is NOT in the morning, add 12 hours. let openHour = if openString.contains("a.m") { Int(openString.filter("0123456789".contains))! } else { Int(openString)! + 12 } let openTimeComponents = DateComponents(hour: openHour, minute: 0, second: 0) openTime = calendar.date( bySettingHour: openTimeComponents.hour!, minute: openTimeComponents.minute!, second: openTimeComponents.second!, of: now)! } if let closeString = timeStrings.last?.filter(":0123456789".contains) { // I've chosen to assume that no visiting chef will ever close in the morning. This could bad choice but I have // yet to see any evidence of a visiting chef leaving before noon so far. let closeStringComponents = closeString.split(separator: ":", maxSplits: 1) let closeTimeComponents = DateComponents( hour: Int(closeStringComponents[0])! + 12, minute: closeStringComponents.count > 1 ? Int(closeStringComponents[1]) : 0, second: 0) closeTime = calendar.date( bySettingHour: closeTimeComponents.hour!, minute: closeTimeComponents.minute!, second: closeTimeComponents.second!, of: now)! } if let ul = try p.nextElementSibling(), ul.tagName() == "ul" { let trucks = try ul.select("li").array().map { try $0.text() } events.append(FoodTruckEvent( date: dateParsed, openTime: openTime, closeTime: closeTime, location: location, trucks: trucks )) print(events) } } return events } catch { print(error) return [] } }