AppLocalization.swift 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  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 dutch
  13. case finnish
  14. case greek
  15. case hebrew
  16. case italian
  17. case norwegian
  18. case polish
  19. case swedish
  20. case frenchCanada
  21. case french
  22. case arabic
  23. case chineseSimplified
  24. case chineseTraditional
  25. case korean
  26. case portugueseBrazil
  27. case russian
  28. case ukrainian
  29. case spanish
  30. case spanishMexico
  31. case vietnamese
  32. var localeIdentifier: String {
  33. switch self {
  34. case .english:
  35. return "en"
  36. case .german:
  37. return "de"
  38. case .dutch:
  39. return "nl"
  40. case .finnish:
  41. return "fi"
  42. case .greek:
  43. return "el"
  44. case .hebrew:
  45. return "he"
  46. case .italian:
  47. return "it"
  48. case .norwegian:
  49. return "nb"
  50. case .polish:
  51. return "pl"
  52. case .swedish:
  53. return "sv"
  54. case .frenchCanada:
  55. return "fr-CA"
  56. case .french:
  57. return "fr"
  58. case .arabic:
  59. return "ar"
  60. case .chineseSimplified:
  61. return "zh-Hans"
  62. case .chineseTraditional:
  63. return "zh-Hant"
  64. case .korean:
  65. return "ko"
  66. case .portugueseBrazil:
  67. return "pt-BR"
  68. case .russian:
  69. return "ru"
  70. case .ukrainian:
  71. return "uk"
  72. case .spanish:
  73. return "es"
  74. case .spanishMexico:
  75. return "es-MX"
  76. case .vietnamese:
  77. return "vi"
  78. }
  79. }
  80. static var systemLanguage: AppLanguage {
  81. let preferred = Locale.preferredLanguages.first ?? "en"
  82. let lower = preferred.lowercased()
  83. if lower.hasPrefix("zh-hant")
  84. || lower.hasPrefix("zh-tw")
  85. || lower.hasPrefix("zh-hk")
  86. || lower.hasPrefix("zh-mo")
  87. || lower.contains("-hant") {
  88. return .chineseTraditional
  89. }
  90. if lower.hasPrefix("zh") {
  91. return .chineseSimplified
  92. }
  93. if lower.hasPrefix("de") {
  94. return .german
  95. }
  96. if lower.hasPrefix("nl") {
  97. return .dutch
  98. }
  99. if lower.hasPrefix("fi") {
  100. return .finnish
  101. }
  102. if lower.hasPrefix("el") {
  103. return .greek
  104. }
  105. if lower.hasPrefix("he") {
  106. return .hebrew
  107. }
  108. if lower.hasPrefix("it") {
  109. return .italian
  110. }
  111. if lower.hasPrefix("nb") || lower.hasPrefix("no") || lower.hasPrefix("nn") {
  112. return .norwegian
  113. }
  114. if lower.hasPrefix("pl") {
  115. return .polish
  116. }
  117. if lower.hasPrefix("sv") {
  118. return .swedish
  119. }
  120. if lower.hasPrefix("en") {
  121. return .english
  122. }
  123. if lower.hasPrefix("fr-ca") {
  124. return .frenchCanada
  125. }
  126. if lower.hasPrefix("fr") {
  127. return .french
  128. }
  129. if lower.hasPrefix("ar") {
  130. return .arabic
  131. }
  132. if lower.hasPrefix("ko") {
  133. return .korean
  134. }
  135. if lower.hasPrefix("pt") {
  136. return .portugueseBrazil
  137. }
  138. if lower.hasPrefix("ru") {
  139. return .russian
  140. }
  141. if lower.hasPrefix("uk") {
  142. return .ukrainian
  143. }
  144. if lower.hasPrefix("es-mx") {
  145. return .spanishMexico
  146. }
  147. if lower.hasPrefix("es") {
  148. return .spanish
  149. }
  150. if lower.hasPrefix("vi") {
  151. return .vietnamese
  152. }
  153. for language in AppLanguage.allCases where preferred.hasPrefix(language.localeIdentifier) {
  154. return language
  155. }
  156. return .english
  157. }
  158. /// Settings language picker labels in each language's native form (autonym).
  159. var localizedDisplayName: String {
  160. let locale = Locale(identifier: localeIdentifier)
  161. guard let name = locale.localizedString(forIdentifier: localeIdentifier) else {
  162. return localeIdentifier
  163. }
  164. return name.capitalized(with: locale)
  165. }
  166. }
  167. func appLocalized(_ key: String, language: AppLanguage) -> String {
  168. guard let path = Bundle.main.path(forResource: language.localeIdentifier, ofType: "lproj"),
  169. let bundle = Bundle(path: path) else {
  170. return key
  171. }
  172. let value = bundle.localizedString(forKey: key, value: key, table: nil)
  173. return value.isEmpty ? key : value
  174. }
  175. func currentAppLanguage() -> AppLanguage {
  176. AppLanguageManager.resolvedLanguage
  177. }
  178. /// Resolves copy for the user’s currently selected language.
  179. func L(_ key: String) -> String {
  180. appLocalized(key, language: currentAppLanguage())
  181. }
  182. /// Localized CV template title. Built-in templates use English keys in `Localizable.strings`;
  183. /// AI-generated titles fall back to per-word translation when no exact key exists.
  184. func localizedTemplateName(_ nameKey: String) -> String {
  185. let language = currentAppLanguage()
  186. let trimmed = nameKey.trimmingCharacters(in: .whitespacesAndNewlines)
  187. guard !trimmed.isEmpty else { return nameKey }
  188. let exact = appLocalized(trimmed, language: language)
  189. if !exact.isEmpty, exact != trimmed { return exact }
  190. guard language != .english else { return trimmed }
  191. let expandedPhrase = tokenizeTemplateName(trimmed).joined(separator: " ")
  192. if expandedPhrase != trimmed {
  193. let phrase = appLocalized(expandedPhrase, language: language)
  194. if !phrase.isEmpty, phrase != expandedPhrase { return phrase }
  195. }
  196. let tokenized = translateTemplateNameByTokens(trimmed, language: language)
  197. return tokenized.isEmpty ? trimmed : tokenized
  198. }
  199. /// Splits space-separated, camelCase, and punctuation-joined AI template titles into words.
  200. private func tokenizeTemplateName(_ name: String) -> [String] {
  201. var s = name.trimmingCharacters(in: .whitespacesAndNewlines)
  202. guard !s.isEmpty else { return [] }
  203. for separator in ["&", "·", "-", "_", "/"] {
  204. s = s.replacingOccurrences(of: separator, with: " ")
  205. }
  206. var expanded = ""
  207. let chars = Array(s)
  208. for index in chars.indices {
  209. let char = chars[index]
  210. if index > 0 {
  211. let prev = chars[index - 1]
  212. if char.isUppercase, prev.isLowercase {
  213. expanded.append(" ")
  214. } else if char.isUppercase, prev.isUppercase, index + 1 < chars.count, chars[index + 1].isLowercase {
  215. expanded.append(" ")
  216. }
  217. }
  218. expanded.append(char)
  219. }
  220. return expanded
  221. .split(whereSeparator: \.isWhitespace)
  222. .map(String.init)
  223. .filter { !$0.isEmpty }
  224. }
  225. private func translateTemplateNameByTokens(_ name: String, language: AppLanguage) -> String {
  226. let tokens = tokenizeTemplateName(name)
  227. guard !tokens.isEmpty else { return name }
  228. let translated = tokens.map { translateTemplateNameToken($0, language: language) }
  229. switch language {
  230. case .chineseSimplified, .chineseTraditional:
  231. return translated.joined()
  232. case .arabic, .dutch, .english, .finnish, .french, .frenchCanada, .german, .greek, .hebrew, .italian, .korean, .norwegian, .polish, .portugueseBrazil, .russian, .spanish, .spanishMexico, .swedish, .ukrainian, .vietnamese:
  233. return translated.joined(separator: " ")
  234. }
  235. }
  236. private func translateTemplateNameToken(_ token: String, language: AppLanguage) -> String {
  237. let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
  238. guard !trimmed.isEmpty else { return token }
  239. let candidates = [
  240. trimmed,
  241. trimmed.capitalized,
  242. trimmed.prefix(1).uppercased() + trimmed.dropFirst().lowercased()
  243. ]
  244. for candidate in candidates where !candidate.isEmpty {
  245. let localized = appLocalized(candidate, language: language)
  246. if !localized.isEmpty, localized != candidate { return localized }
  247. if let lex = TemplateNameTokenLexicon.lookup(candidate, language: language) {
  248. return lex
  249. }
  250. }
  251. return trimmed.prefix(1).uppercased() + trimmed.dropFirst().lowercased()
  252. }
  253. @MainActor
  254. final class AppLanguageManager {
  255. static let shared = AppLanguageManager()
  256. static let didChangeNotification = Notification.Name("AppLanguageManager.didChange")
  257. private static let legacyPreferredLanguageKey = "com.appforindeed.preferredLanguage"
  258. /// Settings override for this run only; cleared on launch so UI follows macOS each time.
  259. nonisolated(unsafe) private static var sessionOverride: AppLanguage?
  260. nonisolated static var resolvedLanguage: AppLanguage {
  261. sessionOverride ?? AppLanguage.systemLanguage
  262. }
  263. var current: AppLanguage {
  264. Self.resolvedLanguage
  265. }
  266. func applyStoredPreferenceOnLaunch() {
  267. // UI copy uses `appLocalized`; do not drive layout from `AppleLanguages` (Arabic forces RTL and breaks CV templates).
  268. Self.sessionOverride = nil
  269. UserDefaults.standard.removeObject(forKey: "AppleLanguages")
  270. UserDefaults.standard.removeObject(forKey: Self.legacyPreferredLanguageKey)
  271. }
  272. func setLanguage(_ language: AppLanguage, notify: Bool = true) {
  273. Self.sessionOverride = language
  274. UserDefaults.standard.removeObject(forKey: "AppleLanguages")
  275. if notify {
  276. NotificationCenter.default.post(name: Self.didChangeNotification, object: self)
  277. }
  278. }
  279. func setLanguage(code: String, notify: Bool = true) {
  280. guard let language = AppLanguage.allCases.first(where: { $0.localeIdentifier == code }) else { return }
  281. setLanguage(language, notify: notify)
  282. }
  283. }