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 }