AppLocalization.swift 7.7 KB

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