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 }