Translations.swift (4644B)
1 // 2 // Translations.swift 3 // damus 4 // 5 // Created by Terry Yiu on 3/29/23. 6 // 7 8 import Foundation 9 import NaturalLanguage 10 11 class Translations: ObservableObject { 12 private static let languageDetectionMinConfidence = 0.5 13 14 @Published var translations: [NostrEvent: String] = [:] 15 @Published var languages: [NostrEvent: String] = [:] 16 17 let settings: UserSettingsStore 18 19 let translator: Translator 20 21 let targetLanguage = currentLanguage() 22 let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) }) 23 24 init(_ settings: UserSettingsStore) { 25 self.settings = settings 26 self.translator = Translator(settings) 27 } 28 29 /** 30 Attempts to detect the language of the content of a given nostr event using Apple's offline NaturalLanguage API. 31 The detected language will be returned only if it has a 50% or more confidence. 32 This is a best effort guess and could be incorrect. 33 */ 34 func detectLanguage(_ event: NostrEvent, state: DamusState) -> String? { 35 if let cachedLanguage = languages[event] { 36 return cachedLanguage 37 } 38 39 // Rely on Apple's NLLanguageRecognizer to tell us which language it thinks the note is in 40 // and filter on only the text portions of the content as URLs and hashtags confuse the language recognizer. 41 let originalBlocks = event.blocks(state.keypair.privkey) 42 let originalOnlyText = originalBlocks.compactMap { $0.is_text }.joined(separator: " ") 43 44 // Only accept language recognition hypothesis if there's at least a 50% probability that it's accurate. 45 let languageRecognizer = NLLanguageRecognizer() 46 languageRecognizer.processString(originalOnlyText) 47 48 guard let locale = languageRecognizer.languageHypotheses(withMaximum: 1).first(where: { $0.value >= Translations.languageDetectionMinConfidence })?.key.rawValue else { 49 return nil 50 } 51 52 // Remove the variant component and just take the language part as translation services typically only supports the variant-less language. 53 // Moreover, speakers of one variant can generally understand other variants. 54 let language = localeToLanguage(locale) 55 languages[event] = language 56 return language 57 } 58 59 func translate(_ event: NostrEvent, state: DamusState) async -> TranslationWithLanguage? { 60 guard shouldTranslate(event, state: state) else { 61 return nil 62 } 63 64 guard let noteLanguage = detectLanguage(event, state: state) else { 65 return nil 66 } 67 68 let translationWithLanguage: TranslationWithLanguage 69 70 if let cachedTranslation = translations[event] { 71 translationWithLanguage = TranslationWithLanguage(translation: cachedTranslation, language: noteLanguage) 72 } else { 73 do { 74 guard let _translationWithLanguage = try await translator.translate(event.get_content(state.keypair.privkey), from: noteLanguage, to: targetLanguage) else { 75 return nil 76 } 77 78 translationWithLanguage = _translationWithLanguage 79 translations[event] = translationWithLanguage.translation 80 languages[event] = translationWithLanguage.language 81 } catch { 82 return nil 83 } 84 } 85 86 // If the translated content is identical to the original content, don't return the translation. 87 if translationWithLanguage.translation == event.get_content(state.keypair.privkey) { 88 languages[event] = targetLanguage 89 return nil 90 } else { 91 return translationWithLanguage 92 } 93 } 94 95 func shouldTranslate(_ event: NostrEvent, state: DamusState) -> Bool { 96 // Do not translate self-authored content because if the language recognizer guesses the wrong language for your own note, 97 // it's annoying and unexpected for the translation to show up. 98 if event.pubkey == state.pubkey && state.is_privkey_user { 99 return false 100 } 101 102 // Avoid translating notes if language cannot be detected or if it is in one of the user's preferred languages. 103 guard let noteLanguage = detectLanguage(event, state: state), !preferredLanguages.contains(noteLanguage) else { 104 return false 105 } 106 107 switch settings.translation_service { 108 case .none: 109 return false 110 case .libretranslate: 111 return URLComponents(string: settings.libretranslate_url) != nil 112 case .deepl: 113 return settings.deepl_api_key != "" 114 } 115 } 116 }