167 lines
7.6 KiB
Swift
167 lines
7.6 KiB
Swift
import Foundation
|
|
|
|
/// 界面语言。源代码里的文案是简体中文;选择英文时,`tr` 在显示前把中文换成英文。
|
|
///
|
|
/// 翻译表以中文原文为键:没有插值的文案精确匹配;带插值的文案用 `{}` 表示插入的内容,
|
|
/// 例如 “{} 个进程” → “{} processes”,英文里可以写 `{1}`、`{2}` 调整顺序。
|
|
public enum AppLanguage: String, CaseIterable, Sendable {
|
|
case system, chinese, english
|
|
|
|
/// 跟随系统时,系统首选语言是中文就用中文,否则用英文。
|
|
/// 读全局偏好而不是 Locale.preferredLanguages:后者会被应用自己写入的 AppleLanguages 覆盖
|
|
public var resolved: AppLanguage {
|
|
guard self == .system else { return self }
|
|
let global = CFPreferencesCopyValue("AppleLanguages" as CFString, kCFPreferencesAnyApplication,
|
|
kCFPreferencesCurrentUser, kCFPreferencesAnyHost) as? [String]
|
|
let preferred = (global?.first ?? Locale.preferredLanguages.first ?? "zh").lowercased()
|
|
return preferred.hasPrefix("zh") ? .chinese : .english
|
|
}
|
|
|
|
/// 让系统提供的文字(日期、显示器名称、应用名称)与界面语言一致:写入应用自己的 AppleLanguages,下次启动生效
|
|
public func applyToProcessLocale(defaults: UserDefaults = .standard) {
|
|
// 地区保持用户设置(影响日期顺序、单位等),只换语言
|
|
let region = (CFPreferencesCopyValue("AppleLocale" as CFString, kCFPreferencesAnyApplication,
|
|
kCFPreferencesCurrentUser, kCFPreferencesAnyHost) as? String)
|
|
.map { Locale(identifier: $0).region?.identifier ?? "US" } ?? "US"
|
|
switch self {
|
|
case .system:
|
|
defaults.removeObject(forKey: "AppleLanguages")
|
|
defaults.removeObject(forKey: "AppleLocale")
|
|
case .chinese:
|
|
defaults.set(["zh-Hans"], forKey: "AppleLanguages")
|
|
defaults.set("zh_Hans_\(region)", forKey: "AppleLocale")
|
|
case .english:
|
|
defaults.set(["en"], forKey: "AppleLanguages")
|
|
defaults.set("en_\(region)", forKey: "AppleLocale")
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum L10n {
|
|
/// 默认中文;应用启动时按设置调用 configure。单元测试与辅助工具不调用,始终保持中文原文
|
|
nonisolated(unsafe) public private(set) static var language: AppLanguage = .chinese
|
|
/// 截图与调试时记录没有翻译的中文,便于补全
|
|
nonisolated(unsafe) public static var missLog: URL?
|
|
|
|
public static var isEnglish: Bool { language == .english }
|
|
|
|
/// 格式化日期用的地区:英文界面用英文月份名,地区沿用系统设置
|
|
public static var locale: Locale {
|
|
guard isEnglish else { return .current }
|
|
return Locale(identifier: "en_" + (Locale.current.region?.identifier ?? "US"))
|
|
}
|
|
|
|
public static func configure(_ language: AppLanguage) {
|
|
lock.lock()
|
|
self.language = language.resolved
|
|
cache = [:]
|
|
lock.unlock()
|
|
}
|
|
|
|
private static let lock = NSLock()
|
|
nonisolated(unsafe) private static var cache: [String: String] = [:]
|
|
nonisolated(unsafe) private static var misses = Set<String>()
|
|
|
|
static func translate(_ text: String) -> String {
|
|
guard language == .english, containsChinese(text) else { return text }
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
if let cached = cache[text] { return cached }
|
|
let result = Translations.shared.translate(text) ?? {
|
|
recordMiss(text)
|
|
return 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) {
|
|
guard let missLog, misses.insert(text).inserted else { return }
|
|
if let handle = try? FileHandle(forWritingTo: missLog) {
|
|
handle.seekToEndOfFile()
|
|
handle.write(Data((text.replacingOccurrences(of: "\n", with: "\\n") + "\n").utf8))
|
|
try? handle.close()
|
|
} else {
|
|
try? Data((text + "\n").utf8).write(to: missLog)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 界面文案:英文界面下返回译文,否则原样返回
|
|
@inline(__always)
|
|
public func tr(_ text: String) -> String {
|
|
L10n.translate(text)
|
|
}
|
|
|
|
/// 翻译表:精确匹配表与带 `{}` 的模板
|
|
final class Translations: @unchecked Sendable {
|
|
static let shared = Translations()
|
|
|
|
private var exact: [String: String] = [:]
|
|
private var templates: [Template] = []
|
|
|
|
struct Template {
|
|
let fragments: [String]
|
|
let regex: NSRegularExpression
|
|
let english: String
|
|
}
|
|
|
|
private init() {
|
|
for table in Translations.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 english = String(parts[1]).replacingOccurrences(of: "\\n", with: "\n")
|
|
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, english: english))
|
|
}
|
|
} else {
|
|
exact[source] = english
|
|
}
|
|
}
|
|
}
|
|
// 片段越长越具体,优先匹配
|
|
templates.sort { $0.fragments.joined().count > $1.fragments.joined().count }
|
|
}
|
|
|
|
func translate(_ text: String, depth: Int = 0) -> String? {
|
|
if let english = exact[text] { return english }
|
|
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 r = Range(match.range(at: index), in: text) else { continue }
|
|
let value = String(text[r])
|
|
// 插进来的内容本身也可能是中文(例如来自其他模块的名称),能翻就翻
|
|
values.append(depth < 2 && L10n.containsChinese(value) ? (translate(value, depth: depth + 1) ?? value) : value)
|
|
}
|
|
return fill(template.english, values)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func fill(_ english: String, _ values: [String]) -> String {
|
|
var result = english
|
|
// 先替换带序号的 {1}{2},再按顺序替换 {}
|
|
for (index, value) in values.enumerated() {
|
|
result = result.replacingOccurrences(of: "{\(index + 1)}", with: value)
|
|
}
|
|
var iterator = values.makeIterator()
|
|
while let range = result.range(of: "{}") {
|
|
result.replaceSubrange(range, with: iterator.next() ?? "")
|
|
}
|
|
return result
|
|
}
|
|
}
|