diff --git a/ios/swiftbible/Models/DailyDevotional.swift b/ios/swiftbible/Models/DailyDevotional.swift index eb16f9c1..a88b015a 100644 --- a/ios/swiftbible/Models/DailyDevotional.swift +++ b/ios/swiftbible/Models/DailyDevotional.swift @@ -14,7 +14,7 @@ struct DevotionalVerse: Codable, Equatable { let testament: String } -struct DailyDevotional: Codable { +struct DailyDevotional: Codable, Equatable { let id: Int let message: String let for_date: String @@ -28,3 +28,39 @@ struct DailyDevotional: Codable { let model: String? let track: String? } + +extension DailyDevotional { + var cleanedForDisplay: DailyDevotional { + DailyDevotional( + id: id, + message: message.removingRedLetterTags(), + for_date: for_date, + devotional_type: devotional_type, + series_name: series_name, + series_part: series_part, + holiday_name: holiday_name, + holiday_url: holiday_url, + anchor_verse: anchor_verse, + verses: verses, + model: model, + track: track + ) + } +} + +extension String { + /// Removes red-letter markup from Bible text before that text is reused in + /// prose contexts such as devotionals, notifications, and saved snippets. + /// + /// Bible paragraphs intentionally keep `` markers for red-letter + /// rendering, but MarkdownUI displays those tags literally. The regex is + /// intentionally narrow: it only strips opening/closing JESUS tags, while + /// preserving the quoted words and all other devotional markdown. + func removingRedLetterTags() -> String { + replacingOccurrences( + of: #"<\s*/?\s*JESUS\s*>"#, + with: "", + options: [.regularExpression, .caseInsensitive] + ) + } +} diff --git a/ios/swiftbible/Services/CacheService.swift b/ios/swiftbible/Services/CacheService.swift index 0704a13b..80da81d7 100644 --- a/ios/swiftbible/Services/CacheService.swift +++ b/ios/swiftbible/Services/CacheService.swift @@ -59,7 +59,7 @@ class CacheService { do { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 - let data = try encoder.encode(devotional) + let data = try encoder.encode(devotional.cleanedForDisplay) try data.write(to: fileURL, options: .atomic) } catch { print("Error saving devotional to cache: \(error)") @@ -81,7 +81,7 @@ class CacheService { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let devotional = try decoder.decode(DailyDevotional.self, from: data) - return devotional + return devotional.cleanedForDisplay } catch { print("Error loading devotional from cache: \(error)") return nil diff --git a/ios/swiftbible/Services/NotificationService.swift b/ios/swiftbible/Services/NotificationService.swift index aa4df457..75e6c66b 100644 --- a/ios/swiftbible/Services/NotificationService.swift +++ b/ios/swiftbible/Services/NotificationService.swift @@ -74,7 +74,7 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate { content.sound = .default if let devotional = CacheService.shared.loadDevotional(for: date), - let teaser = extractTeaser(from: devotional.message) { + let teaser = extractTeaser(from: devotional.cleanedForDisplay.message) { content.title = "Daily Devotional" content.body = teaser } else { diff --git a/ios/swiftbible/Views/Daily Devotional/DailyDevotionalView.swift b/ios/swiftbible/Views/Daily Devotional/DailyDevotionalView.swift index 4d5161b4..b7672135 100644 --- a/ios/swiftbible/Views/Daily Devotional/DailyDevotionalView.swift +++ b/ios/swiftbible/Views/Daily Devotional/DailyDevotionalView.swift @@ -322,7 +322,7 @@ struct DailyDevotionalView: View { @MainActor private func applyDevotional(_ devotional: DailyDevotional) { - message = devotional.message + message = devotional.cleanedForDisplay.message devotionalType = devotional.devotional_type ?? "single" seriesName = devotional.series_name seriesPart = devotional.series_part @@ -503,7 +503,7 @@ struct DailyDevotionalView: View { ) // Save to cache - CacheService.shared.saveDevotional(devotional, for: date) + CacheService.shared.saveDevotional(devotional.cleanedForDisplay, for: date) updateSavedState(for: date) } catch { print("No devotional found for \(dateString): \(error)") @@ -568,7 +568,7 @@ struct DailyDevotionalView: View { isFavorite = false } } else { - let devotional = SavedDevotional(date: selectedDate, message: message) + let devotional = SavedDevotional(date: selectedDate, message: message.removingRedLetterTags()) context.insert(devotional) try? context.save() AnalyticsService.shared.capture(.devotionalSaved, properties: devotionalAnalyticsProperties()) diff --git a/ios/swiftbibleTests/DailyDevotionalTests.swift b/ios/swiftbibleTests/DailyDevotionalTests.swift new file mode 100644 index 00000000..292a09d3 --- /dev/null +++ b/ios/swiftbibleTests/DailyDevotionalTests.swift @@ -0,0 +1,48 @@ +// +// DailyDevotionalTests.swift +// swiftbibleTests +// +// Verifies devotional prose is safe to render after Bible red-letter markup +// has been embedded in source verse text. +// + +import XCTest +@testable import swiftbible + +final class DailyDevotionalTests: XCTestCase { + func testRemovingRedLetterTagsKeepsJesusWords() { + let source = #"> "And he saith unto them, Are ye so without understanding also?" Mark 7:18"# + + XCTAssertEqual( + source.removingRedLetterTags(), + #"> "And he saith unto them, Are ye so without understanding also?" Mark 7:18"# + ) + } + + func testRemovingRedLetterTagsHandlesWhitespaceAndCaseVariants() { + let source = "Peace be unto you. < JESUS >Follow me." + + XCTAssertEqual(source.removingRedLetterTags(), "Peace be unto you. Follow me.") + } + + func testCleanedForDisplaySanitizesMessageOnly() { + let devotional = DailyDevotional( + id: 7, + message: "Before spoken words after", + for_date: "2026-06-22", + devotional_type: "single", + series_name: nil, + series_part: nil, + holiday_name: nil, + holiday_url: nil, + anchor_verse: "Mark 7:18", + verses: [DevotionalVerse(book: "Mark", chapter: 7, verse: 18, testament: "new")], + model: "test-model", + track: "narrative" + ) + + XCTAssertEqual(devotional.cleanedForDisplay.message, "Before spoken words after") + XCTAssertEqual(devotional.cleanedForDisplay.anchor_verse, "Mark 7:18") + XCTAssertEqual(devotional.cleanedForDisplay.verses, devotional.verses) + } +}