damus

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

TranslateView.swift (7404B)


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