Commit e45681f9 by Demid Merzlyakov

Added alert info parser.

parent 9b149a6a
......@@ -154,8 +154,6 @@
CE13B88F26248A77007CBD4D /* GoogleService-Info-Staging.plist in Resources */ = {isa = PBXBuildFile; fileRef = CE13B88D26248A77007CBD4D /* GoogleService-Info-Staging.plist */; };
CE13B97B2626FB11007CBD4D /* PSMLocationSDK.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE13B7DC262478E7007CBD4D /* PSMLocationSDK.xcframework */; };
CE13B97C2626FB11007CBD4D /* PSMLocationSDK.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CE13B7DC262478E7007CBD4D /* PSMLocationSDK.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
CE13B98226272A1F007CBD4D /* AppNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE13B98126272A1F007CBD4D /* AppNotification.swift */; };
CE13B98526272E18007CBD4D /* WeatherAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE13B98426272E18007CBD4D /* WeatherAlert.swift */; };
CE13B98726273236007CBD4D /* NWSAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE13B98626273236007CBD4D /* NWSAlert.swift */; };
CE28474F26159857006C8DC5 /* HealthSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE28474E26159857006C8DC5 /* HealthSource.swift */; };
CE28475226159A32006C8DC5 /* BlendHealthModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE28475126159A32006C8DC5 /* BlendHealthModels.swift */; };
......@@ -210,6 +208,10 @@
CEDE4F0B25EFA3A7007457E9 /* UpdatableModelObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDE4F0A25EFA3A7007457E9 /* UpdatableModelObject.swift */; };
CEDE4F0F25EFA3B4007457E9 /* UpdatableModelObjectInTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDE4F0E25EFA3B4007457E9 /* UpdatableModelObjectInTime.swift */; };
CEE0A179262FA9650044C257 /* DelayedSaveStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE0A178262FA9650044C257 /* DelayedSaveStorage.swift */; };
CEE0A17B263179E60044C257 /* NWSAlertInfoBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE0A17A263179E50044C257 /* NWSAlertInfoBlock.swift */; };
CEE0A1A026317A1E0044C257 /* NWSAlertExtendedInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE0A19F26317A1E0044C257 /* NWSAlertExtendedInfo.swift */; };
CEE0A1A226317A3F0044C257 /* NWSSeverityLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE0A1A126317A3F0044C257 /* NWSSeverityLevel.swift */; };
CEE0A1A426317A8F0044C257 /* NWSAlertInfoParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE0A1A326317A8F0044C257 /* NWSAlertInfoParser.swift */; };
CEF959652600C2F900975FAA /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF959642600C2F900975FAA /* AnalyticsService.swift */; };
CEF959692600C30500975FAA /* Global.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF959682600C30500975FAA /* Global.swift */; };
CEF9596C2600C32E00975FAA /* AnalyticsEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF9596B2600C32E00975FAA /* AnalyticsEvent.swift */; };
......@@ -389,8 +391,6 @@
CE13B809262480B3007CBD4D /* Scheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Scheduler.swift; sourceTree = "<group>"; };
CE13B88C26248A77007CBD4D /* GoogleService-Info-Production.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info-Production.plist"; sourceTree = "<group>"; };
CE13B88D26248A77007CBD4D /* GoogleService-Info-Staging.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info-Staging.plist"; sourceTree = "<group>"; };
CE13B98126272A1F007CBD4D /* AppNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotification.swift; sourceTree = "<group>"; };
CE13B98426272E18007CBD4D /* WeatherAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherAlert.swift; sourceTree = "<group>"; };
CE13B98626273236007CBD4D /* NWSAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWSAlert.swift; sourceTree = "<group>"; };
CE28474E26159857006C8DC5 /* HealthSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthSource.swift; sourceTree = "<group>"; };
CE28475126159A32006C8DC5 /* BlendHealthModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlendHealthModels.swift; sourceTree = "<group>"; };
......@@ -445,6 +445,10 @@
CEDE4F0A25EFA3A7007457E9 /* UpdatableModelObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatableModelObject.swift; sourceTree = "<group>"; };
CEDE4F0E25EFA3B4007457E9 /* UpdatableModelObjectInTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatableModelObjectInTime.swift; sourceTree = "<group>"; };
CEE0A178262FA9650044C257 /* DelayedSaveStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelayedSaveStorage.swift; sourceTree = "<group>"; };
CEE0A17A263179E50044C257 /* NWSAlertInfoBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWSAlertInfoBlock.swift; sourceTree = "<group>"; };
CEE0A19F26317A1E0044C257 /* NWSAlertExtendedInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWSAlertExtendedInfo.swift; sourceTree = "<group>"; };
CEE0A1A126317A3F0044C257 /* NWSSeverityLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWSSeverityLevel.swift; sourceTree = "<group>"; };
CEE0A1A326317A8F0044C257 /* NWSAlertInfoParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWSAlertInfoParser.swift; sourceTree = "<group>"; };
CEF959642600C2F900975FAA /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
CEF959682600C30500975FAA /* Global.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Global.swift; sourceTree = "<group>"; };
CEF9596B2600C32E00975FAA /* AnalyticsEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEvent.swift; sourceTree = "<group>"; };
......@@ -1004,6 +1008,7 @@
children = (
CE13B98026272A13007CBD4D /* Model */,
CE0456232629C04C003D252B /* NWSAlertsManager.swift */,
CEE0A1A326317A8F0044C257 /* NWSAlertInfoParser.swift */,
);
path = Notifications;
sourceTree = "<group>";
......@@ -1011,8 +1016,11 @@
CE13B98026272A13007CBD4D /* Model */ = {
isa = PBXGroup;
children = (
CE13B98626273236007CBD4D /* NWSAlert.swift */,
CE04561E26282325003D252B /* NWSCurrentEventsReponse.swift */,
CE13B98626273236007CBD4D /* NWSAlert.swift */,
CEE0A1A126317A3F0044C257 /* NWSSeverityLevel.swift */,
CEE0A19F26317A1E0044C257 /* NWSAlertExtendedInfo.swift */,
CEE0A17A263179E50044C257 /* NWSAlertInfoBlock.swift */,
);
path = Model;
sourceTree = "<group>";
......@@ -1020,8 +1028,6 @@
CE13B98326272A59007CBD4D /* Notifications */ = {
isa = PBXGroup;
children = (
CE13B98126272A1F007CBD4D /* AppNotification.swift */,
CE13B98426272E18007CBD4D /* WeatherAlert.swift */,
87D81581262EFC9A0015A6D1 /* Notifications.swift */,
);
path = Notifications;
......@@ -1509,6 +1515,7 @@
CDD0F1E82572429E00CF5017 /* AppFont.swift in Sources */,
CE8962AA26175DF500CA274A /* CoreAirQuality.swift in Sources */,
CE28475D2615A5B3006C8DC5 /* Health.swift in Sources */,
CEE0A1A026317A1E0044C257 /* NWSAlertExtendedInfo.swift in Sources */,
CDC6124F25E7964700188DA7 /* TodayDayTimesCell.swift in Sources */,
CD593BC226088A5900C93428 /* TimePeriodOffsetHolder.swift in Sources */,
CE8962A226175DF500CA274A /* _CoreAirQuality.swift in Sources */,
......@@ -1553,6 +1560,7 @@
CDC6125725E7AB1A00188DA7 /* TodayAirQualityCell.swift in Sources */,
CD593BCF2608A50900C93428 /* ForecastHourlyCell.swift in Sources */,
CD6B3036257262C2004B34B3 /* UIColor+Highlight.swift in Sources */,
CEE0A17B263179E60044C257 /* NWSAlertInfoBlock.swift in Sources */,
CD1DDD332602305200AC62B2 /* ForecastInfoCell.swift in Sources */,
CEDE4E8425EEFD56007457E9 /* WdtDailySummariesArray.swift in Sources */,
CDEE8AD725DA882200C289DE /* ForecastPeriodButton.swift in Sources */,
......@@ -1627,9 +1635,11 @@
CE13B98726273236007CBD4D /* NWSAlert.swift in Sources */,
CE13B819262480B3007CBD4D /* A9Cache.swift in Sources */,
CE578FE625FB415F00E8B85D /* LocationViewController.swift in Sources */,
CEE0A1A226317A3F0044C257 /* NWSSeverityLevel.swift in Sources */,
CD86246525E66E8A0097F3FB /* PrecipitationCell.swift in Sources */,
CD80917B2578E4A8003541A4 /* UIViewController+Alert.swift in Sources */,
CEF959932600C63500975FAA /* Analytics.swift in Sources */,
CEE0A1A426317A8F0044C257 /* NWSAlertInfoParser.swift in Sources */,
CE13B821262480B3007CBD4D /* Scheduler.swift in Sources */,
CEDE4F0F25EFA3B4007457E9 /* UpdatableModelObjectInTime.swift in Sources */,
CE13B81E262480B3007CBD4D /* AdCacheManager.swift in Sources */,
......@@ -1658,11 +1668,9 @@
CEAFF0A325E0FF0800DF4EBF /* LocationManager.swift in Sources */,
CE13B7E226247BF9007CBD4D /* UserDefaultsValue.swift in Sources */,
CEAD00A12577B2D5003596AD /* StuffThatIsPresentInTheMainProject.swift in Sources */,
CE13B98226272A1F007CBD4D /* AppNotification.swift in Sources */,
CEDE4E8925EEFFEF007457E9 /* WdtDayNight.swift in Sources */,
CE13B820262480B3007CBD4D /* AdLogger.swift in Sources */,
CDF48092261729680076E9F5 /* UIApplication+Settings.swift in Sources */,
CE13B98526272E18007CBD4D /* WeatherAlert.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
......
......@@ -72,7 +72,7 @@
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
//
// Notification.swift
// 1Weather
//
// Created by Demid Merzlyakov on 14.04.2021.
//
import Foundation
protocol AppNotification {
var date: Date { get }
var title: String { get }
var text: String? { get }
var location: Location? { get }
var expires: Date? { get }
var expired: Bool { get }
}
extension AppNotification {
var expired: Bool {
guard let expires = expires else {
return false
}
return Date() < expires
}
}
//
// NWSAlert.swift
// 1Weather
//
// Created by Demid Merzlyakov on 14.04.2021.
//
import Foundation
struct WeatherAlert: AppNotification {
var date: Date
var title: String
var text: String?
var location: Location?
var expires: Date?
var whatText: String?
var whereText: String?
var whenText: String?
var impactText: String?
var instructionsText: String?
var targetAreaText: String?
}
......@@ -7,32 +7,6 @@
import Foundation
public enum NWSSeverityLevel: String, Codable, Comparable {
case warning = "1"
case watch = "2"
case advisory = "3"
public static func < (lhs: Self, rhs: Self) -> Bool {
lhs != rhs &&
(
(lhs == .warning) ||
(lhs == .watch && rhs == .advisory)
)
}
public static func <= (lhs: Self, rhs: Self) -> Bool {
lhs == rhs || lhs < rhs
}
public static func >= (lhs: Self, rhs: Self) -> Bool {
lhs == rhs || lhs > rhs
}
public static func > (lhs: Self, rhs: Self) -> Bool {
lhs != rhs && !(lhs < rhs)
}
}
public struct NWSAlert: Codable, Equatable, Hashable {
public var issueDate: Date?
public let weatherID: String
......@@ -47,6 +21,8 @@ public struct NWSAlert: Codable, Equatable, Hashable {
/// This property is set by NWSAlertManager after decoding the response.
public var city: String = ""
public var extendedInfo: NWSAlertExtendedInfo?
// Used to encode/decode a JSON object to send/recieve data with the server
private enum CodingKeys: String, CodingKey {
case weatherID
......
//
// NWSAlertExtendedInfo.swift
// 1Weather
//
// Created by Demid Merzlyakov on 22.04.2021.
//
import Foundation
public struct NWSAlertExtendedInfo: Codable {
public var title: String?
public var issueDate: Date?
public var expiryDate: Date?
public var issuer: String?
public var infoBlocks: [NWSAlertInfoBlock] = [NWSAlertInfoBlock]()
}
//
// NWSAlertInfoBlock.swift
// 1Weather
//
// Created by Demid Merzlyakov on 22.04.2021.
//
import Foundation
public struct NWSAlertInfoBlock: Codable {
let title: String?
let text: String?
}
//
// NWSSeverityLevel.swift
// 1Weather
//
// Created by Demid Merzlyakov on 22.04.2021.
//
import Foundation
public enum NWSSeverityLevel: String, Codable, Comparable {
case warning = "1"
case watch = "2"
case advisory = "3"
public static func < (lhs: Self, rhs: Self) -> Bool {
lhs != rhs &&
(
(lhs == .warning) ||
(lhs == .watch && rhs == .advisory)
)
}
public static func <= (lhs: Self, rhs: Self) -> Bool {
lhs == rhs || lhs < rhs
}
public static func >= (lhs: Self, rhs: Self) -> Bool {
lhs == rhs || lhs > rhs
}
public static func > (lhs: Self, rhs: Self) -> Bool {
lhs != rhs && !(lhs < rhs)
}
}
//
// NWSAlertInfoParser.swift
// 1Weather
//
// Created by Demid Merzlyakov on 22.04.2021.
//
import Foundation
class NWSAlertInfoParser {
public func fetchExtendedInfo(for alert: NWSAlert, completion: @escaping (NWSAlertExtendedInfo?) -> ()) {
let log = Logger(componentName: "NWSAlertInfoParser (alert \(alert.messageID))")
log.info("URL: \(alert.messageURL)")
guard let url = URL(string: alert.messageURL) else {
log.error("Bad URL")
completion(nil)
return
}
let urlSession = URLSession.shared
urlSession.dataTask(with: url) { [weak self] (data, response, error) in
guard let self = self else { return }
if let httpResponse = response as? HTTPURLResponse {
guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 400 else {
log.error("HTTP error \(httpResponse.statusCode)")
completion(nil)
return
}
}
guard let data = data, let responseString = String(data: data, encoding: .utf8) else {
log.error("Empty response")
completion(nil)
return
}
let extendedInfo: NWSAlertExtendedInfo = self.parseExtendedInfo(from: responseString)
completion(extendedInfo)
}
}
// MARK: - Private methods
private static var dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
// Date examples:
// April 15 at 4:24AM MDT
// April 14 at 9:32PM CDT
// April 15 at 9:33AM EDT
dateFormatter.dateFormat = "yyyy MMMM d 'at' h:mma zzz"
dateFormatter.locale = Locale(identifier: "en-US")
return dateFormatter
}()
private func parse(dateString: String, shouldBeInThePast: Bool) -> Date? {
// There's no year here, so we've got to guess.
let currentDate = Date()
let calendar = Calendar(identifier: .gregorian)
guard let currentYear = calendar.dateComponents([.year], from: currentDate).year else {
return nil
}
let dateStringWithCurrentYear = "\(currentYear) \(dateString)"
if shouldBeInThePast {
guard let dateWithCurrentYear = NWSAlertInfoParser.dateFormatter.date(from: dateStringWithCurrentYear) else {
return nil
}
if dateWithCurrentYear > currentDate {
let dateStringWithPreviousYear = "\(currentYear - 1) \(dateString)"
return NWSAlertInfoParser.dateFormatter.date(from: dateStringWithPreviousYear)
}
else {
return dateWithCurrentYear
}
}
else {
return NWSAlertInfoParser.dateFormatter.date(from: dateStringWithCurrentYear)
}
}
private func parse(firstLine: String) -> NWSAlertExtendedInfo {
// A few first line examples:
// Winter Weather Advisory issued April 15 at 4:24AM MDT until April 16 at 9:00AM MDT by NWS RapidCity
// Flood Warning issued April 14 at 9:32PM CDT until April 19 at 7:00AM CDT by NWS New Orleans
// Rip Current Statement issued April 15 at 9:33AM EDT until April 17 at 8:00AM EDT by NWS Mobile
// Flood Warning issued April 14 at 8:10PM CDT until April 16 at 9:00PM CDT by NWS Saint Louis
// So, the structure is:
// <TITLE> issued <ISSUE_DATE_TIME> until <EXPIRE_DATE_TIME> by <ISSUER>
var result = NWSAlertExtendedInfo()
// Using try!, since it's a hardcoded regexp, which has to be correct. If it's not, I want it to fail here and now, while I'm debugging this.
let titlePattern = "(?<title>.+?)"
let issuedDatePattern = "(?<issuedDate>[a-z0-9: ]*?)"
let expiryDatePattern = "(?<expiryDate>[a-z0-9: ]*?)"
let issuerPattern = "(?<issuer>.+)"
let fullPattern = "\(titlePattern) issued \(issuedDatePattern)(?: until \(expiryDatePattern))? by \(issuerPattern)"
let regex = try? NSRegularExpression(pattern: fullPattern, options: .caseInsensitive)
guard let firstMatch = regex?.matches(in: firstLine, range: NSRange(firstLine.startIndex..., in: firstLine)).first else {
return result
}
result.title = (firstLine as NSString).substring(with: firstMatch.range(withName: "title"))
let issuedDateString = (firstLine as NSString).substring(with: firstMatch.range(withName: "issuedDate"))
result.issueDate = parse(dateString: issuedDateString, shouldBeInThePast: true)
let expiryDateRange = firstMatch.range(withName: "expiryDate")
if expiryDateRange.location != NSNotFound {
let expiryDateString = (firstLine as NSString).substring(with: expiryDateRange)
result.expiryDate = parse(dateString: expiryDateString, shouldBeInThePast: false)
}
result.issuer = (firstLine as NSString).substring(with: firstMatch.range(withName: "issuer"))
return result
}
private func parseBlock(paragraph: String) -> NWSAlertInfoBlock? {
let firstLineAndTheRest = paragraph.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: true)
guard let firstLine = firstLineAndTheRest.first else {
return nil
}
var remainingLines: String? = nil
if firstLineAndTheRest.count > 1 {
remainingLines = String(firstLineAndTheRest[1])
}
var title: String?
var paragraphText: String?
if firstLine.contains("...") {
let firstLineBreakdown = firstLine.components(separatedBy: "...")
title = firstLineBreakdown[0]
paragraphText = firstLineBreakdown[1] + (remainingLines ?? "")
}
else {
paragraphText = firstLine + (remainingLines ?? "")
}
title = title?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
paragraphText = paragraphText?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
if title?.isEmpty == true {
title = nil
}
if paragraphText?.isEmpty == true {
paragraphText = nil
}
guard title != nil || paragraphText != nil else {
return nil
}
return NWSAlertInfoBlock(title: title, text: paragraphText)
}
private func parseBlocks(from text: String) -> [NWSAlertInfoBlock] {
var result = [NWSAlertInfoBlock]()
/*
The format of alerts is not strict, but there's some form of structure and similarities.
We're going to try our best to parse the text into meaningful blocks.
The overall structure is:
FIRST_LINE (see parse(firstLine:) )
REST_OF_TEXT (that's what we're here for)
In REST_OF_TEXT there may be just plain text, paragraphs without titlees, paragraphs with titles.
Some common elements include
• Paragraph with title:
* TITLE...PARAGRAPH_TEXT
or
...TITLE...
PARAGRAPH_TEXT
Note: sometimes in case of ...TITLE... there's just title, without the text.
• Paragraph without title:
* PARAGRAPH TEXT
• Plain text is just plain text.
*/
let partialParagraphs = text.components(separatedBy: "\n* ")
for partialParagraph in partialParagraphs {
for paragraph in partialParagraph.components(separatedBy: "\n...") {
let trimmed = paragraph.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
if let block = parseBlock(paragraph: trimmed) {
result.append(block)
}
}
}
return result
}
private func parseExtendedInfo(from response: String) -> NWSAlertExtendedInfo {
guard let firstLineBreakIndex = response.firstIndex(of: "\n") else {
return NWSAlertExtendedInfo()
}
let firstLine = response.prefix(upTo: firstLineBreakIndex)
let remainingText = response.suffix(from: firstLine.endIndex)
var extendedInfo: NWSAlertExtendedInfo = parse(firstLine: String(firstLine))
extendedInfo.infoBlocks = parseBlocks(from: String(remainingText))
return extendedInfo
}
}
......@@ -51,9 +51,6 @@ extension String {
return self.trimmingCharacters(in: CharacterSet.whitespaces)
}
func trimSpaceNewlines() -> String {
return self.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}
func trim(_ characters: String) -> String {
return self.trimmingCharacters(in: CharacterSet(charactersIn: characters))
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment