From c7277056f3a51682a99e86ff557adcb2a664da21 Mon Sep 17 00:00:00 2001 From: D3C0Y Date: Sun, 2 Aug 2026 20:59:05 +0000 Subject: [PATCH 1/2] Fix news HTML entities and Murmansk phone extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decode   and related entities in RSS titles, and dial local Murmansk 6-digit PBX numbers as +7 8152 … with parenthetical codes as ;ext= extensions instead of concatenating digits. Co-authored-by: D3C0Y --- .../Sources/Core/HTMLTextCleaning.swift | 75 +++++++++++++++++++ .../Sources/Core/OfficialNewsService.swift | 20 +---- .../Sources/Core/PhoneNumberFormatting.swift | 57 ++++++++++++++ .../Sources/Views/InAppBrowserView.swift | 2 +- MauverseIOS/Sources/Views/ProfileView.swift | 4 +- MauverseIOS/Sources/Views/ServicesView.swift | 4 +- .../Views/UniversityContactsView.swift | 3 +- MauverseIOS/project.yml | 4 +- mauverse-api/apps/gateway/utils/parser.py | 32 +++++--- .../PhoneNumberFormattingTests.cs | 26 +++++++ .../mauverse_mobile.Tests.csproj | 2 + .../Templates/TelephoneInfoTemplate.xaml | 21 +++++- .../Utils/PhoneNumberFormatting.cs | 65 ++++++++++++++++ .../Dialogs/DetailTelephoneViewModel.cs | 31 ++++++-- .../Services/UniversityContactsViewModel.cs | 11 ++- mauverse_mobile/mau.csproj | 4 +- 16 files changed, 307 insertions(+), 54 deletions(-) create mode 100644 MauverseIOS/Sources/Core/HTMLTextCleaning.swift create mode 100644 MauverseIOS/Sources/Core/PhoneNumberFormatting.swift create mode 100644 mauverse_mobile.Tests/PhoneNumberFormattingTests.cs create mode 100644 mauverse_mobile/Utils/PhoneNumberFormatting.cs diff --git a/MauverseIOS/Sources/Core/HTMLTextCleaning.swift b/MauverseIOS/Sources/Core/HTMLTextCleaning.swift new file mode 100644 index 0000000..1d822aa --- /dev/null +++ b/MauverseIOS/Sources/Core/HTMLTextCleaning.swift @@ -0,0 +1,75 @@ +import Foundation + +enum HTMLTextCleaning { + /// Decodes HTML entities (` `, ` `, …) and strips tags for RSS titles/bodies. + static func cleanRSS(_ value: String) -> String { + let decoded = decodeEntities(value) + return decoded + .replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression) + .replacingOccurrences(of: "[\\u00A0\\u2000-\\u200B\\u202F\\u205F\\u3000]+", with: " ", options: .regularExpression) + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func decodeEntities(_ value: String) -> String { + guard !value.isEmpty else { return value } + + var result = value + let named: [(String, String)] = [ + (" ", " "), + (" ", " "), + (" ", " "), + (" ", " "), + ("—", "—"), + ("–", "–"), + ("«", "«"), + ("»", "»"), + (""", "\""), + ("'", "'"), + ("'", "'"), + ("<", "<"), + (">", ">"), + ("&", "&") + ] + // Decode named entities first; & last among pairs that can nest. + for (entity, replacement) in named where entity != "&" { + result = result.replacingOccurrences(of: entity, with: replacement, options: .caseInsensitive) + } + + // Numeric decimal   and hex   + result = replaceNumericEntities(result) + result = result.replacingOccurrences(of: "&", with: "&", options: .caseInsensitive) + return result + } + + private static func replaceNumericEntities(_ value: String) -> String { + var output = value + let patterns: [(String, (String) -> String?)] = [ + (#"&#(\d+);"#, { Int($0).flatMap(unicodeScalarString) }), + (#"&#x([0-9a-fA-F]+);"#, { UInt32($0, radix: 16).flatMap(unicodeScalarString) }) + ] + for (pattern, decoder) in patterns { + guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } + let range = NSRange(output.startIndex..., in: output) + let matches = regex.matches(in: output, range: range).reversed() + for match in matches { + guard match.numberOfRanges >= 2, + let full = Range(match.range(at: 0), in: output), + let body = Range(match.range(at: 1), in: output), + let replacement = decoder(String(output[body])) else { continue } + output.replaceSubrange(full, with: replacement) + } + } + return output + } + + private static func unicodeScalarString(_ value: Int) -> String? { + guard let scalar = UnicodeScalar(value) else { return nil } + return String(Character(scalar)) + } + + private static func unicodeScalarString(_ value: UInt32) -> String? { + guard let scalar = UnicodeScalar(value) else { return nil } + return String(Character(scalar)) + } +} diff --git a/MauverseIOS/Sources/Core/OfficialNewsService.swift b/MauverseIOS/Sources/Core/OfficialNewsService.swift index 1a374e5..5f09a61 100644 --- a/MauverseIOS/Sources/Core/OfficialNewsService.swift +++ b/MauverseIOS/Sources/Core/OfficialNewsService.swift @@ -58,7 +58,7 @@ actor OfficialNewsService { private func fetch(url: URL, cachePolicy: URLRequest.CachePolicy) async throws -> [NewsItem] { var request = URLRequest(url: url, cachePolicy: cachePolicy, timeoutInterval: 30) request.timeoutInterval = 30 - request.setValue("MAUverse/1.12.1 (iOS)", forHTTPHeaderField: "User-Agent") + request.setValue("MAUverse/1.12.2 (iOS)", forHTTPHeaderField: "User-Agent") request.setValue("application/rss+xml, application/xml, text/xml", forHTTPHeaderField: "Accept") let (data, response) = try await URLSession.shared.data(for: request) @@ -152,10 +152,10 @@ private final class RSSNewsParser: NSObject, XMLParserDelegate { guard current != nil else { return } let value = text.trimmingCharacters(in: .whitespacesAndNewlines) switch elementName { - case "title": current?.title = value.cleanedRSS + case "title": current?.title = HTMLTextCleaning.cleanRSS(value) case "link": current?.link = Self.absoluteURL(value) case "description": - current?.description = value.cleanedRSS + current?.description = HTMLTextCleaning.cleanRSS(value) if current?.image == nil { current?.image = Self.firstImageURL(in: value) } @@ -205,20 +205,6 @@ private final class RSSNewsParser: NSObject, XMLParserDelegate { } } -private extension String { - var cleanedRSS: String { - replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression) - .replacingOccurrences(of: " ", with: " ") - .replacingOccurrences(of: "—", with: "—") - .replacingOccurrences(of: "–", with: "–") - .replacingOccurrences(of: "«", with: "«") - .replacingOccurrences(of: "»", with: "»") - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - } -} - private extension Array { func uniqued(by key: (Element) -> Key) -> [Element] { var seen = Set() diff --git a/MauverseIOS/Sources/Core/PhoneNumberFormatting.swift b/MauverseIOS/Sources/Core/PhoneNumberFormatting.swift new file mode 100644 index 0000000..ab7ff5a --- /dev/null +++ b/MauverseIOS/Sources/Core/PhoneNumberFormatting.swift @@ -0,0 +1,57 @@ +import Foundation + +enum PhoneNumberFormatting { + /// Murmansk city code used for local 6-digit university PBX numbers. + static let murmanskAreaCode = "8152" + + /// Builds a `tel:` URL for Russian / Murmansk numbers. + /// Local numbers like `21-38-81 (3045)` become `tel:+78152213881;ext=3045`. + static func telURL(from raw: String) -> URL? { + let parsed = parse(raw) + guard let e164 = parsed.e164 else { return nil } + if let extensionCode = parsed.extensionCode, !extensionCode.isEmpty { + return URL(string: "tel:\(e164);ext=\(extensionCode)") + } + return URL(string: "tel:\(e164)") + } + + static func parse(_ raw: String) -> (e164: String?, extensionCode: String?) { + var working = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !working.isEmpty else { return (nil, nil) } + + var extensionCode: String? + if let match = working.range(of: #"\(\d{2,6}\)\s*$"#, options: .regularExpression) { + extensionCode = String(working[match]).filter(\.isNumber) + working.removeSubrange(match) + } else if let match = working.range( + of: #"(?i)(?:доб\.?|ext\.?|extension)\s*\d{2,6}\s*$"#, + options: .regularExpression + ) { + extensionCode = String(working[match]).filter(\.isNumber) + working.removeSubrange(match) + } + + let digits = working.filter(\.isNumber) + guard !digits.isEmpty else { return (nil, nil) } + + let e164: String? + if digits.count == 6 { + // Local Murmansk landline: 21-38-81 → +7 8152 213881 + e164 = "+7\(murmanskAreaCode)\(digits)" + } else if digits.count == 10, digits.hasPrefix(murmanskAreaCode) || digits.hasPrefix("800") { + e164 = "+7\(digits)" + } else if digits.count == 11, digits.hasPrefix("8") { + e164 = "+7\(digits.dropFirst())" + } else if digits.count == 11, digits.hasPrefix("7") { + e164 = "+\(digits)" + } else if working.contains("+"), digits.count >= 11 { + e164 = "+\(digits)" + } else if digits.count >= 10 { + e164 = digits.hasPrefix("7") ? "+\(digits)" : "+7\(digits)" + } else { + e164 = nil + } + + return (e164, extensionCode) + } +} diff --git a/MauverseIOS/Sources/Views/InAppBrowserView.swift b/MauverseIOS/Sources/Views/InAppBrowserView.swift index db5a850..7ffb94d 100644 --- a/MauverseIOS/Sources/Views/InAppBrowserView.swift +++ b/MauverseIOS/Sources/Views/InAppBrowserView.swift @@ -23,7 +23,7 @@ final class InAppBrowserController: NSObject, ObservableObject, WKNavigationDele let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .default() - configuration.applicationNameForUserAgent = "MAUverse/1.12.1" + configuration.applicationNameForUserAgent = "MAUverse/1.12.2" configuration.defaultWebpagePreferences.allowsContentJavaScript = true configuration.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: configuration) diff --git a/MauverseIOS/Sources/Views/ProfileView.swift b/MauverseIOS/Sources/Views/ProfileView.swift index 119fcda..573831f 100644 --- a/MauverseIOS/Sources/Views/ProfileView.swift +++ b/MauverseIOS/Sources/Views/ProfileView.swift @@ -73,7 +73,7 @@ struct ProfileView: View { .buttonStyle(.plain) .mauSurface() - Text("MAUverse 1.12.1 (30)") + Text("MAUverse 1.12.2 (31)") .font(.caption) .foregroundStyle(MauTheme.muted) } @@ -280,7 +280,7 @@ private struct SettingsView: View { .foregroundStyle(MauTheme.blue) } } - Text("Версия 1.12.1 • сборка 30").font(.caption) + Text("Версия 1.12.2 • сборка 31").font(.caption) } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) diff --git a/MauverseIOS/Sources/Views/ServicesView.swift b/MauverseIOS/Sources/Views/ServicesView.swift index 9505a7c..c310453 100644 --- a/MauverseIOS/Sources/Views/ServicesView.swift +++ b/MauverseIOS/Sources/Views/ServicesView.swift @@ -964,9 +964,7 @@ private struct ContactCard: View { } private func phoneURL(_ value: String) -> URL? { - let normalized = value.filter { $0.isNumber || $0 == "+" } - guard normalized.filter(\.isNumber).count >= 5 else { return nil } - return URL(string: "tel:\(normalized)") + PhoneNumberFormatting.telURL(from: value) } } diff --git a/MauverseIOS/Sources/Views/UniversityContactsView.swift b/MauverseIOS/Sources/Views/UniversityContactsView.swift index 0c7cb6b..a5d1c55 100644 --- a/MauverseIOS/Sources/Views/UniversityContactsView.swift +++ b/MauverseIOS/Sources/Views/UniversityContactsView.swift @@ -110,8 +110,7 @@ struct UniversityContactsView: View { } private func phoneURL(_ phone: String) -> URL? { - let digits = phone.filter { $0.isNumber || $0 == "+" } - return URL(string: "tel:\(digits)") + PhoneNumberFormatting.telURL(from: phone) } private func copyText(_ value: String, label: String) { diff --git a/MauverseIOS/project.yml b/MauverseIOS/project.yml index 6cf9924..403007f 100644 --- a/MauverseIOS/project.yml +++ b/MauverseIOS/project.yml @@ -28,8 +28,8 @@ targets: INFOPLIST_FILE: Resources/Info.plist GENERATE_INFOPLIST_FILE: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.12.1 - CURRENT_PROJECT_VERSION: 30 + MARKETING_VERSION: 1.12.2 + CURRENT_PROJECT_VERSION: 31 MAUVERSE_SCHEDULE_TOKEN: "" TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO diff --git a/mauverse-api/apps/gateway/utils/parser.py b/mauverse-api/apps/gateway/utils/parser.py index 7a2aa4d..459da39 100644 --- a/mauverse-api/apps/gateway/utils/parser.py +++ b/mauverse-api/apps/gateway/utils/parser.py @@ -221,6 +221,26 @@ def get_teacher_info_json(self, teacher: TeacherInfo) -> list[Any] | dict[str, A return payload +_THIN_SPACES = ( + "\xa0", # nbsp + "\u2002", # ensp + "\u2003", # emsp + "\u2009", # thinsp + "\u200a", + "\u202f", + "\u205f", +) + + +def _clean_rss_text(value: str) -> str: + text = html.unescape(value or "") + for space in _THIN_SPACES: + text = text.replace(space, " ") + text = re.sub(r"<[^>]+>", "", text) + text = re.sub(r"\s+", " ", text).strip() + return text.replace("\n", "") + + class RssParser: def __init__(self) -> None: self.base_url = parser_config.MAIN_URL @@ -247,16 +267,8 @@ def get_rss_data(self, parser_type: ParserType) -> list[dict[str, Any]]: if any(node is None for node in (title_node, link_node, description_node, date_node)): continue - title = re.sub( - r"<[^>]+>", - "", - html.unescape(title_node.text).replace("\xa0", " "), - ).replace("\n", "") - description = re.sub( - r"<[^>]+>", - "", - html.unescape(description_node.text).replace("\xa0", " "), - ).replace("\n", "") + title = _clean_rss_text(title_node.text) + description = _clean_rss_text(description_node.text) try: local_date = date_parser.parse(date_node.text) except (OverflowError, ValueError): diff --git a/mauverse_mobile.Tests/PhoneNumberFormattingTests.cs b/mauverse_mobile.Tests/PhoneNumberFormattingTests.cs new file mode 100644 index 0000000..651a5ac --- /dev/null +++ b/mauverse_mobile.Tests/PhoneNumberFormattingTests.cs @@ -0,0 +1,26 @@ +using mau.Utils; +using Xunit; + +namespace Mauverse.Mobile.Tests; + +public sealed class PhoneNumberFormattingTests +{ + [Theory] + [InlineData("21-38-81 (3045)", "+78152213881;ext=3045")] + [InlineData("40-33-39", "+78152403339")] + [InlineData("8 8152 21-38-72", "+78152213872")] + [InlineData("+7 (8152) 21-38-01", "+78152213801")] + [InlineData("8 800 350-12-21", "+78003501221")] + [InlineData("21-38-81 доб. 3045", "+78152213881;ext=3045")] + public void FormatsMurmanskAndRussianNumbers(string raw, string expected) + { + Assert.Equal(expected, PhoneNumberFormatting.ToDialString(raw)); + } + + [Fact] + public void RejectsEmptyInput() + { + Assert.Null(PhoneNumberFormatting.ToDialString(" ")); + Assert.Null(PhoneNumberFormatting.ToDialString(null)); + } +} diff --git a/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj b/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj index fa31e03..e0f0735 100644 --- a/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj +++ b/mauverse_mobile.Tests/mauverse_mobile.Tests.csproj @@ -33,6 +33,8 @@ Link="Production\UniversityContactsCatalog.cs" /> + diff --git a/mauverse_mobile/Templates/TelephoneInfoTemplate.xaml b/mauverse_mobile/Templates/TelephoneInfoTemplate.xaml index f28ac2f..1a4566f 100644 --- a/mauverse_mobile/Templates/TelephoneInfoTemplate.xaml +++ b/mauverse_mobile/Templates/TelephoneInfoTemplate.xaml @@ -3,6 +3,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit" xmlns:viewmodel="clr-namespace:mau.ViewModel.Services" + xmlns:dialogs="clr-namespace:mau.ViewModel.Dialogs" xmlns:converters="clr-namespace:mau.Utils.Converters" xmlns:controls="clr-namespace:mau.Controls" xmlns:dto="clr-namespace:mau.DTOModels" @@ -70,12 +71,24 @@