| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- //
- // AppLocalization.swift
- // App for Indeed
- //
- // English-first localization using Localizable.strings (same pattern as the LinkedIn app).
- // Add more locales by creating `<locale>.lproj/Localizable.strings` and extending AppLanguage.
- //
- import Foundation
- enum AppLanguage: CaseIterable {
- case english
- case german
- case swedish
- case frenchCanada
- case french
- case arabic
- case chineseSimplified
- case chineseTraditional
- var localeIdentifier: String {
- switch self {
- case .english:
- return "en"
- case .german:
- return "de"
- case .swedish:
- return "sv"
- case .frenchCanada:
- return "fr-CA"
- case .french:
- return "fr"
- case .arabic:
- return "ar"
- case .chineseSimplified:
- return "zh-Hans"
- case .chineseTraditional:
- return "zh-Hant"
- }
- }
- static var systemLanguage: AppLanguage {
- let preferred = Locale.preferredLanguages.first ?? "en"
- let lower = preferred.lowercased()
- if lower.hasPrefix("zh-hant")
- || lower.hasPrefix("zh-tw")
- || lower.hasPrefix("zh-hk")
- || lower.hasPrefix("zh-mo")
- || lower.contains("-hant") {
- return .chineseTraditional
- }
- if lower.hasPrefix("zh") {
- return .chineseSimplified
- }
- if lower.hasPrefix("de") {
- return .german
- }
- if lower.hasPrefix("sv") {
- return .swedish
- }
- if lower.hasPrefix("en") {
- return .english
- }
- if lower.hasPrefix("fr-ca") {
- return .frenchCanada
- }
- if lower.hasPrefix("fr") {
- return .french
- }
- if lower.hasPrefix("ar") {
- return .arabic
- }
- for language in AppLanguage.allCases where preferred.hasPrefix(language.localeIdentifier) {
- return language
- }
- return .english
- }
- /// Settings language picker labels in each language's native form (autonym).
- var localizedDisplayName: String {
- let locale = Locale(identifier: localeIdentifier)
- guard let name = locale.localizedString(forIdentifier: localeIdentifier) else {
- return localeIdentifier
- }
- return name.capitalized(with: locale)
- }
- }
- func appLocalized(_ key: String, language: AppLanguage) -> String {
- guard let path = Bundle.main.path(forResource: language.localeIdentifier, ofType: "lproj"),
- let bundle = Bundle(path: path) else {
- return key
- }
- let value = bundle.localizedString(forKey: key, value: key, table: nil)
- return value.isEmpty ? key : value
- }
- func currentAppLanguage() -> AppLanguage {
- AppLanguageManager.resolvedLanguage
- }
- /// Resolves copy for the user’s currently selected language.
- func L(_ key: String) -> String {
- appLocalized(key, language: currentAppLanguage())
- }
- /// Localized CV template title. Built-in templates use English keys in `Localizable.strings`;
- /// AI-generated titles fall back to per-word translation when no exact key exists.
- func localizedTemplateName(_ nameKey: String) -> String {
- let language = currentAppLanguage()
- let trimmed = nameKey.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return nameKey }
- let exact = appLocalized(trimmed, language: language)
- if !exact.isEmpty, exact != trimmed { return exact }
- guard language != .english else { return trimmed }
- let expandedPhrase = tokenizeTemplateName(trimmed).joined(separator: " ")
- if expandedPhrase != trimmed {
- let phrase = appLocalized(expandedPhrase, language: language)
- if !phrase.isEmpty, phrase != expandedPhrase { return phrase }
- }
- let tokenized = translateTemplateNameByTokens(trimmed, language: language)
- return tokenized.isEmpty ? trimmed : tokenized
- }
- /// Splits space-separated, camelCase, and punctuation-joined AI template titles into words.
- private func tokenizeTemplateName(_ name: String) -> [String] {
- var s = name.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !s.isEmpty else { return [] }
- for separator in ["&", "·", "-", "_", "/"] {
- s = s.replacingOccurrences(of: separator, with: " ")
- }
- var expanded = ""
- let chars = Array(s)
- for index in chars.indices {
- let char = chars[index]
- if index > 0 {
- let prev = chars[index - 1]
- if char.isUppercase, prev.isLowercase {
- expanded.append(" ")
- } else if char.isUppercase, prev.isUppercase, index + 1 < chars.count, chars[index + 1].isLowercase {
- expanded.append(" ")
- }
- }
- expanded.append(char)
- }
- return expanded
- .split(whereSeparator: \.isWhitespace)
- .map(String.init)
- .filter { !$0.isEmpty }
- }
- private func translateTemplateNameByTokens(_ name: String, language: AppLanguage) -> String {
- let tokens = tokenizeTemplateName(name)
- guard !tokens.isEmpty else { return name }
- let translated = tokens.map { translateTemplateNameToken($0, language: language) }
- switch language {
- case .chineseSimplified, .chineseTraditional:
- return translated.joined()
- case .arabic, .english, .french, .frenchCanada, .german, .swedish:
- return translated.joined(separator: " ")
- }
- }
- private func translateTemplateNameToken(_ token: String, language: AppLanguage) -> String {
- let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return token }
- let candidates = [
- trimmed,
- trimmed.capitalized,
- trimmed.prefix(1).uppercased() + trimmed.dropFirst().lowercased()
- ]
- for candidate in candidates where !candidate.isEmpty {
- let localized = appLocalized(candidate, language: language)
- if !localized.isEmpty, localized != candidate { return localized }
- if let lex = TemplateNameTokenLexicon.lookup(candidate, language: language) {
- return lex
- }
- }
- return trimmed.prefix(1).uppercased() + trimmed.dropFirst().lowercased()
- }
- @MainActor
- final class AppLanguageManager {
- static let shared = AppLanguageManager()
- static let didChangeNotification = Notification.Name("AppLanguageManager.didChange")
- private static let legacyPreferredLanguageKey = "com.appforindeed.preferredLanguage"
- /// Settings override for this run only; cleared on launch so UI follows macOS each time.
- nonisolated(unsafe) private static var sessionOverride: AppLanguage?
- nonisolated static var resolvedLanguage: AppLanguage {
- sessionOverride ?? AppLanguage.systemLanguage
- }
- var current: AppLanguage {
- Self.resolvedLanguage
- }
- func applyStoredPreferenceOnLaunch() {
- // UI copy uses `appLocalized`; do not drive layout from `AppleLanguages` (Arabic forces RTL and breaks CV templates).
- Self.sessionOverride = nil
- UserDefaults.standard.removeObject(forKey: "AppleLanguages")
- UserDefaults.standard.removeObject(forKey: Self.legacyPreferredLanguageKey)
- }
- func setLanguage(_ language: AppLanguage, notify: Bool = true) {
- Self.sessionOverride = language
- UserDefaults.standard.removeObject(forKey: "AppleLanguages")
- if notify {
- NotificationCenter.default.post(name: Self.didChangeNotification, object: self)
- }
- }
- func setLanguage(code: String, notify: Bool = true) {
- guard let language = AppLanguage.allCases.first(where: { $0.localeIdentifier == code }) else { return }
- setLanguage(language, notify: notify)
- }
- }
|