From 4fbc9882cee57c25d877e5f657e0c7cfd152c9ca Mon Sep 17 00:00:00 2001 From: Terry Yiu <963907+tyiu@users.noreply.github.com> Date: Thu, 19 Jan 2023 21:59:37 -0500 Subject: [PATCH] Add LibreTranslate integration for machine translating notes from other languages --- damus.xcodeproj/project.pbxproj | 4 + damus/Components/InvoiceView.swift | 2 +- damus/ContentView.swift | 1 + damus/Models/LibreTranslateServer.swift | 44 ++++++ damus/Models/UserSettingsStore.swift | 74 ++++++++++ damus/Nostr/NostrEvent.swift | 6 +- damus/Views/ConfigView.swift | 34 +++++ damus/Views/NoteContentView.swift | 176 +++++++++++++++++++++++- damus/Views/ProfileView.swift | 2 +- damus/Views/SideMenuView.swift | 2 +- 10 files changed, 340 insertions(+), 5 deletions(-) create mode 100644 damus/Models/LibreTranslateServer.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 3883df8d..671de318 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 3ACB685C297633BC00C46468 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3ACB685A297633BC00C46468 /* InfoPlist.strings */; }; 3ACB685F297633BC00C46468 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3ACB685D297633BC00C46468 /* Localizable.strings */; }; 3ACBCB78295FE5C70037388A /* TimeAgoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACBCB77295FE5C70037388A /* TimeAgoTests.swift */; }; + 3AE45AF6297BB2E700C1D842 /* LibreTranslateServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */; }; 4C06670128FC7C5900038D2A /* RelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C06670028FC7C5900038D2A /* RelayView.swift */; }; 4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4C06670328FC7EC500038D2A /* Kingfisher */; }; 4C06670628FCB08600038D2A /* ImageCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C06670528FCB08600038D2A /* ImageCarousel.swift */; }; @@ -236,6 +237,7 @@ 3ACB685B297633BC00C46468 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/InfoPlist.strings"; sourceTree = ""; }; 3ACB685E297633BC00C46468 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = ""; }; 3ACBCB77295FE5C70037388A /* TimeAgoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeAgoTests.swift; sourceTree = ""; }; + 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreTranslateServer.swift; sourceTree = ""; }; 3AEB8003297CCEA800713A25 /* tr-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "tr-TR"; path = "tr-TR.lproj/InfoPlist.strings"; sourceTree = ""; }; 3AEB8004297CCEA800713A25 /* tr-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "tr-TR"; path = "tr-TR.lproj/Localizable.strings"; sourceTree = ""; }; 3AEB8005297CCEA900713A25 /* tr-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "tr-TR"; path = "tr-TR.lproj/Localizable.stringsdict"; sourceTree = ""; }; @@ -582,6 +584,7 @@ 7C45AE70297353390031D7BC /* KFImageModel.swift */, 4CF0ABD32980996B00D66079 /* Report.swift */, 4CF0ABDD2981A69500D66079 /* MutelistModel.swift */, + 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */, ); path = Models; sourceTree = ""; @@ -1171,6 +1174,7 @@ 4C06670B28FDE64700038D2A /* damus.c in Sources */, 4FE60CDD295E1C5E00105A1F /* Wallet.swift in Sources */, 3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */, + 3AE45AF6297BB2E700C1D842 /* LibreTranslateServer.swift in Sources */, 4C99737B28C92A9200E53835 /* ChatroomMetadata.swift in Sources */, 4CC7AAF4297F18B400430951 /* ReplyDescription.swift in Sources */, 4C75EFA427FA577B0006080F /* PostView.swift in Sources */, diff --git a/damus/Components/InvoiceView.swift b/damus/Components/InvoiceView.swift index 33dc7f15..8142b2da 100644 --- a/damus/Components/InvoiceView.swift +++ b/damus/Components/InvoiceView.swift @@ -34,7 +34,7 @@ struct InvoiceView: View { let invoice: Invoice @State var showing_select_wallet: Bool = false - @ObservedObject var user_settings = UserSettingsStore() + @EnvironmentObject var user_settings: UserSettingsStore var PayButton: some View { Button { diff --git a/damus/ContentView.swift b/damus/ContentView.swift index b7f61b8f..669c540f 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -292,6 +292,7 @@ struct ContentView: View { .padding([.bottom], 8) } } + .environmentObject(user_settings) .onAppear() { self.connect() //KingfisherManager.shared.cache.clearDiskCache() diff --git a/damus/Models/LibreTranslateServer.swift b/damus/Models/LibreTranslateServer.swift new file mode 100644 index 00000000..7b9c796e --- /dev/null +++ b/damus/Models/LibreTranslateServer.swift @@ -0,0 +1,44 @@ +// +// LibreTranslateServer.swift +// damus +// +// Created by Terry Yiu on 1/21/23. +// + +import Foundation + +enum LibreTranslateServer: String, CaseIterable, Identifiable { + var id: String { self.rawValue } + + struct Model: Identifiable, Hashable { + var id: String { self.tag } + var tag: String + var displayName: String + var url: String? + } + + case none + case argosopentech + case terraprint + case vern + case custom + + var model: Model { + switch self { + case .none: + return .init(tag: self.rawValue, displayName: NSLocalizedString("None", comment: "Dropdown option for selecting no translation server."), url: nil) + case .argosopentech: + return .init(tag: self.rawValue, displayName: "translate.argosopentech.com", url: "https://translate.argosopentech.com") + case .terraprint: + return .init(tag: self.rawValue, displayName: "translate.terraprint.co", url: "https://translate.terraprint.co") + case .vern: + return .init(tag: self.rawValue, displayName: "lt.vern.cc", url: "https://lt.vern.cc") + case .custom: + return .init(tag: self.rawValue, displayName: NSLocalizedString("Custom", comment: "Dropdown option for selecting a custom translation server."), url: nil) + } + } + + static var allModels: [Model] { + return Self.allCases.map { $0.model } + } +} diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift index fd09d3d2..749dfd81 100644 --- a/damus/Models/UserSettingsStore.swift +++ b/damus/Models/UserSettingsStore.swift @@ -6,6 +6,7 @@ // import Foundation +import Vault class UserSettingsStore: ObservableObject { @Published var default_wallet: Wallet { @@ -26,6 +27,44 @@ class UserSettingsStore: ObservableObject { } } + @Published var libretranslate_server: LibreTranslateServer { + didSet { + if oldValue == libretranslate_server { + return + } + + UserDefaults.standard.set(libretranslate_server.rawValue, forKey: "libretranslate_server") + + libretranslate_api_key = "" + + if libretranslate_server == .custom || libretranslate_server == .none { + libretranslate_url = "" + } else { + libretranslate_url = libretranslate_server.model.url! + } + } + } + + @Published var libretranslate_url: String { + didSet { + UserDefaults.standard.set(libretranslate_url, forKey: "libretranslate_url") + } + } + + @Published var libretranslate_api_key: String { + didSet { + do { + if libretranslate_api_key == "" { + try clearLibreTranslateApiKey() + } else { + try saveLibreTranslateApiKey(libretranslate_api_key) + } + } catch { + // No-op. + } + } + } + init() { if let defaultWalletName = UserDefaults.standard.string(forKey: "default_wallet"), let default_wallet = Wallet(rawValue: defaultWalletName) @@ -37,5 +76,40 @@ class UserSettingsStore: ObservableObject { show_wallet_selector = UserDefaults.standard.object(forKey: "show_wallet_selector") as? Bool ?? true left_handed = UserDefaults.standard.object(forKey: "left_handed") as? Bool ?? false + + if let translationServerName = UserDefaults.standard.string(forKey: "libretranslate_server"), + let translationServer = LibreTranslateServer(rawValue: translationServerName) { + self.libretranslate_server = translationServer + libretranslate_url = translationServer.model.url ?? UserDefaults.standard.object(forKey: "libretranslate_url") as? String ?? "" + } else { + // Note from @tyiu: + // Default server is disabled by default for now until we gain some confidence that it is working well in production. + // Instead of throwing all Damus users onto feature immediately, allow for discovery of feature organically. + // Also, we are connecting to servers listed as mirrors on the official LibreTranslate GitHub README that do not require API keys. + // However, we have not asked them for permission to use, so we're trying to be good neighbors for now. + // Opportunity: spin up dedicated trusted LibreTranslate server that requires an API key for any access (or higher rate limit access). + libretranslate_server = .none + libretranslate_url = "" + } + + do { + libretranslate_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusLibreTranslateKeychainConfiguration()) + } catch { + libretranslate_api_key = "" + } + } + + func saveLibreTranslateApiKey(_ apiKey: String) throws { + try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusLibreTranslateKeychainConfiguration()) + } + + func clearLibreTranslateApiKey() throws { + try Vault.deletePrivateKey(keychainConfiguration: DamusLibreTranslateKeychainConfiguration()) } } + +struct DamusLibreTranslateKeychainConfiguration: KeychainConfiguration { + var serviceName = "damus" + var accessGroup: String? = nil + var accountName = "libretranslate_apikey" +} diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift index 1344c835..2f459924 100644 --- a/damus/Nostr/NostrEvent.swift +++ b/damus/Nostr/NostrEvent.swift @@ -103,11 +103,15 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has if let bs = _blocks { return bs } - let blocks = parse_mentions(content: self.get_content(privkey), tags: self.tags) + let blocks = get_blocks(content: self.get_content(privkey)) self._blocks = blocks return blocks } + func get_blocks(content: String) -> [Block] { + return parse_mentions(content: content, tags: self.tags) + } + lazy var inner_event: NostrEvent? = { // don't try to deserialize an inner event if we know there won't be one if self.known_kind == .boost { diff --git a/damus/Views/ConfigView.swift b/damus/Views/ConfigView.swift index f5861811..34c53d21 100644 --- a/damus/Views/ConfigView.swift +++ b/damus/Views/ConfigView.swift @@ -15,6 +15,7 @@ struct ConfigView: View { @State var confirm_logout: Bool = false @State var new_relay: String = "" @State var show_privkey: Bool = false + @State var show_libretranslate_api_key: Bool = false @State var privkey: String @State var privkey_copied: Bool = false @State var pubkey_copied: Bool = false @@ -116,6 +117,39 @@ struct ConfigView: View { } } + Section(NSLocalizedString("LibreTranslate Translations", comment: "Section title for selecting the server that hosts the LibreTranslate machine translation API.")) { + Picker(NSLocalizedString("Server", comment: "Prompt selection of LibreTranslate server to perform machine translations on notes"), selection: $user_settings.libretranslate_server) { + ForEach(LibreTranslateServer.allCases, id: \.self) { server in + Text(server.model.displayName) + .tag(server.model.tag) + } + } + + if user_settings.libretranslate_server != .none { + TextField(NSLocalizedString("URL", comment: "Example URL to LibreTranslate server"), text: $user_settings.libretranslate_url) + .disableAutocorrection(true) + .disabled(user_settings.libretranslate_server != .custom) + .autocapitalization(UITextAutocapitalizationType.none) + HStack { + if show_libretranslate_api_key { + TextField(NSLocalizedString("API Key (optional)", comment: "Example URL to LibreTranslate server"), text: $user_settings.libretranslate_api_key) + .disableAutocorrection(true) + .autocapitalization(UITextAutocapitalizationType.none) + Button(NSLocalizedString("Hide API Key", comment: "Button to hide the LibreTranslate server API key.")) { + show_libretranslate_api_key = false + } + } else { + SecureField(NSLocalizedString("API Key (optional)", comment: "Example URL to LibreTranslate server"), text: $user_settings.libretranslate_api_key) + .disableAutocorrection(true) + .autocapitalization(UITextAutocapitalizationType.none) + Button(NSLocalizedString("Show API Key", comment: "Button to hide the LibreTranslate server API key.")) { + show_libretranslate_api_key = true + } + } + } + } + } + Section(NSLocalizedString("Left Handed", comment: "Moves the post button to the left side of the screen")) { Toggle(NSLocalizedString("Left Handed", comment: "Moves the post button to the left side of the screen"), isOn: $user_settings.left_handed) .toggleStyle(.switch) diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift index 0c76d682..f1740011 100644 --- a/damus/Views/NoteContentView.swift +++ b/damus/Views/NoteContentView.swift @@ -8,6 +8,10 @@ import SwiftUI import LinkPresentation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + struct NoteArtifacts { let content: AttributedString let images: [URL] @@ -21,6 +25,10 @@ struct NoteArtifacts { func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: String?) -> NoteArtifacts { let blocks = ev.blocks(privkey) + return render_blocks(blocks: blocks, profiles: profiles, privkey: privkey) +} + +func render_blocks(blocks: [Block], profiles: Profiles, privkey: String?) -> NoteArtifacts { var invoices: [Invoice] = [] var img_urls: [URL] = [] var link_urls: [URL] = [] @@ -47,7 +55,7 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: String?) - } } } - + return NoteArtifacts(content: txt, images: img_urls, invoices: invoices, links: link_urls) } @@ -64,10 +72,18 @@ struct NoteContentView: View { let show_images: Bool + @State var checkingTranslationStatus: Bool = false + @State var language: String? = nil + @State var translated_note: String? = nil + @State var show_translated_note: Bool = false + @State var translated_artifacts: NoteArtifacts? = nil + @State var artifacts: NoteArtifacts @State var preview: LinkViewRepresentable? = nil let size: EventViewKind + + @EnvironmentObject var user_settings: UserSettingsStore func MainContent() -> some View { return VStack(alignment: .leading) { @@ -75,6 +91,29 @@ struct NoteContentView: View { .font(eventviewsize_to_font(size)) .fixedSize(horizontal: false, vertical: true) + if size == .selected && language != nil && translated_artifacts != nil { + let languageName = Locale.current.localizedString(forLanguageCode: language!) + if show_translated_note { + Button(NSLocalizedString("Translated from \(languageName!)", comment: "Button to indicate that the note has been translated from a different language.")) { + show_translated_note = false + } + .font(.footnote) + .contentShape(Rectangle()) + .padding(.top, 10) + + Text(translated_artifacts!.content) + .font(eventviewsize_to_font(size)) + .fixedSize(horizontal: false, vertical: true) + } else { + Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) { + show_translated_note = true + } + .font(.footnote) + .contentShape(Rectangle()) + .padding(.top, 10) + } + } + if show_images && artifacts.images.count > 0 { ImageCarousel(urls: artifacts.images) } else if !show_images && artifacts.images.count > 0 { @@ -142,6 +181,35 @@ struct NoteContentView: View { previews.store(evid: self.event.id, preview: view) self.preview = view } + + if size == .selected && language == nil && !checkingTranslationStatus && user_settings.libretranslate_url != "" { + checkingTranslationStatus = true + + let currentLanguage = Locale.current.languageCode ?? "en" + let translator = Translator(user_settings.libretranslate_url, apiKey: user_settings.libretranslate_api_key) + + do { + language = try await translator.detect(event.content) + + if language == nil { + language = currentLanguage + translated_note = nil + } else if language != currentLanguage { + translated_note = try await translator.translate(event.content, from: language!, to: currentLanguage) + + if translated_note != nil { + let blocks = event.get_blocks(content: translated_note!) + translated_artifacts = render_blocks(blocks: blocks, profiles: profiles, privkey: privkey) + } + } + } catch { + // If for whatever reason we're not able to figure out the language of the note, or translate the note, fail gracefully and do not retry. It's not the end of the world. Don't want to take down someone's translation server with an accidental denial of service attack. + language = currentLanguage + translated_note = nil + } + + checkingTranslationStatus = false + } } } @@ -196,6 +264,112 @@ func mention_str(_ m: Mention, profiles: Profiles) -> AttributedString { } +public struct Translator { + private let url: String + private let apiKey: String? + private let session = URLSession.shared + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + public init(_ url: String, apiKey: String? = nil) { + self.url = url + self.apiKey = apiKey + } + + public func detect(_ text: String) async throws -> String? { + let url = try makeURL(path: "/detect") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + struct RequestBody: Encodable { + let q: String + let api_key: String? + } + let body = RequestBody(q: text, api_key: apiKey) + request.httpBody = try encoder.encode(body) + + struct Response: Decodable { + let confidence: Double + let language: String + } + + let data = try await session.data(for: request) + let response = try decoder.decode([Response].self, from: data) + let language = response.first! + + if language.confidence >= 80 { + return language.language + } else { + return nil + } + } + + public func translate(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> String { + let url = try makeURL(path: "/translate") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + struct RequestBody: Encodable { + let q: String + let source: String + let target: String + let api_key: String? + } + let body = RequestBody(q: text, source: sourceLanguage, target: targetLanguage, api_key: apiKey) + request.httpBody = try encoder.encode(body) + + struct Response: Decodable { + let translatedText: String + } + let response: Response = try await decodedData(for: request) + return response.translatedText + } + + private func makeURL(path: String) throws -> URL { + guard var components = URLComponents(string: url) else { + throw URLError(.badURL) + } + components.path = path + guard let url = components.url else { + throw URLError(.badURL) + } + return url + } + + private func decodedData(for request: URLRequest) async throws -> Output { + let data = try await session.data(for: request) + let result = try decoder.decode(Output.self, from: data) + return result + } +} + +private extension URLSession { + func data(for request: URLRequest) async throws -> Data { + var task: URLSessionDataTask? + let onCancel = { task?.cancel() } + return try await withTaskCancellationHandler( + operation: { + try await withCheckedThrowingContinuation { continuation in + task = dataTask(with: request) { data, _, error in + guard let data = data else { + let error = error ?? URLError(.badServerResponse) + return continuation.resume(throwing: error) + } + continuation.resume(returning: data) + } + task?.resume() + } + }, + onCancel: { onCancel() } + ) + } +} + + struct NoteContentView_Previews: PreviewProvider { static var previews: some View { let state = test_damus_state() diff --git a/damus/Views/ProfileView.swift b/damus/Views/ProfileView.swift index 4be870a1..85179b62 100644 --- a/damus/Views/ProfileView.swift +++ b/damus/Views/ProfileView.swift @@ -115,7 +115,7 @@ struct ProfileView: View { @State var is_zoomed: Bool = false @State var show_share_sheet: Bool = false @State var action_sheet_presented: Bool = false - @StateObject var user_settings = UserSettingsStore() + @EnvironmentObject var user_settings: UserSettingsStore @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme diff --git a/damus/Views/SideMenuView.swift b/damus/Views/SideMenuView.swift index d28b3a80..710cb56c 100644 --- a/damus/Views/SideMenuView.swift +++ b/damus/Views/SideMenuView.swift @@ -12,7 +12,7 @@ struct SideMenuView: View { @Binding var isSidebarVisible: Bool @State var confirm_logout: Bool = false - @StateObject var user_settings = UserSettingsStore() + @EnvironmentObject var user_settings: UserSettingsStore @State private var showQRCode = false