Nav apraksta

AppLocalization.swift 7.6KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. //
  2. // AppLocalization.swift
  3. // App for Indeed
  4. //
  5. // English-first localization using Localizable.strings (same pattern as the LinkedIn app).
  6. // Add more locales by creating `<locale>.lproj/Localizable.strings` and extending AppLanguage.
  7. //
  8. import Foundation
  9. enum AppLanguage: CaseIterable {
  10. case english
  11. case german
  12. case swedish
  13. case frenchCanada
  14. case french
  15. case arabic
  16. case chineseSimplified
  17. case chineseTraditional
  18. var localeIdentifier: String {
  19. switch self {
  20. case .english:
  21. return "en"
  22. case .german:
  23. return "de"
  24. case .swedish:
  25. return "sv"
  26. case .frenchCanada:
  27. return "fr-CA"
  28. case .french:
  29. return "fr"
  30. case .arabic:
  31. return "ar"
  32. case .chineseSimplified:
  33. return "zh-Hans"
  34. case .chineseTraditional:
  35. return "zh-Hant"
  36. }
  37. }
  38. static var systemLanguage: AppLanguage {
  39. let preferred = Locale.preferredLanguages.first ?? "en"
  40. let lower = preferred.lowercased()
  41. if lower.hasPrefix("zh-hant")
  42. || lower.hasPrefix("zh-tw")
  43. || lower.hasPrefix("zh-hk")
  44. || lower.hasPrefix("zh-mo")
  45. || lower.contains("-hant") {
  46. return .chineseTraditional
  47. }
  48. if lower.hasPrefix("zh") {
  49. return .chineseSimplified
  50. }
  51. if lower.hasPrefix("de") {
  52. return .german
  53. }
  54. if lower.hasPrefix("sv") {
  55. return .swedish
  56. }
  57. if lower.hasPrefix("en") {
  58. return .english
  59. }
  60. if lower.hasPrefix("fr-ca") {
  61. return .frenchCanada
  62. }
  63. if lower.hasPrefix("fr") {
  64. return .french
  65. }
  66. if lower.hasPrefix("ar") {
  67. return .arabic
  68. }
  69. for language in AppLanguage.allCases where preferred.hasPrefix(language.localeIdentifier) {
  70. return language
  71. }
  72. return .english
  73. }
  74. /// Settings language picker labels in each language's native form (autonym).
  75. var localizedDisplayName: String {
  76. let locale = Locale(identifier: localeIdentifier)
  77. guard let name = locale.localizedString(forIdentifier: localeIdentifier) else {
  78. return localeIdentifier
  79. }
  80. return name.capitalized(with: locale)
  81. }
  82. }
  83. func appLocalized(_ key: String, language: AppLanguage) -> String {
  84. guard let path = Bundle.main.path(forResource: language.localeIdentifier, ofType: "lproj"),
  85. let bundle = Bundle(path: path) else {
  86. return key
  87. }
  88. let value = bundle.localizedString(forKey: key, value: key, table: nil)
  89. return value.isEmpty ? key : value
  90. }
  91. func currentAppLanguage() -> AppLanguage {
  92. AppLanguageManager.resolvedLanguage
  93. }
  94. /// Resolves copy for the user’s currently selected language.
  95. func L(_ key: String) -> String {
  96. appLocalized(key, language: currentAppLanguage())
  97. }
  98. /// Localized CV template title. Built-in templates use English keys in `Localizable.strings`;
  99. /// AI-generated titles fall back to per-word translation when no exact key exists.
  100. func localizedTemplateName(_ nameKey: String) -> String {
  101. let language = currentAppLanguage()
  102. let trimmed = nameKey.trimmingCharacters(in: .whitespacesAndNewlines)
  103. guard !trimmed.isEmpty else { return nameKey }
  104. let exact = appLocalized(trimmed, language: language)
  105. if !exact.isEmpty, exact != trimmed { return exact }
  106. guard language != .english else { return trimmed }
  107. let expandedPhrase = tokenizeTemplateName(trimmed).joined(separator: " ")
  108. if expandedPhrase != trimmed {
  109. let phrase = appLocalized(expandedPhrase, language: language)
  110. if !phrase.isEmpty, phrase != expandedPhrase { return phrase }
  111. }
  112. let tokenized = translateTemplateNameByTokens(trimmed, language: language)
  113. return tokenized.isEmpty ? trimmed : tokenized
  114. }
  115. /// Splits space-separated, camelCase, and punctuation-joined AI template titles into words.
  116. private func tokenizeTemplateName(_ name: String) -> [String] {
  117. var s = name.trimmingCharacters(in: .whitespacesAndNewlines)
  118. guard !s.isEmpty else { return [] }
  119. for separator in ["&", "·", "-", "_", "/"] {
  120. s = s.replacingOccurrences(of: separator, with: " ")
  121. }
  122. var expanded = ""
  123. let chars = Array(s)
  124. for index in chars.indices {
  125. let char = chars[index]
  126. if index > 0 {
  127. let prev = chars[index - 1]
  128. if char.isUppercase, prev.isLowercase {
  129. expanded.append(" ")
  130. } else if char.isUppercase, prev.isUppercase, index + 1 < chars.count, chars[index + 1].isLowercase {
  131. expanded.append(" ")
  132. }
  133. }
  134. expanded.append(char)
  135. }
  136. return expanded
  137. .split(whereSeparator: \.isWhitespace)
  138. .map(String.init)
  139. .filter { !$0.isEmpty }
  140. }
  141. private func translateTemplateNameByTokens(_ name: String, language: AppLanguage) -> String {
  142. let tokens = tokenizeTemplateName(name)
  143. guard !tokens.isEmpty else { return name }
  144. let translated = tokens.map { translateTemplateNameToken($0, language: language) }
  145. switch language {
  146. case .chineseSimplified, .chineseTraditional:
  147. return translated.joined()
  148. case .arabic, .english, .french, .frenchCanada, .german, .swedish:
  149. return translated.joined(separator: " ")
  150. }
  151. }
  152. private func translateTemplateNameToken(_ token: String, language: AppLanguage) -> String {
  153. let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
  154. guard !trimmed.isEmpty else { return token }
  155. let candidates = [
  156. trimmed,
  157. trimmed.capitalized,
  158. trimmed.prefix(1).uppercased() + trimmed.dropFirst().lowercased()
  159. ]
  160. for candidate in candidates where !candidate.isEmpty {
  161. let localized = appLocalized(candidate, language: language)
  162. if !localized.isEmpty, localized != candidate { return localized }
  163. if let lex = TemplateNameTokenLexicon.lookup(candidate, language: language) {
  164. return lex
  165. }
  166. }
  167. return trimmed.prefix(1).uppercased() + trimmed.dropFirst().lowercased()
  168. }
  169. @MainActor
  170. final class AppLanguageManager {
  171. static let shared = AppLanguageManager()
  172. static let didChangeNotification = Notification.Name("AppLanguageManager.didChange")
  173. private static let legacyPreferredLanguageKey = "com.appforindeed.preferredLanguage"
  174. /// Settings override for this run only; cleared on launch so UI follows macOS each time.
  175. nonisolated(unsafe) private static var sessionOverride: AppLanguage?
  176. nonisolated static var resolvedLanguage: AppLanguage {
  177. sessionOverride ?? AppLanguage.systemLanguage
  178. }
  179. var current: AppLanguage {
  180. Self.resolvedLanguage
  181. }
  182. func applyStoredPreferenceOnLaunch() {
  183. // UI copy uses `appLocalized`; do not drive layout from `AppleLanguages` (Arabic forces RTL and breaks CV templates).
  184. Self.sessionOverride = nil
  185. UserDefaults.standard.removeObject(forKey: "AppleLanguages")
  186. UserDefaults.standard.removeObject(forKey: Self.legacyPreferredLanguageKey)
  187. }
  188. func setLanguage(_ language: AppLanguage, notify: Bool = true) {
  189. Self.sessionOverride = language
  190. UserDefaults.standard.removeObject(forKey: "AppleLanguages")
  191. if notify {
  192. NotificationCenter.default.post(name: Self.didChangeNotification, object: self)
  193. }
  194. }
  195. func setLanguage(code: String, notify: Bool = true) {
  196. guard let language = AppLanguage.allCases.first(where: { $0.localeIdentifier == code }) else { return }
  197. setLanguage(language, notify: notify)
  198. }
  199. }