diff options
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/App/AppStorageKeys.swift | 2 | ||||
| -rw-r--r-- | Hutch/Networking/SystemStatusCacheStore.swift | 59 | ||||
| -rw-r--r-- | Hutch/Networking/SystemStatusRepository.swift | 172 | ||||
| -rw-r--r-- | Hutch/Networking/SystemStatusService.swift | 118 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 48 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 28 | ||||
| -rw-r--r-- | Hutch/Views/More/MoreView.swift | 27 | ||||
| -rw-r--r-- | Hutch/Views/More/MoreViewModel.swift | 39 | ||||
| -rw-r--r-- | Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift | 119 | ||||
| -rw-r--r-- | Hutch/Views/SystemStatus/SystemStatusView.swift | 8 | ||||
| -rw-r--r-- | Hutch/Views/SystemStatus/SystemStatusViewModel.swift | 34 | ||||
| -rw-r--r-- | HutchTests/AppStateTests.swift | 11 | ||||
| -rw-r--r-- | HutchTests/SystemStatusRepositoryTests.swift | 147 | ||||
| -rw-r--r-- | HutchTests/SystemStatusServiceTests.swift | 63 |
15 files changed, 790 insertions, 101 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 2846239..ed3e455 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -515,7 +515,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -532,7 +532,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.13.0; + MARKETING_VERSION = 2.13.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -552,7 +552,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.13.0; + MARKETING_VERSION = 2.13.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -632,7 +632,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -642,7 +642,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.13.0; + MARKETING_VERSION = 2.13.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -661,7 +661,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -671,7 +671,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.13.0; + MARKETING_VERSION = 2.13.1; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift index dcf38ba..c1b39c4 100644 --- a/Hutch/App/AppStorageKeys.swift +++ b/Hutch/App/AppStorageKeys.swift @@ -6,4 +6,6 @@ enum AppStorageKeys { static let wrapRepositoryFileLines = "wrapRepositoryFileLines" static let lookupHistory = "lookupHistory" static let hutchStatsBaseURL = "hutchStatsBaseURL" + static let systemStatusSnapshotCache = "systemStatusSnapshotCache" + static let systemStatusIncidentCache = "systemStatusIncidentCache" } diff --git a/Hutch/Networking/SystemStatusCacheStore.swift b/Hutch/Networking/SystemStatusCacheStore.swift new file mode 100644 index 0000000..63c64b9 --- /dev/null +++ b/Hutch/Networking/SystemStatusCacheStore.swift @@ -0,0 +1,59 @@ +import Foundation + +actor SystemStatusCacheStore { + private let snapshotCacheKey = "systemStatusSnapshotCache" + private let incidentCacheKey = "systemStatusIncidentCache" + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func loadSnapshotHTML() -> (html: String, timestamp: Date)? { + guard let html = defaults.string(forKey: snapshotCacheKey) else { + return nil + } + + let timestampValue = defaults.double(forKey: snapshotTimestampKey) + guard timestampValue > 0 else { + defaults.removeObject(forKey: snapshotCacheKey) + defaults.removeObject(forKey: snapshotTimestampKey) + return nil + } + + return (html, Date(timeIntervalSince1970: timestampValue)) + } + + func saveSnapshotHTML(_ html: String, timestamp: Date) { + defaults.set(html, forKey: snapshotCacheKey) + defaults.set(timestamp.timeIntervalSince1970, forKey: snapshotTimestampKey) + } + + func loadIncidentFeedData() -> (data: Data, timestamp: Date)? { + guard let data = defaults.data(forKey: incidentCacheKey) else { + return nil + } + + let timestampValue = defaults.double(forKey: incidentTimestampKey) + guard timestampValue > 0 else { + defaults.removeObject(forKey: incidentCacheKey) + defaults.removeObject(forKey: incidentTimestampKey) + return nil + } + + return (data, Date(timeIntervalSince1970: timestampValue)) + } + + func saveIncidentFeedData(_ data: Data, timestamp: Date) { + defaults.set(data, forKey: incidentCacheKey) + defaults.set(timestamp.timeIntervalSince1970, forKey: incidentTimestampKey) + } + + private var snapshotTimestampKey: String { + "\(snapshotCacheKey).timestamp" + } + + private var incidentTimestampKey: String { + "\(incidentCacheKey).timestamp" + } +} diff --git a/Hutch/Networking/SystemStatusRepository.swift b/Hutch/Networking/SystemStatusRepository.swift index 48c5b16..5f51315 100644 --- a/Hutch/Networking/SystemStatusRepository.swift +++ b/Hutch/Networking/SystemStatusRepository.swift @@ -1,57 +1,193 @@ import Foundation +protocol SystemStatusServing: Sendable { + func fetchSnapshotHTML() async throws -> String + func fetchIncidentFeedData() async throws -> Data +} + +struct CachedSystemStatusValue<Value: Sendable>: Sendable { + let value: Value + let lastSuccessfulAt: Date + let isStale: Bool + let refreshErrorMessage: String? +} + actor SystemStatusRepository { - private let service: SystemStatusService + private let service: any SystemStatusServing private let ttl: TimeInterval + private let cacheStore: SystemStatusCacheStore + private let now: @Sendable () -> Date private var snapshotCache: CacheEntry<SystemStatusSnapshot>? private var incidentsCache: CacheEntry<[StatusIncident]>? + private var hasLoadedPersistentCache = false - init(service: SystemStatusService = SystemStatusService(), ttl: TimeInterval = 10 * 60) { + init( + service: any SystemStatusServing = SystemStatusService(), + ttl: TimeInterval = 10 * 60, + cacheStore: SystemStatusCacheStore = SystemStatusCacheStore(), + now: @escaping @Sendable () -> Date = Date.init + ) { self.service = service self.ttl = ttl + self.cacheStore = cacheStore + self.now = now } func snapshot(forceRefresh: Bool = false) async throws -> SystemStatusSnapshot { - if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl) { - return cached.value + try await snapshotResult(forceRefresh: forceRefresh).value + } + + func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] { + try await recentIncidentsResult(forceRefresh: forceRefresh).value + } + + func snapshotResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<SystemStatusSnapshot> { + await loadPersistentCacheIfNeeded() + + if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) { + return CachedSystemStatusValue( + value: cached.value, + lastSuccessfulAt: cached.timestamp, + isStale: false, + refreshErrorMessage: nil + ) } do { - let snapshot = try await service.fetchSnapshot() - snapshotCache = CacheEntry(value: snapshot, timestamp: Date()) - return snapshot + let html = try await service.fetchSnapshotHTML() + let snapshot = try SystemStatusService.parseSnapshotHTML(html, fetchedAt: now()) + let entry = CacheEntry(value: snapshot, timestamp: now()) + snapshotCache = entry + await cacheStore.saveSnapshotHTML(html, timestamp: entry.timestamp) + return CachedSystemStatusValue( + value: snapshot, + lastSuccessfulAt: entry.timestamp, + isStale: false, + refreshErrorMessage: nil + ) } catch { if let cached = snapshotCache { - return cached.value + return CachedSystemStatusValue( + value: cached.value, + lastSuccessfulAt: cached.timestamp, + isStale: true, + refreshErrorMessage: refreshErrorMessage(from: error) + ) } throw error } } - func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] { - if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl) { - return cached.value + func recentIncidentsResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<[StatusIncident]> { + await loadPersistentCacheIfNeeded() + + if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) { + return CachedSystemStatusValue( + value: cached.value, + lastSuccessfulAt: cached.timestamp, + isStale: false, + refreshErrorMessage: nil + ) } do { - let incidents = try await service.fetchIncidentFeed() - incidentsCache = CacheEntry(value: incidents, timestamp: Date()) - return incidents + let feedData = try await service.fetchIncidentFeedData() + let incidents = try await SystemStatusService.parseIncidentFeedXML(feedData) + let entry = CacheEntry(value: incidents, timestamp: now()) + incidentsCache = entry + await cacheStore.saveIncidentFeedData(feedData, timestamp: entry.timestamp) + return CachedSystemStatusValue( + value: incidents, + lastSuccessfulAt: entry.timestamp, + isStale: false, + refreshErrorMessage: nil + ) } catch { if let cached = incidentsCache { - return cached.value + return CachedSystemStatusValue( + value: cached.value, + lastSuccessfulAt: cached.timestamp, + isStale: true, + refreshErrorMessage: refreshErrorMessage(from: error) + ) } throw error } } + + private func loadPersistentCacheIfNeeded() async { + guard !hasLoadedPersistentCache else { return } + if let persistedSnapshot = await cacheStore.loadSnapshotHTML(), + let snapshot = try? SystemStatusService.parseSnapshotHTML(persistedSnapshot.html, fetchedAt: persistedSnapshot.timestamp) { + snapshotCache = CacheEntry(value: snapshot, timestamp: persistedSnapshot.timestamp) + } + if let persistedFeed = await cacheStore.loadIncidentFeedData(), + let incidents = try? await SystemStatusService.parseIncidentFeedXML(persistedFeed.data) { + incidentsCache = CacheEntry(value: incidents, timestamp: persistedFeed.timestamp) + } + hasLoadedPersistentCache = true + } + + private func refreshErrorMessage(from error: any Error) -> String { + if let error = error as? SRHTError { + switch error { + case .graphQLErrors(let errors): + let firstMessage = errors.first?.message.lowercased() ?? "" + if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") { + return "You do not have permission to do that." + } + if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") { + return "That content is no longer available." + } + return "Something went wrong. Please try again." + case .httpError(let code): + if code == 401 { + return "Please sign in again." + } + if code == 403 { + return "You do not have permission to do that." + } + if code == 404 { + return "That content is no longer available." + } + if (500...599).contains(code) { + return "The server is unavailable right now. Please try again." + } + return "Something went wrong. Please try again." + case .invalidAuthenticatedURL: + return "That request could not be completed." + case .decodingError: + return "The response could not be loaded right now." + case .networkError(let underlyingError): + return refreshErrorMessage(from: underlyingError) + case .unauthorized: + return "Please sign in again." + } + } + + let nsError = error as NSError + switch nsError.code { + case NSURLErrorNotConnectedToInternet, + NSURLErrorNetworkConnectionLost, + NSURLErrorTimedOut, + NSURLErrorCannotFindHost, + NSURLErrorCannotConnectToHost, + NSURLErrorDNSLookupFailed, + NSURLErrorInternationalRoamingOff, + NSURLErrorDataNotAllowed: + return "Check your connection and try again." + default: + return "Something went wrong. Please try again." + } + } } -private struct CacheEntry<Value: Sendable>: Sendable { +struct CacheEntry<Value: Sendable>: Sendable { let value: Value let timestamp: Date - nonisolated func isExpired(ttl: TimeInterval) -> Bool { - Date().timeIntervalSince(timestamp) > ttl + nonisolated func isExpired(ttl: TimeInterval, now: @escaping @Sendable () -> Date = Date.init) -> Bool { + now().timeIntervalSince(timestamp) > ttl } } diff --git a/Hutch/Networking/SystemStatusService.swift b/Hutch/Networking/SystemStatusService.swift index fd38fd3..5dd27b5 100644 --- a/Hutch/Networking/SystemStatusService.swift +++ b/Hutch/Networking/SystemStatusService.swift @@ -13,15 +13,23 @@ struct SystemStatusService: Sendable { } func fetchSnapshot() async throws -> SystemStatusSnapshot { - let html = try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml") + let html = try await fetchSnapshotHTML() return try Self.parseSnapshotHTML(html, fetchedAt: now()) } func fetchIncidentFeed() async throws -> [StatusIncident] { - let data = try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml") + let data = try await fetchIncidentFeedData() return try await Self.parseIncidentFeedXML(data) } + func fetchSnapshotHTML() async throws -> String { + try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml") + } + + func fetchIncidentFeedData() async throws -> Data { + try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml") + } + private func fetchText(from url: URL, accept: String) async throws -> String { let data = try await fetchData(from: url, accept: accept) guard let text = String(data: data, encoding: .utf8) else { @@ -62,6 +70,8 @@ struct SystemStatusService: Sendable { } } +extension SystemStatusService: SystemStatusServing {} + extension SystemStatusService { nonisolated static func parseSnapshotHTML(_ html: String, fetchedAt: Date) throws -> SystemStatusSnapshot { let services = parseServices(in: html) @@ -96,7 +106,7 @@ extension SystemStatusService { nonisolated private static func parseServices(in html: String) -> [StatusServiceState] { firstMatches( in: html, - pattern: #"<div class="component" data-status="([^"]+)">([\s\S]*?)</div>"# + pattern: #"<div\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bcomponent\b[^"']*["'])(?=[^>]*\bdata-status\s*=\s*["']([^"']+)["'])[^>]*>([\s\S]*?)</div>"# ).compactMap { captures in guard captures.count >= 2 else { return nil } @@ -104,16 +114,18 @@ extension SystemStatusService { let content = captures[1] guard let linkCaptures = firstMatches( in: content, - pattern: #"<a[^>]*href="([^"]+)"[^>]*>\s*(.*?)\s*</a>"# + pattern: #"<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)</a>"# ).first, - linkCaptures.count >= 2, - let statusText = firstMatch(in: content, pattern: #"<span class="component-status">\s*(.*?)\s*</span>"#) else { + linkCaptures.count >= 2 else { return nil } let href = linkCaptures[0] let cleanedName = cleanText(linkCaptures[1]) - let readableStatus = cleanText(statusText) + let readableStatus = firstMatch( + in: content, + pattern: #"<(?:span|small|div)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bcomponent-status\b[^"']*["'])[^>]*>([\s\S]*?)</(?:span|small|div)>"# + ).map(cleanText) ?? firstStatusLabel(in: content) ?? "" let level = statusLevel(fromHTMLStatus: rawStatus) return StatusServiceState( @@ -129,19 +141,19 @@ extension SystemStatusService { nonisolated private static func parseHTMLIncidentCards(in html: String) -> [StatusIncident] { firstMatches( in: html, - pattern: #"<a href="([^"]+)" class="issue no-underline">([\s\S]*?)</a>"# + pattern: #"<a\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bissue\b[^"']*["'])(?=[^>]*\bhref\s*=\s*["']([^"']+)["'])[^>]*>([\s\S]*?)</a>"# ).compactMap { captures in guard captures.count >= 2 else { return nil } let href = captures[0] let content = captures[1] - guard let titleHTML = firstMatch(in: content, pattern: #"<h3>\s*([\s\S]*?)\s*</h3>"#), - let titleAttribute = firstMatch(in: content, pattern: #"<small class="date[^"]*" title="([^"]+)">"#), - let publishedAt = htmlIssueDateFormatter.date(from: cleanText(titleAttribute)) else { + guard let titleHTML = firstMatch(in: content, pattern: #"<h[1-6][^>]*>\s*([\s\S]*?)\s*</h[1-6]>"#) + ?? firstMatch(in: content, pattern: #"<strong[^>]*>\s*([\s\S]*?)\s*</strong>"#) + ?? firstMatch(in: content, pattern: #"<span[^>]*>\s*([\s\S]*?)\s*</span>"#), + let publishedAt = publishedIncidentDate(in: content) else { return nil } let url = URL(string: href, relativeTo: statusURL)?.absoluteURL - let isActive = content.localizedCaseInsensitiveContains("This issue is not resolved yet") return StatusIncident( id: url?.absoluteString ?? cleanText(titleHTML), title: cleanText(titleHTML), @@ -149,7 +161,7 @@ extension SystemStatusService { url: url, publishedAt: publishedAt, updatedAt: nil, - isActive: isActive + isActive: isActiveIncidentCard(content) ) } } @@ -157,12 +169,12 @@ extension SystemStatusService { nonisolated private static func parseActiveIncidentSummaries(in html: String) -> [String: String] { firstMatches( in: html, - pattern: #"<div class="announcement-box"[\s\S]*?<div class="padding">([\s\S]*?)</div>\s*<hr class="clean announcement-box">"# + pattern: #"<div\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bannouncement-box\b[^"']*["'])[^>]*>([\s\S]*?)</div>\s*(?:<hr\b[^>]*\bannouncement-box\b[^>]*>)?"# ).reduce(into: [:]) { partialResult, captures in guard let content = captures.first, let titleLinkCaptures = firstMatches( in: content, - pattern: #"<a href="([^"]+)"><strong class="bold">([\s\S]*?)</strong></a>"# + pattern: #"<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)</a>"# ).first, let href = titleLinkCaptures.first else { return @@ -197,17 +209,22 @@ extension SystemStatusService { } nonisolated private static func statusLevel(fromLabel label: String) -> StatusLevel { - switch label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "operational": - .operational - case "disrupted", "degraded": - .degraded - case "down", "major outage": - .majorOutage - case "maintenance": - .maintenance + let normalizedLabel = label + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + + return switch normalizedLabel { + case "operational", "all systems operational": + StatusLevel.operational + case "disrupted", "degraded", "partial outage": + StatusLevel.degraded + case "down", "major outage", "outage": + StatusLevel.majorOutage + case "maintenance", "scheduled maintenance", "under maintenance": + StatusLevel.maintenance default: - .unknown + StatusLevel.unknown } } @@ -252,6 +269,47 @@ extension SystemStatusService { .trimmingCharacters(in: .whitespacesAndNewlines) } + nonisolated private static func firstStatusLabel(in text: String) -> String? { + cleanText(text) + .split(separator: "•") + .map(String.init) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first(where: { statusLevel(fromLabel: $0) != .unknown }) + } + + nonisolated private static func publishedIncidentDate(in content: String) -> Date? { + if let timeValue = firstMatch(in: content, pattern: #"<time\b[^>]*\bdatetime\s*=\s*["']([^"']+)["'][^>]*>"#), + let parsed = parseISO8601Date(cleanText(timeValue)) { + return parsed + } + + if let titleAttribute = firstMatch( + in: content, + pattern: #"<(?:small|time)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bdate\b[^"']*["'])[^>]*\btitle\s*=\s*["']([^"']+)["'][^>]*>"# + ) ?? firstMatch(in: content, pattern: #"\btitle\s*=\s*["']([^"']+UTC)["']"#) { + return htmlIssueDateFormatter.date(from: cleanText(titleAttribute)) + } + + return nil + } + + nonisolated private static func isActiveIncidentCard(_ content: String) -> Bool { + let normalizedContent = cleanText(content).lowercased() + return normalizedContent.contains("not resolved yet") + || normalizedContent.contains("ongoing") + || normalizedContent.contains("investigating") + } + + nonisolated fileprivate static func parseISO8601Date(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: value) ?? { + let fallbackFormatter = ISO8601DateFormatter() + fallbackFormatter.formatOptions = [.withInternetDateTime] + return fallbackFormatter.date(from: value) + }() + } + nonisolated private static let htmlIssueDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -259,9 +317,9 @@ extension SystemStatusService { formatter.dateFormat = "MMM d HH:mm:ss yyyy zzz" return formatter }() + } -@MainActor private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchecked Sendable { private var incidents: [StatusIncident] = [] private var currentItem: FeedItem? @@ -315,7 +373,7 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec currentItem.guid = value case "description": currentItem.description = value - case "pubDate": + case "pubDate", "dc:date": currentItem.pubDate = value case "category": currentItem.category = value @@ -345,7 +403,7 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec func makeIncident() -> StatusIncident? { let cleanedTitle = title.replacingOccurrences(of: "[Resolved] ", with: "") guard !cleanedTitle.isEmpty, - let publishedAt = SystemStatusFeedParser.pubDateFormatter.date(from: pubDate) else { + let publishedAt = SystemStatusFeedParser.parsePubDate(pubDate) else { return nil } @@ -382,6 +440,10 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec return decodeHTMLEntities(stripped) } + nonisolated private static func parsePubDate(_ value: String) -> Date? { + pubDateFormatter.date(from: value) ?? SystemStatusService.parseISO8601Date(value) + } + nonisolated private static let pubDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index ab0cdf8..632e804 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -78,19 +78,25 @@ struct HomeView: View { .refreshable { await viewModel.loadDashboard() } + .connectivityOverlay(hasContent: viewModel.hasDashboardContent) { + await viewModel.loadDashboard() + } } @ViewBuilder private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View { - if let bannerTitle = viewModel.systemStatusBannerTitle { - Section { - NavigationLink { - SystemStatusView() - } label: { - HomeSystemStatusBanner(title: bannerTitle) - } - .buttonStyle(.plain) + Section { + NavigationLink { + SystemStatusView() + } label: { + SystemStatusSummaryRow( + snapshot: viewModel.systemStatusSnapshot, + isLoading: viewModel.isLoadingSystemStatus, + errorMessage: viewModel.systemStatusErrorMessage, + isShowingStaleData: viewModel.isShowingStaleSystemStatus + ) } + .buttonStyle(.plain) } } @@ -252,32 +258,6 @@ private struct HomeInboxToolbarIcon: View { } } -private struct HomeSystemStatusBanner: View { - let title: String - - var body: some View { - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - VStack(alignment: .leading, spacing: 2) { - Text("SourceHut service disruption") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - Text(title) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .padding(.vertical, 4) - .contentShape(Rectangle()) - } -} - private struct HomeProjectRow: View { let project: Project diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index a06df20..ba00a36 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -144,6 +144,9 @@ final class HomeViewModel { var assignedTickets: [HomeAssignedTicket] = [] var recentBuilds: [HomeBuildItem] = [] private(set) var systemStatusSnapshot: SystemStatusSnapshot? + private(set) var isLoadingSystemStatus = false + private(set) var isShowingStaleSystemStatus = false + private(set) var systemStatusErrorMessage: String? private(set) var hasUnreadInboxThreads = false private(set) var unreadInboxThreadCount: Int? private(set) var isLoadingProjects = false @@ -279,8 +282,11 @@ final class HomeViewModel { isLoadingProjects = true isLoadingAssignedTickets = true isLoadingRecentBuilds = true + isLoadingSystemStatus = true assignedTicketsError = nil recentBuildsError = nil + isShowingStaleSystemStatus = false + systemStatusErrorMessage = nil async let projectsTask = loadProjects() async let jobsTask = loadRecentJobs() @@ -324,13 +330,21 @@ final class HomeViewModel { unreadInboxThreadCount = await inboxUnreadTask hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 - systemStatusSnapshot = await systemStatusTask + let systemStatusResult = await systemStatusTask + switch systemStatusResult { + case .success(let result): + systemStatusSnapshot = result.value + isShowingStaleSystemStatus = result.isStale + systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil + case .failure(let error): + systemStatusErrorMessage = error.userFacingMessage + } + isLoadingSystemStatus = false persistNeedsAttentionSnapshot() } - var systemStatusBannerTitle: String? { - guard let systemStatusSnapshot, systemStatusSnapshot.hasDisruption else { return nil } - return systemStatusSnapshot.bannerSummary + var hasDashboardContent: Bool { + !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || systemStatusSnapshot != nil } func resolveTicket(_ ticket: HomeAssignedTicket) async { @@ -430,11 +444,11 @@ final class HomeViewModel { } } - private func loadSystemStatusSnapshot() async -> SystemStatusSnapshot? { + private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> { do { - return try await systemStatusRepository.snapshot() + return .success(try await systemStatusRepository.snapshotResult()) } catch { - return systemStatusSnapshot + return .failure(error) } } diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift index 284b1ad..6325954 100644 --- a/Hutch/Views/More/MoreView.swift +++ b/Hutch/Views/More/MoreView.swift @@ -7,6 +7,7 @@ struct MoreView: View { ("chat.sr.ht", SRHTWebURL.chat) ] + @State private var viewModel: MoreViewModel? @State private var showAccountSwitcher = false var body: some View { @@ -29,9 +30,14 @@ struct MoreView: View { NavigationLink(value: MoreRoute.pastes) { Label("Pastes", systemImage: "doc.on.clipboard") } - + NavigationLink(value: MoreRoute.systemStatus) { - Label("System Status", systemImage: "server.rack") + SystemStatusSummaryRow( + snapshot: viewModel?.systemStatusSnapshot, + isLoading: viewModel?.isLoadingSystemStatus ?? true, + errorMessage: viewModel?.systemStatusErrorMessage, + isShowingStaleData: viewModel?.isShowingStaleSystemStatus ?? false + ) } } @@ -58,6 +64,12 @@ struct MoreView: View { } } .navigationTitle("More") + .refreshable { + await ensureViewModel().loadSystemStatus(forceRefresh: true) + } + .task { + await ensureViewModel().loadIfNeeded() + } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { @@ -71,4 +83,15 @@ struct MoreView: View { AccountSwitcherView() } } + + @MainActor + private func ensureViewModel() -> MoreViewModel { + if let viewModel { + return viewModel + } + + let newViewModel = MoreViewModel(repository: appState.systemStatusRepository) + viewModel = newViewModel + return newViewModel + } } diff --git a/Hutch/Views/More/MoreViewModel.swift b/Hutch/Views/More/MoreViewModel.swift new file mode 100644 index 0000000..8f23975 --- /dev/null +++ b/Hutch/Views/More/MoreViewModel.swift @@ -0,0 +1,39 @@ +import Foundation + +@Observable +@MainActor +final class MoreViewModel { + private let repository: SystemStatusRepository + + private(set) var systemStatusSnapshot: SystemStatusSnapshot? + private(set) var isLoadingSystemStatus = false + private(set) var isShowingStaleSystemStatus = false + private(set) var systemStatusErrorMessage: String? + + init(repository: SystemStatusRepository) { + self.repository = repository + } + + func loadIfNeeded() async { + guard systemStatusSnapshot == nil, !isLoadingSystemStatus else { return } + await loadSystemStatus() + } + + func loadSystemStatus(forceRefresh: Bool = false) async { + isLoadingSystemStatus = true + defer { isLoadingSystemStatus = false } + isShowingStaleSystemStatus = false + systemStatusErrorMessage = nil + + do { + let result = try await repository.snapshotResult(forceRefresh: forceRefresh) + systemStatusSnapshot = result.value + isShowingStaleSystemStatus = result.isStale + systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil + } catch { + if systemStatusSnapshot == nil { + systemStatusErrorMessage = error.userFacingMessage + } + } + } +} diff --git a/Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift b/Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift new file mode 100644 index 0000000..5d94dad --- /dev/null +++ b/Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct SystemStatusSummaryRow: View { + let title: String + let snapshot: SystemStatusSnapshot? + let isLoading: Bool + let errorMessage: String? + let isShowingStaleData: Bool + + init( + title: String = "System Status", + snapshot: SystemStatusSnapshot?, + isLoading: Bool = false, + errorMessage: String? = nil, + isShowingStaleData: Bool = false + ) { + self.title = title + self.snapshot = snapshot + self.isLoading = isLoading + self.errorMessage = errorMessage + self.isShowingStaleData = isShowingStaleData + } + + var body: some View { + HStack(spacing: 12) { + icon + .frame(width: 20) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + + Text(primaryMessage) + .font(.caption) + .foregroundStyle(primaryMessageColor) + .lineLimit(2) + + if let metadataMessage { + Text(metadataMessage) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + + @ViewBuilder + private var icon: some View { + if isLoading && snapshot == nil { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: iconName) + .foregroundStyle(iconColor) + } + } + + private var primaryMessage: String { + if let snapshot { + return snapshot.hasDisruption ? snapshot.bannerSummary : snapshot.overallStatusText + } + if let errorMessage, !errorMessage.isEmpty { + return errorMessage + } + if isLoading { + return "Loading system status…" + } + return "System status is unavailable right now." + } + + private var metadataMessage: String? { + if let snapshot { + if isShowingStaleData { + return "Updated \(snapshot.lastUpdated.relativeDescription) • Showing saved data" + } + return "Updated \(snapshot.lastUpdated.relativeDescription)" + } + if errorMessage != nil { + return "Open System Status to retry." + } + return nil + } + + private var iconName: String { + if let snapshot { + return snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill" + } + if errorMessage != nil { + return "exclamationmark.triangle" + } + return "server.rack" + } + + private var iconColor: Color { + if let snapshot { + return snapshot.hasDisruption ? .orange : .green + } + if errorMessage != nil { + return .secondary + } + return .secondary + } + + private var primaryMessageColor: Color { + if snapshot != nil { + return .secondary + } + if errorMessage != nil { + return .secondary + } + return .secondary + } +} diff --git a/Hutch/Views/SystemStatus/SystemStatusView.swift b/Hutch/Views/SystemStatus/SystemStatusView.swift index 053210e..08a46eb 100644 --- a/Hutch/Views/SystemStatus/SystemStatusView.swift +++ b/Hutch/Views/SystemStatus/SystemStatusView.swift @@ -31,6 +31,14 @@ struct SystemStatusView: View { @ViewBuilder private func content(_ viewModel: SystemStatusViewModel) -> some View { List { + if viewModel.isShowingStaleData, let staleDataMessage = viewModel.staleDataMessage { + Section { + Label(staleDataMessage, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + if let snapshot = viewModel.snapshot { summarySection(snapshot) servicesSection(snapshot) diff --git a/Hutch/Views/SystemStatus/SystemStatusViewModel.swift b/Hutch/Views/SystemStatus/SystemStatusViewModel.swift index 646d7ca..7700208 100644 --- a/Hutch/Views/SystemStatus/SystemStatusViewModel.swift +++ b/Hutch/Views/SystemStatus/SystemStatusViewModel.swift @@ -8,6 +8,8 @@ final class SystemStatusViewModel { private(set) var snapshot: SystemStatusSnapshot? private(set) var recentIncidents: [StatusIncident] = [] private(set) var isLoading = false + private(set) var isShowingStaleData = false + private(set) var staleDataMessage: String? var errorMessage: String? init(repository: SystemStatusRepository) { @@ -25,12 +27,24 @@ final class SystemStatusViewModel { defer { isLoading = false } errorMessage = nil + staleDataMessage = nil + isShowingStaleData = false - async let snapshotTask = repository.snapshot(forceRefresh: forceRefresh) - async let incidentsTask = repository.recentIncidents(forceRefresh: forceRefresh) + async let snapshotTask = repository.snapshotResult(forceRefresh: forceRefresh) + async let incidentsTask = repository.recentIncidentsResult(forceRefresh: forceRefresh) + + var refreshWarnings: [String] = [] do { - snapshot = try await snapshotTask + let result = try await snapshotTask + snapshot = result.value + if result.isStale { + isShowingStaleData = true + staleDataMessage = "Showing the last saved system status snapshot." + if let warning = result.refreshErrorMessage { + refreshWarnings.append(warning) + } + } } catch { if snapshot == nil { errorMessage = error.userFacingMessage @@ -38,11 +52,23 @@ final class SystemStatusViewModel { } do { - recentIncidents = try await incidentsTask + let result = try await incidentsTask + recentIncidents = result.value + if result.isStale { + isShowingStaleData = true + staleDataMessage = staleDataMessage ?? "Showing the last saved incident history." + if let warning = result.refreshErrorMessage { + refreshWarnings.append(warning) + } + } } catch { if errorMessage == nil && recentIncidents.isEmpty { errorMessage = error.userFacingMessage } } + + if hasContent, let firstWarning = refreshWarnings.first { + errorMessage = "Showing cached system status. \(firstWarning)" + } } } diff --git a/HutchTests/AppStateTests.swift b/HutchTests/AppStateTests.swift index ca34df2..9cf4e13 100644 --- a/HutchTests/AppStateTests.swift +++ b/HutchTests/AppStateTests.swift @@ -22,4 +22,15 @@ struct AppStateTests { #expect(appState.deepLinkError == "The ticket could not be found or is inaccessible.") } + + @Test + @MainActor + func openSystemStatusSelectsMoreTabAndQueuesNavigation() { + let appState = AppState() + + appState.openSystemStatus() + + #expect(appState.selectedTab == .more) + #expect(appState.pendingTabNavigation == .systemStatus) + } } diff --git a/HutchTests/SystemStatusRepositoryTests.swift b/HutchTests/SystemStatusRepositoryTests.swift new file mode 100644 index 0000000..fb08732 --- /dev/null +++ b/HutchTests/SystemStatusRepositoryTests.swift @@ -0,0 +1,147 @@ +import Foundation +import Testing +@testable import Hutch + +struct SystemStatusRepositoryTests { + + @Test + func fallsBackToPersistedSnapshotWhenRefreshFails() async throws { + let defaultsName = "SystemStatusRepositoryTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsName)) + defaults.removePersistentDomain(forName: defaultsName) + defer { defaults.removePersistentDomain(forName: defaultsName) } + + let cachedSnapshot = SystemStatusSnapshot( + services: [ + StatusServiceState(id: "git.sr.ht", name: "git.sr.ht", slug: "git.sr.ht", status: .degraded, description: nil) + ], + activeIncidents: [], + lastUpdated: Date(timeIntervalSince1970: 120) + ) + let cacheStore = SystemStatusCacheStore(defaults: defaults) + let initialRepository = SystemStatusRepository( + service: TestSystemStatusService( + snapshotHTMLHandler: { Self.cachedSnapshotHTML }, + incidentsDataHandler: { Data(Self.emptyRSS.utf8) } + ), + cacheStore: cacheStore, + now: { Date(timeIntervalSince1970: 120) } + ) + + _ = try await initialRepository.snapshotResult(forceRefresh: true) + + let fallbackRepository = SystemStatusRepository( + service: TestSystemStatusService( + snapshotHTMLHandler: { throw SRHTError.httpError(503) }, + incidentsDataHandler: { Data(Self.emptyRSS.utf8) } + ), + ttl: 0, + cacheStore: cacheStore, + now: { Date(timeIntervalSince1970: 180) } + ) + + let result = try await fallbackRepository.snapshotResult(forceRefresh: true) + + #expect(result.value == cachedSnapshot) + #expect(result.isStale) + #expect(result.lastSuccessfulAt == Date(timeIntervalSince1970: 120)) + #expect(result.refreshErrorMessage != nil) + } + + @Test + func fallsBackToPersistedIncidentsWhenRefreshFails() async throws { + let defaultsName = "SystemStatusRepositoryTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsName)) + defaults.removePersistentDomain(forName: defaultsName) + defer { defaults.removePersistentDomain(forName: defaultsName) } + + let cachedIncidents = [ + StatusIncident( + id: "incident-1", + title: "builds.sr.ht outage", + summary: "Builds are failing.", + url: nil, + publishedAt: Date(timeIntervalSince1970: 200), + updatedAt: nil, + isActive: true + ) + ] + let cacheStore = SystemStatusCacheStore(defaults: defaults) + let initialRepository = SystemStatusRepository( + service: TestSystemStatusService( + snapshotHTMLHandler: { Self.cachedOperationalHTML }, + incidentsDataHandler: { Data(Self.cachedIncidentRSS.utf8) } + ), + cacheStore: cacheStore, + now: { Date(timeIntervalSince1970: 220) } + ) + + _ = try await initialRepository.recentIncidentsResult(forceRefresh: true) + + let fallbackRepository = SystemStatusRepository( + service: TestSystemStatusService( + snapshotHTMLHandler: { Self.cachedOperationalHTML }, + incidentsDataHandler: { throw SRHTError.httpError(504) } + ), + ttl: 0, + cacheStore: cacheStore, + now: { Date(timeIntervalSince1970: 260) } + ) + + let result = try await fallbackRepository.recentIncidentsResult(forceRefresh: true) + + #expect(result.value == cachedIncidents) + #expect(result.isStale) + #expect(result.lastSuccessfulAt == Date(timeIntervalSince1970: 220)) + #expect(result.refreshErrorMessage != nil) + } +} + +private struct TestSystemStatusService: SystemStatusServing { + let snapshotHTMLHandler: @Sendable () async throws -> String + let incidentsDataHandler: @Sendable () async throws -> Data + + func fetchSnapshotHTML() async throws -> String { + try await snapshotHTMLHandler() + } + + func fetchIncidentFeedData() async throws -> Data { + try await incidentsDataHandler() + } +} + +private extension SystemStatusRepositoryTests { + static let cachedSnapshotHTML = #""" + <div class="component" data-status="disrupted"> + <a href="/affected/git.sr.ht/">git.sr.ht</a> + <span class="component-status">Disrupted</span> + </div> + """# + + static let cachedOperationalHTML = #""" + <div class="component" data-status="ok"> + <a href="/affected/meta.sr.ht/">meta.sr.ht</a> + <span class="component-status">Operational</span> + </div> + """# + + static let cachedIncidentRSS = #""" + <rss version="2.0"> + <channel> + <item> + <title>builds.sr.ht outage</title> + <link>https://status.sr.ht/issues/1/</link> + <pubDate>Thu, 01 Jan 1970 00:03:20 +0000</pubDate> + <guid>incident-1</guid> + <description><p>Builds are failing.</p></description> + </item> + </channel> + </rss> + """# + + static let emptyRSS = #""" + <rss version="2.0"> + <channel></channel> + </rss> + """# +} diff --git a/HutchTests/SystemStatusServiceTests.swift b/HutchTests/SystemStatusServiceTests.swift index 58ace33..d1577b6 100644 --- a/HutchTests/SystemStatusServiceTests.swift +++ b/HutchTests/SystemStatusServiceTests.swift @@ -20,6 +20,18 @@ struct SystemStatusServiceTests { } @Test + func parsesStatusHTMLWithClassOrderChangesAndTimeElements() throws { + let snapshot = try SystemStatusService.parseSnapshotHTML(Self.variantHTML, fetchedAt: .now) + + #expect(snapshot.services.count == 2) + #expect(snapshot.services[0].status == .operational) + #expect(snapshot.services[1].status == .majorOutage) + #expect(snapshot.activeIncidents.count == 1) + #expect(snapshot.activeIncidents[0].title == "builds.sr.ht outage") + #expect(snapshot.activeIncidents[0].publishedAt == ISO8601DateFormatter().date(from: "2026-04-07T12:00:00Z")) + } + + @Test func parsesIncidentFeedRSS() async throws { let incidents = try await SystemStatusService.parseIncidentFeedXML(Data(Self.sampleRSS.utf8)) @@ -33,6 +45,15 @@ struct SystemStatusServiceTests { } @Test + func parsesIncidentFeedWithISO8601Dates() async throws { + let incidents = try await SystemStatusService.parseIncidentFeedXML(Data(Self.variantRSS.utf8)) + + #expect(incidents.count == 1) + #expect(incidents[0].title == "Status feed moved") + #expect(incidents[0].publishedAt == ISO8601DateFormatter().date(from: "2026-04-07T15:30:00Z")) + } + + @Test func bannerSummaryPrefersSpecificServiceThenCount() { let operational = SystemStatusSnapshot( services: [ @@ -102,6 +123,32 @@ struct SystemStatusServiceTests { </html> """# + private static let variantHTML = #""" + <html> + <body> + <div class="component extra" data-status="ok"> + <a class="no-underline" href="/affected/meta.sr.ht/">meta.sr.ht</a> + <small class="component-status secondary">All systems operational</small> + </div> + <div data-status="down" class="extra component"> + <a href="/affected/builds.sr.ht/" class="link">builds.sr.ht</a> + <div class="component-status badge">Outage</div> + </div> + <div class="announcement-box"> + <div class="padding"> + <p><a href="/issues/2026-04-07-builds-outage/"><strong>builds.sr.ht outage</strong></a></p> + <p><strong>Build jobs are currently failing.</strong></p> + </div> + </div> + <a class="issue no-underline urgent" href="/issues/2026-04-07-builds-outage/"> + <time class="date" datetime="2026-04-07T12:00:00Z">Apr 7</time> + <h4>builds.sr.ht outage</h4> + <span>Investigating elevated failures</span> + </a> + </body> + </html> + """# + private static let sampleRSS = #""" <?xml version="1.0" encoding="utf-8" standalone="yes"?> <rss version="2.0"> @@ -126,4 +173,20 @@ struct SystemStatusServiceTests { </channel> </rss> """# + + private static let variantRSS = #""" + <?xml version="1.0" encoding="utf-8" standalone="yes"?> + <rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"> + <channel> + <title>sr.ht status</title> + <item> + <title>Status feed moved</title> + <link>https://status.sr.ht/issues/2026-04-07-feed-moved/</link> + <dc:date>2026-04-07T15:30:00Z</dc:date> + <guid>https://status.sr.ht/issues/2026-04-07-feed-moved/</guid> + <description><p>Use the new feed endpoint.</p></description> + </item> + </channel> + </rss> + """# } |
