damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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 }