Files

203 lines
9.5 KiB
Swift

// Copyright (c) 2026 GiantAccel, LLC
// XStats modifications Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
import Foundation
public enum L10n {
// 所有可变全局状态都由同一把锁保护;tr 同时用于 UI 和后台采样任务。
private static let lock = NSLock()
nonisolated(unsafe) private static var selectedLanguage: AppLanguage = .chinese
nonisolated(unsafe) private static var cache: [String: String] = [:]
nonisolated(unsafe) private static var misses = Set<String>()
nonisolated(unsafe) private static var missingLogURL: URL?
public static var language: AppLanguage { lock.withLock { selectedLanguage } }
public static var missLog: URL? {
get { lock.withLock { missingLogURL } }
set { lock.withLock { missingLogURL = newValue } }
}
public static var isEnglish: Bool { language == .english }
/// 在线地名只有中英文两套数据时,其他语言使用英文,国家名称仍由当前 Locale 本地化。
public static var usesEnglishNames: Bool { !language.isChinese }
public static var locale: Locale { locale(for: language) }
public static func locale(for language: AppLanguage) -> Locale {
let region = Locale.current.region?.identifier ?? "US"
return Locale(identifier: language.resolved.languageCode.replacingOccurrences(of: "-", with: "_") + "_" + region)
}
public static func configure(_ language: AppLanguage) {
let resolved = language.resolved
lock.withLock {
selectedLanguage = resolved
cache.removeAll(keepingCapacity: true)
}
}
static func translate(_ text: String) -> String {
lock.withLock {
guard selectedLanguage != .chinese, containsChinese(text) else { return text }
if let cached = cache[text] { return cached }
let catalog = Translations.catalog(for: selectedLanguage)
let translated = catalog?.translate(text, fallback: Translations.shared)
if translated == nil { recordMiss(text) }
let result = translated ?? Translations.shared.translate(text) ?? text
if cache.count > 4000 { cache.removeAll(keepingCapacity: true) }
cache[text] = result
return result
}
}
static func containsChinese(_ text: String) -> Bool {
text.unicodeScalars.contains {
(0x4E00...0x9FFF).contains($0.value) || (0x3000...0x303F).contains($0.value) || (0xFF00...0xFFEF).contains($0.value)
}
}
/// 仅在持锁状态调用。按语言记录缺失条目,缺失新文案回退英文而非空白。
private static func recordMiss(_ text: String) {
let key = "\(selectedLanguage.languageCode)\t\(text)"
guard let missingLogURL, misses.insert(key).inserted else { return }
let line = key.replacingOccurrences(of: "\n", with: "\\n") + "\n"
if let handle = try? FileHandle(forWritingTo: missingLogURL) {
handle.seekToEndOfFile()
handle.write(Data(line.utf8))
try? handle.close()
} else {
try? Data(line.utf8).write(to: missingLogURL)
}
}
}
/// 中文原文作为稳定键,界面显示前按所选语言翻译。
@inline(__always)
public func tr(_ text: String) -> String { L10n.translate(text) }
/// 不可变翻译表,可在采样和界面线程共享。占位符支持 {} 以及重排后的 {1}、{2}。
final class Translations: Sendable {
static let shared = Translations(tables: tables)
private static let catalogs: [AppLanguage: Translations] = {
var result: [AppLanguage: Translations] = [.english: shared]
for language in AppLanguage.allCases where language != .system && language != .chinese && language != .english {
guard let url = Bundle.module.url(forResource: language.languageCode, withExtension: "tsv"),
let text = try? String(contentsOf: url, encoding: .utf8) else { continue }
result[language] = Translations(tables: [text], rightToLeft: language.isRightToLeft)
}
return result
}()
static func catalog(for language: AppLanguage) -> Translations? { catalogs[language.resolved] }
private let exact: [String: String]
private let templates: [Template]
private let rightToLeft: Bool
let sourceKeys: Set<String>
struct Template: Sendable {
let fragments: [String]
let regex: NSRegularExpression
let translation: String
}
init(tables: [String], rightToLeft: Bool = false) {
var values: [String: String] = [:]
for table in tables {
for line in table.split(separator: "\n", omittingEmptySubsequences: true) {
let parts = line.split(separator: "\t", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2 else { continue }
let source = String(parts[0]).replacingOccurrences(of: "\\n", with: "\n")
let translation = String(parts[1]).replacingOccurrences(of: "\\n", with: "\n")
// 错误占位符会丢失路径/数值,拒绝该条目并交由英文回退处理。
guard !translation.isEmpty, Self.validPlaceholders(source: source, translation: translation) else { continue }
values[source] = translation
}
}
sourceKeys = Set(values.keys)
var exact: [String: String] = [:]
var templates: [Template] = []
for (source, translation) in values {
if source.contains("{}") {
let fragments = source.components(separatedBy: "{}")
let pattern = "^" + fragments.map(NSRegularExpression.escapedPattern(for:)).joined(separator: "(.*?)") + "$"
if let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) {
templates.append(Template(fragments: fragments.filter { !$0.isEmpty }, regex: regex, translation: translation))
}
} else {
exact[source] = translation
}
}
self.exact = exact
// 长模板优先;长度相同时稳定排序,避免相近模板因字典顺序出现随机匹配。
self.templates = templates.sorted {
let left = $0.fragments.joined(), right = $1.fragments.joined()
return left.count == right.count ? left < right : left.count > right.count
}
self.rightToLeft = rightToLeft
}
func translate(_ text: String, depth: Int = 0, fallback: Translations? = nil) -> String? {
if let translation = exact[text] { return translation }
let range = NSRange(text.startIndex..., in: text)
for template in templates where template.fragments.allSatisfy(text.contains) {
guard let match = template.regex.firstMatch(in: text, range: range) else { continue }
var values: [String] = []
for index in 1..<match.numberOfRanges {
guard let range = Range(match.range(at: index), in: text) else { continue }
let value = String(text[range])
values.append(depth < 2 && L10n.containsChinese(value)
? (translate(value, depth: depth + 1, fallback: fallback)
?? fallback?.translate(value, depth: depth + 1) ?? value) : value)
}
return fill(template.translation, values)
}
return nil
}
private func fill(_ translation: String, _ values: [String]) -> String {
// RTL 模板中的路径、IP 和数值独立定向,不让标点与相邻阿拉伯文互相重排。
let values = rightToLeft ? values.map { "\u{2068}\($0)\u{2069}" } : values
// 只扫描模板,绝不再次扫描插入值,保留文件名或命令里本来就有的 {}。
var result = ""
var cursor = translation.startIndex
var sequential = 0
for match in Self.placeholder.matches(in: translation, range: NSRange(translation.startIndex..., in: translation)) {
guard let range = Range(match.range, in: translation),
let numberRange = Range(match.range(at: 1), in: translation) else { continue }
result += translation[cursor..<range.lowerBound]
let number = translation[numberRange]
let index: Int
if number.isEmpty {
index = sequential
sequential += 1
} else {
index = (Int(number) ?? 0) - 1
}
result += values.indices.contains(index) ? values[index] : String(translation[range])
cursor = range.upperBound
}
return result + translation[cursor...]
}
private static let placeholder = try! NSRegularExpression(pattern: #"\{(\d*)\}"#)
private static func validPlaceholders(source: String, translation: String) -> Bool {
let count = source.components(separatedBy: "{}").count - 1
var sequential = 0
var used = Set<Int>()
for match in placeholder.matches(in: translation, range: NSRange(translation.startIndex..., in: translation)) {
guard let range = Range(match.range(at: 1), in: translation) else { return false }
let number = translation[range]
if number.isEmpty {
sequential += 1
used.insert(sequential)
} else {
guard let index = Int(number), index > 0 else { return false }
used.insert(index)
}
}
return used == Set(1..<(count + 1))
}
}