damus

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

TranslateView.swift (8254B)


      1 //
      2 //  TranslateButton.swift
      3 //  damus
      4 //
      5 //  Created by William Casarin on 2023-02-02.
      6 //
      7 
      8 import SwiftUI
      9 import NaturalLanguage
     10 
     11 
     12 struct Translated: Equatable {
     13     let artifacts: NoteArtifactsSeparated
     14     let language: String
     15 }
     16 
     17 enum TranslateStatus: Equatable {
     18     case havent_tried
     19     case translating
     20     case translated(Translated)
     21     case not_needed
     22 }
     23 
     24 fileprivate let MIN_UNIQUE_CHARS = 2
     25 
     26 struct TranslateView: View {
     27     let damus_state: DamusState
     28     let event: NostrEvent
     29     let size: EventViewKind
     30 
     31     @Binding var isAppleTranslationPopoverPresented: Bool
     32 
     33     @ObservedObject var translations_model: TranslationModel
     34     
     35     init(damus_state: DamusState, event: NostrEvent, size: EventViewKind, isAppleTranslationPopoverPresented: Binding<Bool>) {
     36         self.damus_state = damus_state
     37         self.event = event
     38         self.size = size
     39         self._isAppleTranslationPopoverPresented = isAppleTranslationPopoverPresented
     40         self._translations_model = ObservedObject(wrappedValue: damus_state.events.get_cache_data(event.id).translations_model)
     41     }
     42     
     43     var TranslateButton: some View {
     44         Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) {
     45             if damus_state.settings.translation_service == .none {
     46                 isAppleTranslationPopoverPresented = true
     47             } else {
     48                 translate()
     49             }
     50         }
     51         .translate_button_style()
     52     }
     53     
     54     func TranslatedView(lang: String?, artifacts: NoteArtifactsSeparated, font_size: Double) -> some View {
     55         return VStack(alignment: .leading) {
     56             let translatedFromLanguageString = String(format: NSLocalizedString("Translated from %@", comment: "Button to indicate that the note has been translated from a different language."), lang ?? "ja")
     57             Text(translatedFromLanguageString)
     58                 .foregroundColor(.gray)
     59                 .font(.footnote)
     60                 .padding([.top, .bottom], 10)
     61 
     62             if self.size == .selected {
     63                 SelectableText(damus_state: damus_state, event: event, attributedString: artifacts.content.attributed, size: self.size)
     64             } else {
     65                 artifacts.content.text
     66                     .font(eventviewsize_to_font(self.size, font_size: font_size))
     67             }
     68         }
     69     }
     70     
     71     func translate() {
     72         Task {
     73             guard let note_language = translations_model.note_language else {
     74                 return
     75             }
     76             let res = await translate_note(profiles: damus_state.profiles, keypair: damus_state.keypair, event: event, settings: damus_state.settings, note_lang: note_language, purple: damus_state.purple)
     77             DispatchQueue.main.async {
     78                 self.translations_model.state = res
     79             }
     80         }
     81     }
     82         
     83     func should_transl(_ note_lang: String) -> Bool {
     84         guard should_translate(event: event, our_keypair: damus_state.keypair, note_lang: note_lang) else {
     85             return false
     86         }
     87 
     88         if TranslationService.isAppleTranslationPopoverSupported {
     89             return damus_state.settings.translation_service == .none || damus_state.settings.can_translate
     90         } else {
     91             return damus_state.settings.can_translate
     92         }
     93     }
     94     
     95     var body: some View {
     96         Group {
     97             switch self.translations_model.state {
     98             case .havent_tried:
     99                 if damus_state.settings.auto_translate && damus_state.settings.translation_service != .none {
    100                     Text("")
    101                 } else if let note_lang = translations_model.note_language, should_transl(note_lang)  {
    102                     TranslateButton
    103                 } else {
    104                     Text("")
    105                 }
    106             case .translating:
    107                 Text("")
    108             case .translated(let translated):
    109                 let languageName = Locale.current.localizedString(forLanguageCode: translated.language)
    110                 TranslatedView(lang: languageName, artifacts: translated.artifacts, font_size: damus_state.settings.font_size)
    111             case .not_needed:
    112                 Text("")
    113             }
    114         }
    115     }
    116     
    117     func translationMeetsStringDistanceRequirements(original: String, translated: String) -> Bool {
    118         return levenshteinDistanceIsGreaterThanOrEqualTo(from: original, to: translated, threshold: MIN_UNIQUE_CHARS)
    119     }
    120 }
    121 
    122 extension View {
    123     func translate_button_style() -> some View {
    124         return self
    125             .font(.footnote)
    126             .contentShape(Rectangle())
    127             .padding([.top, .bottom], 10)
    128     }
    129 }
    130 
    131 struct TranslateView_Previews: PreviewProvider {
    132     @State static var isAppleTranslationPopoverPresented: Bool = false
    133 
    134     static var previews: some View {
    135         let ds = test_damus_state
    136         TranslateView(damus_state: ds, event: test_note, size: .normal, isAppleTranslationPopoverPresented: $isAppleTranslationPopoverPresented)
    137     }
    138 }
    139 
    140 func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, settings: UserSettingsStore, note_lang: String, purple: DamusPurple) async -> TranslateStatus {
    141 
    142     // If the note language is different from our preferred languages, send a translation request.
    143     let translator = Translator(settings, purple: purple)
    144     let originalContent = event.get_content(keypair)
    145     let translated_note = try? await translator.translate(originalContent, from: note_lang, to: current_language())
    146     
    147     guard let translated_note else {
    148         // if its the same, give up and don't retry
    149         return .not_needed
    150     }
    151     
    152     guard originalContent != translated_note else {
    153         // if its the same, give up and don't retry
    154         return .not_needed
    155     }
    156     
    157     guard translationMeetsStringDistanceRequirements(original: originalContent, translated: translated_note) else {
    158         return .not_needed
    159     }
    160 
    161     // Render translated note
    162     let translated_blocks = parse_note_content(content: .content(translated_note, event.tags))
    163     let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles)
    164     
    165     // and cache it
    166     return .translated(Translated(artifacts: artifacts, language: note_lang))
    167 }
    168 
    169 func current_language() -> String {
    170     if #available(iOS 16, *) {
    171         return Locale.current.language.languageCode?.identifier ?? "en"
    172     } else {
    173         return Locale.current.languageCode ?? "en"
    174     }
    175 }
    176     
    177 func levenshteinDistanceIsGreaterThanOrEqualTo(from source: String, to target: String, threshold: Int) -> Bool {
    178     let sourceCount = source.count
    179     let targetCount = target.count
    180     
    181     // Early return if the difference in lengths is already greater than or equal to the threshold,
    182     // indicating the edit distance meets the condition without further calculation.
    183     if abs(sourceCount - targetCount) >= threshold {
    184         return true
    185     }
    186     
    187     var matrix = [[Int]](repeating: [Int](repeating: 0, count: targetCount + 1), count: sourceCount + 1)
    188 
    189     for i in 0...sourceCount {
    190         matrix[i][0] = i
    191     }
    192 
    193     for j in 0...targetCount {
    194         matrix[0][j] = j
    195     }
    196 
    197     for i in 1...sourceCount {
    198         var rowMin = Int.max
    199         for j in 1...targetCount {
    200             let sourceIndex = source.index(source.startIndex, offsetBy: i - 1)
    201             let targetIndex = target.index(target.startIndex, offsetBy: j - 1)
    202 
    203             let cost = source[sourceIndex] == target[targetIndex] ? 0 : 1
    204             matrix[i][j] = min(
    205                 matrix[i - 1][j] + 1,    // Deletion
    206                 matrix[i][j - 1] + 1,    // Insertion
    207                 matrix[i - 1][j - 1] + cost  // Substitution
    208             )
    209             rowMin = min(rowMin, matrix[i][j])
    210         }
    211         // If the minimum edit distance found in any row is already greater than or equal to the threshold,
    212         // you can conclude the edit distance meets the criteria.
    213         if rowMin >= threshold {
    214             return true
    215         }
    216     }
    217 
    218     return matrix[sourceCount][targetCount] >= threshold
    219 }
    220 
    221 func translationMeetsStringDistanceRequirements(original: String, translated: String) -> Bool {
    222     return levenshteinDistanceIsGreaterThanOrEqualTo(from: original, to: translated, threshold: MIN_UNIQUE_CHARS)
    223 }