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) }),
+ (#"([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 @@
-
-
+
+ IsVisible="{Binding Phone2, Converter={StaticResource HideLabelConverter}}">
+
+
+
+
diff --git a/mauverse_mobile/Utils/PhoneNumberFormatting.cs b/mauverse_mobile/Utils/PhoneNumberFormatting.cs
new file mode 100644
index 0000000..ba5f032
--- /dev/null
+++ b/mauverse_mobile/Utils/PhoneNumberFormatting.cs
@@ -0,0 +1,65 @@
+using System.Text.RegularExpressions;
+
+namespace mau.Utils;
+
+///
+/// Formats university phone numbers for the platform dialer.
+/// Local Murmansk PBX numbers like "21-38-81 (3045)" become "+78152213881;ext=3045".
+///
+public static partial class PhoneNumberFormatting
+{
+ public const string MurmanskAreaCode = "8152";
+
+ public static string? ToDialString(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ return null;
+
+ var working = raw.Trim();
+ string? extension = null;
+
+ var paren = TrailingParenExtension().Match(working);
+ if (paren.Success)
+ {
+ extension = paren.Groups[1].Value;
+ working = working[..paren.Index].TrimEnd();
+ }
+ else
+ {
+ var labeled = TrailingLabeledExtension().Match(working);
+ if (labeled.Success)
+ {
+ extension = labeled.Groups[1].Value;
+ working = working[..labeled.Index].TrimEnd();
+ }
+ }
+
+ var digits = new string(working.Where(char.IsDigit).ToArray());
+ if (digits.Length == 0)
+ return null;
+
+ string? e164 = digits.Length switch
+ {
+ 6 => $"+7{MurmanskAreaCode}{digits}",
+ 10 when digits.StartsWith(MurmanskAreaCode, StringComparison.Ordinal)
+ || digits.StartsWith("800", StringComparison.Ordinal)
+ => $"+7{digits}",
+ 11 when digits.StartsWith('8') => $"+7{digits[1..]}",
+ 11 when digits.StartsWith('7') => $"+{digits}",
+ >= 10 when digits.StartsWith('7') => $"+{digits}",
+ >= 10 => $"+7{digits}",
+ _ => null
+ };
+
+ if (e164 is null)
+ return null;
+
+ return string.IsNullOrEmpty(extension) ? e164 : $"{e164};ext={extension}";
+ }
+
+ [GeneratedRegex(@"\((\d{2,6})\)\s*$", RegexOptions.CultureInvariant)]
+ private static partial Regex TrailingParenExtension();
+
+ [GeneratedRegex(@"(?i)(?:доб\.?|ext\.?|extension)\s*(\d{2,6})\s*$", RegexOptions.CultureInvariant)]
+ private static partial Regex TrailingLabeledExtension();
+}
diff --git a/mauverse_mobile/ViewModel/Dialogs/DetailTelephoneViewModel.cs b/mauverse_mobile/ViewModel/Dialogs/DetailTelephoneViewModel.cs
index c098ace..8cf1630 100644
--- a/mauverse_mobile/ViewModel/Dialogs/DetailTelephoneViewModel.cs
+++ b/mauverse_mobile/ViewModel/Dialogs/DetailTelephoneViewModel.cs
@@ -6,13 +6,6 @@
using mau.Utils;
using mau.Utils.API.Interaface;
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using System.Windows.Input;
-
namespace mau.ViewModel.Dialogs
{
public partial class DetailTelephoneViewModel : BaseViewModel
@@ -53,5 +46,29 @@ async Task GetPhone(DeptInfoDTO dept)
IsBusy = false;
}
}
+
+ [RelayCommand]
+ async Task CallAsync(string? rawPhone)
+ {
+ if (string.IsNullOrWhiteSpace(rawPhone))
+ return;
+
+ try
+ {
+ var dial = PhoneNumberFormatting.ToDialString(rawPhone);
+ if (string.IsNullOrWhiteSpace(dial))
+ {
+ await AppShell.DisplaySnackbarAsync("Некорректный номер телефона");
+ return;
+ }
+
+ PhoneDialer.Default.Open(dial);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine(ex);
+ await AppShell.DisplaySnackbarAsync("Не удалось открыть телефон");
+ }
+ }
}
}
diff --git a/mauverse_mobile/ViewModel/Services/UniversityContactsViewModel.cs b/mauverse_mobile/ViewModel/Services/UniversityContactsViewModel.cs
index 5175b90..f817cb1 100644
--- a/mauverse_mobile/ViewModel/Services/UniversityContactsViewModel.cs
+++ b/mauverse_mobile/ViewModel/Services/UniversityContactsViewModel.cs
@@ -37,11 +37,14 @@ async Task CallAsync(UniversityContactBlock? block)
try
{
- var digits = new string(block.Phone.Where(c => char.IsDigit(c) || c == '+').ToArray());
- if (digits.StartsWith('8') && digits.Length >= 11)
- digits = "+7" + digits[1..];
+ var dial = PhoneNumberFormatting.ToDialString(block.Phone);
+ if (string.IsNullOrWhiteSpace(dial))
+ {
+ await AppShell.DisplaySnackbarAsync("Некорректный номер телефона");
+ return;
+ }
- PhoneDialer.Default.Open(digits);
+ PhoneDialer.Default.Open(dial);
}
catch (Exception ex)
{
diff --git a/mauverse_mobile/mau.csproj b/mauverse_mobile/mau.csproj
index e91cdf5..7905e82 100644
--- a/mauverse_mobile/mau.csproj
+++ b/mauverse_mobile/mau.csproj
@@ -16,8 +16,8 @@
MAUverse
com.pmi4freal.mauverse3
- 1.12.0
- 29
+ 1.12.2
+ 31
https://app.mauniver.ru/dev/mauverse/
None