Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions MauverseIOS/Sources/Core/HTMLTextCleaning.swift
Original file line number Diff line number Diff line change
@@ -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)] = [
("&nbsp;", " "),
("&thinsp;", " "),
("&ensp;", " "),
("&emsp;", " "),
("&mdash;", "—"),
("&ndash;", "–"),
("&laquo;", "«"),
("&raquo;", "»"),
("&quot;", "\""),
("&apos;", "'"),
("&#39;", "'"),
("&lt;", "<"),
("&gt;", ">"),
("&amp;", "&")
]
// Decode named entities first; &amp; last among pairs that can nest.
for (entity, replacement) in named where entity != "&amp;" {
result = result.replacingOccurrences(of: entity, with: replacement, options: .caseInsensitive)
}

// Numeric decimal &#160; and hex &#x2009;
result = replaceNumericEntities(result)
result = result.replacingOccurrences(of: "&amp;", 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))
}
}
20 changes: 3 additions & 17 deletions MauverseIOS/Sources/Core/OfficialNewsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -205,20 +205,6 @@ private final class RSSNewsParser: NSObject, XMLParserDelegate {
}
}

private extension String {
var cleanedRSS: String {
replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
.replacingOccurrences(of: "&nbsp;", with: " ")
.replacingOccurrences(of: "&mdash;", with: "—")
.replacingOccurrences(of: "&ndash;", with: "–")
.replacingOccurrences(of: "&laquo;", with: "«")
.replacingOccurrences(of: "&raquo;", with: "»")
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

private extension Array {
func uniqued<Key: Hashable>(by key: (Element) -> Key) -> [Element] {
var seen = Set<Key>()
Expand Down
57 changes: 57 additions & 0 deletions MauverseIOS/Sources/Core/PhoneNumberFormatting.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion MauverseIOS/Sources/Views/InAppBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions MauverseIOS/Sources/Views/ProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 1 addition & 3 deletions MauverseIOS/Sources/Views/ServicesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
3 changes: 1 addition & 2 deletions MauverseIOS/Sources/Views/UniversityContactsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions MauverseIOS/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 22 additions & 10 deletions mauverse-api/apps/gateway/utils/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
26 changes: 26 additions & 0 deletions mauverse_mobile.Tests/PhoneNumberFormattingTests.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
2 changes: 2 additions & 0 deletions mauverse_mobile.Tests/mauverse_mobile.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
Link="Production\UniversityContactsCatalog.cs" />
<Compile Include="..\mauverse_mobile\Utils\Services\ThemePalette.cs"
Link="Production\ThemePalette.cs" />
<Compile Include="..\mauverse_mobile\Utils\PhoneNumberFormatting.cs"
Link="Production\PhoneNumberFormatting.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
21 changes: 17 additions & 4 deletions mauverse_mobile/Templates/TelephoneInfoTemplate.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -70,12 +71,24 @@
<Label FontSize="13" TextColor="{DynamicResource TextPrimary}"
Text="{Binding DepEmail, StringFormat='Почта: {0}'}"
IsVisible="{Binding DepEmail, Converter={StaticResource HideLabelConverter}}"/>
<Label FontSize="13" TextColor="{DynamicResource TextPrimary}"
<Label FontSize="13" TextColor="{DynamicResource Primary}"
Text="{Binding Phone, StringFormat='Телефон: {0}'}"
IsVisible="{Binding Phone, Converter={StaticResource HideLabelConverter}}"/>
<Label FontSize="13" TextColor="{DynamicResource TextPrimary}"
IsVisible="{Binding Phone, Converter={StaticResource HideLabelConverter}}">
<Label.GestureRecognizers>
<TapGestureRecognizer
Command="{Binding Path=CallCommand, Source={RelativeSource AncestorType={x:Type dialogs:DetailTelephoneViewModel}}, x:DataType={x:Type dialogs:DetailTelephoneViewModel}}"
CommandParameter="{Binding Phone}" />
</Label.GestureRecognizers>
</Label>
<Label FontSize="13" TextColor="{DynamicResource Primary}"
Text="{Binding Phone2, StringFormat='Доп.: {0}'}"
IsVisible="{Binding Phone2, Converter={StaticResource HideLabelConverter}}"/>
IsVisible="{Binding Phone2, Converter={StaticResource HideLabelConverter}}">
<Label.GestureRecognizers>
<TapGestureRecognizer
Command="{Binding Path=CallCommand, Source={RelativeSource AncestorType={x:Type dialogs:DetailTelephoneViewModel}}, x:DataType={x:Type dialogs:DetailTelephoneViewModel}}"
CommandParameter="{Binding Phone2}" />
</Label.GestureRecognizers>
</Label>
<Label FontSize="13" TextColor="{DynamicResource TextPrimary}"
Text="{Binding Fax, StringFormat='Факс: {0}'}"
IsVisible="{Binding Fax, Converter={StaticResource HideLabelConverter}}"/>
Expand Down
Loading
Loading