Sin descripción

AppLocalization.swift 7.8KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  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 — fixed English names, not localized.
  75. var localizedDisplayName: String {
  76. switch self {
  77. case .english:
  78. return "English"
  79. case .german:
  80. return "German"
  81. case .swedish:
  82. return "Swedish"
  83. case .frenchCanada:
  84. return "French (Canada)"
  85. case .french:
  86. return "French"
  87. case .arabic:
  88. return "Arabic"
  89. case .chineseSimplified:
  90. return "Chinese (Simplified)"
  91. case .chineseTraditional:
  92. return "Chinese (Traditional)"
  93. }
  94. }
  95. }
  96. func appLocalized(_ key: String, language: AppLanguage) -> String {
  97. guard let path = Bundle.main.path(forResource: language.localeIdentifier, ofType: "lproj"),
  98. let bundle = Bundle(path: path) else {
  99. return key
  100. }
  101. let value = bundle.localizedString(forKey: key, value: key, table: nil)
  102. return value.isEmpty ? key : value
  103. }
  104. func currentAppLanguage() -> AppLanguage {
  105. AppLanguageManager.resolvedLanguage
  106. }
  107. /// Resolves copy for the user’s currently selected language.
  108. func L(_ key: String) -> String {
  109. appLocalized(key, language: currentAppLanguage())
  110. }
  111. /// Localized CV template title. Built-in templates use English keys in `Localizable.strings`;
  112. /// AI-generated titles fall back to per-word translation when no exact key exists.
  113. func localizedTemplateName(_ nameKey: String) -> String {
  114. let language = currentAppLanguage()
  115. let trimmed = nameKey.trimmingCharacters(in: .whitespacesAndNewlines)
  116. guard !trimmed.isEmpty else { return nameKey }
  117. let exact = appLocalized(trimmed, language: language)
  118. if !exact.isEmpty, exact != trimmed { return exact }
  119. guard language != .english else { return trimmed }
  120. let expandedPhrase = tokenizeTemplateName(trimmed).joined(separator: " ")
  121. if expandedPhrase != trimmed {
  122. let phrase = appLocalized(expandedPhrase, language: language)
  123. if !phrase.isEmpty, phrase != expandedPhrase { return phrase }
  124. }
  125. let tokenized = translateTemplateNameByTokens(trimmed, language: language)
  126. return tokenized.isEmpty ? trimmed : tokenized
  127. }
  128. /// Splits space-separated, camelCase, and punctuation-joined AI template titles into words.
  129. private func tokenizeTemplateName(_ name: String) -> [String] {
  130. var s = name.trimmingCharacters(in: .whitespacesAndNewlines)
  131. guard !s.isEmpty else { return [] }
  132. for separator in ["&", "·", "-", "_", "/"] {
  133. s = s.replacingOccurrences(of: separator, with: " ")
  134. }
  135. var expanded = ""
  136. let chars = Array(s)
  137. for index in chars.indices {
  138. let char = chars[index]
  139. if index > 0 {
  140. let prev = chars[index - 1]
  141. if char.isUppercase, prev.isLowercase {
  142. expanded.append(" ")
  143. } else if char.isUppercase, prev.isUppercase, index + 1 < chars.count, chars[index + 1].isLowercase {
  144. expanded.append(" ")
  145. }
  146. }
  147. expanded.append(char)
  148. }
  149. return expanded
  150. .split(whereSeparator: \.isWhitespace)
  151. .map(String.init)
  152. .filter { !$0.isEmpty }
  153. }
  154. private func translateTemplateNameByTokens(_ name: String, language: AppLanguage) -> String {
  155. let tokens = tokenizeTemplateName(name)
  156. guard !tokens.isEmpty else { return name }
  157. let translated = tokens.map { translateTemplateNameToken($0, language: language) }
  158. switch language {
  159. case .chineseSimplified, .chineseTraditional:
  160. return translated.joined()
  161. case .arabic, .english, .french, .frenchCanada, .german, .swedish:
  162. return translated.joined(separator: " ")
  163. }
  164. }
  165. private func translateTemplateNameToken(_ token: String, language: AppLanguage) -> String {
  166. let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
  167. guard !trimmed.isEmpty else { return token }
  168. let candidates = [
  169. trimmed,
  170. trimmed.capitalized,
  171. trimmed.prefix(1).uppercased() + trimmed.dropFirst().lowercased()
  172. ]
  173. for candidate in candidates where !candidate.isEmpty {
  174. let localized = appLocalized(candidate, language: language)
  175. if !localized.isEmpty, localized != candidate { return localized }
  176. if let lex = TemplateNameTokenLexicon.lookup(candidate, language: language) {
  177. return lex
  178. }
  179. }
  180. return trimmed.prefix(1).uppercased() + trimmed.dropFirst().lowercased()
  181. }
  182. @MainActor
  183. final class AppLanguageManager {
  184. static let shared = AppLanguageManager()
  185. static let didChangeNotification = Notification.Name("AppLanguageManager.didChange")
  186. private static let legacyPreferredLanguageKey = "com.appforindeed.preferredLanguage"
  187. /// Settings override for this run only; cleared on launch so UI follows macOS each time.
  188. nonisolated(unsafe) private static var sessionOverride: AppLanguage?
  189. nonisolated static var resolvedLanguage: AppLanguage {
  190. sessionOverride ?? AppLanguage.systemLanguage
  191. }
  192. var current: AppLanguage {
  193. Self.resolvedLanguage
  194. }
  195. func applyStoredPreferenceOnLaunch() {
  196. // UI copy uses `appLocalized`; do not drive layout from `AppleLanguages` (Arabic forces RTL and breaks CV templates).
  197. Self.sessionOverride = nil
  198. UserDefaults.standard.removeObject(forKey: "AppleLanguages")
  199. UserDefaults.standard.removeObject(forKey: Self.legacyPreferredLanguageKey)
  200. }
  201. func setLanguage(_ language: AppLanguage, notify: Bool = true) {
  202. Self.sessionOverride = language
  203. UserDefaults.standard.removeObject(forKey: "AppleLanguages")
  204. if notify {
  205. NotificationCenter.default.post(name: Self.didChangeNotification, object: self)
  206. }
  207. }
  208. func setLanguage(code: String, notify: Bool = true) {
  209. guard let language = AppLanguage.allCases.first(where: { $0.localeIdentifier == code }) else { return }
  210. setLanguage(language, notify: notify)
  211. }
  212. }