damus

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

commit 567303e680993fe714419de087ee3c2cc1de7561
parent 7d1bac4028a87f9eaa1b887c11e1c7c3f693887d
Author: Terry Yiu <963907+tyiu@users.noreply.github.com>
Date:   Sat,  4 Feb 2023 12:07:27 -0500

Add DeepL translation integration

Changelog-Added: DeepL translation integration
Closes: #522

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 12++++++++++++
Mdamus/Components/TranslateView.swift | 9+++------
Adamus/Models/DeepLPlan.swift | 35+++++++++++++++++++++++++++++++++++
Mdamus/Models/LibreTranslateServer.swift | 3---
Adamus/Models/TranslationService.swift | 37+++++++++++++++++++++++++++++++++++++
Mdamus/Models/UserSettingsStore.swift | 118++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Adamus/Util/Translator.swift | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/ConfigView.swift | 133++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mdamus/Views/NoteContentView.swift | 81-------------------------------------------------------------------------------
9 files changed, 427 insertions(+), 128 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ 3AA247FD297E3CFF0090C62D /* RepostsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FC297E3CFF0090C62D /* RepostsModel.swift */; }; 3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FE297E3D900090C62D /* RepostsView.swift */; }; 3AA24802297E3DC20090C62D /* RepostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA24801297E3DC20090C62D /* RepostView.swift */; }; + 3AAA95CA298DF87B00F3D526 /* TranslationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AAA95C9298DF87B00F3D526 /* TranslationService.swift */; }; + 3AAA95CC298E07E900F3D526 /* DeepLPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AAA95CB298E07E900F3D526 /* DeepLPlan.swift */; }; + 3AB72AB9298ECF30004BB58C /* Translator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB72AB8298ECF30004BB58C /* Translator.swift */; }; 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 */; }; @@ -249,9 +252,12 @@ 3AA247FC297E3CFF0090C62D /* RepostsModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepostsModel.swift; sourceTree = "<group>"; }; 3AA247FE297E3D900090C62D /* RepostsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostsView.swift; sourceTree = "<group>"; }; 3AA24801297E3DC20090C62D /* RepostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostView.swift; sourceTree = "<group>"; }; + 3AAA95C9298DF87B00F3D526 /* TranslationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationService.swift; sourceTree = "<group>"; }; + 3AAA95CB298E07E900F3D526 /* DeepLPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLPlan.swift; sourceTree = "<group>"; }; 3AB5B86A2986D8A3006599D2 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = "<group>"; }; 3AB5B86B2986D8A3006599D2 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; }; 3AB5B86C2986D8A3006599D2 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = de; path = de.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; + 3AB72AB8298ECF30004BB58C /* Translator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Translator.swift; sourceTree = "<group>"; }; 3AC524EE298C000B00693EBF /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = "<group>"; }; 3AC524EF298C000B00693EBF /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; }; 3AC524F0298C000B00693EBF /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; @@ -621,6 +627,8 @@ 4CF0ABD32980996B00D66079 /* Report.swift */, 4CF0ABDD2981A69500D66079 /* MutelistModel.swift */, 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */, + 3AAA95C9298DF87B00F3D526 /* TranslationService.swift */, + 3AAA95CB298E07E900F3D526 /* DeepLPlan.swift */, ); path = Models; sourceTree = "<group>"; @@ -733,6 +741,7 @@ 4CB883A52975F83C00DC99E7 /* LNUrlPayRequest.swift */, 4CB883A72975FC1800DC99E7 /* Zaps.swift */, 4CB883B5297730E400DC99E7 /* LNUrls.swift */, + 3AB72AB8298ECF30004BB58C /* Translator.swift */, ); path = Util; sourceTree = "<group>"; @@ -1112,6 +1121,7 @@ 4C7FF7D52823313F009601DB /* Mentions.swift in Sources */, 4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */, 4C987B57283FD07F0042CE38 /* FollowersModel.swift in Sources */, + 3AB72AB9298ECF30004BB58C /* Translator.swift in Sources */, 4C363A9028247A1D006E126D /* NostrLink.swift in Sources */, 4C0A3F8C280F5FCA000448DE /* ChatroomView.swift in Sources */, 4C477C9E282C3A4800033AA3 /* TipCounter.swift in Sources */, @@ -1134,6 +1144,7 @@ 4C75EFB328049D640006080F /* NostrEvent.swift in Sources */, 4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */, 4C363A8428233689006E126D /* Parser.swift in Sources */, + 3AAA95CA298DF87B00F3D526 /* TranslationService.swift in Sources */, 4CE4F9E328528C5200C00DD9 /* AddRelayView.swift in Sources */, 4C363A9A28283854006E126D /* Reply.swift in Sources */, BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */, @@ -1242,6 +1253,7 @@ 4CF0ABD42980996B00D66079 /* Report.swift in Sources */, 4C0A3F97280F8E02000448DE /* ThreadView.swift in Sources */, 4C06670B28FDE64700038D2A /* damus.c in Sources */, + 3AAA95CC298E07E900F3D526 /* DeepLPlan.swift in Sources */, 4FE60CDD295E1C5E00105A1F /* Wallet.swift in Sources */, 3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */, 3AE45AF6297BB2E700C1D842 /* LibreTranslateServer.swift in Sources */, diff --git a/damus/Components/TranslateView.swift b/damus/Components/TranslateView.swift @@ -71,10 +71,7 @@ struct TranslateView: View { } } .task { - let translate_url = damus_state.settings.libretranslate_url - let api_key = damus_state.settings.libretranslate_api_key - - guard noteLanguage == nil && !checkingTranslationStatus && translate_url != "" else { + guard noteLanguage == nil && !checkingTranslationStatus && damus_state.settings.can_translate(damus_state.pubkey) else { return } @@ -91,7 +88,7 @@ struct TranslateView: View { noteLanguage = NLLanguageRecognizer.dominantLanguage(for: content)?.rawValue ?? currentLanguage if let lang = noteLanguage, noteLanguage != currentLanguage { - // If the detected dominant language is a variant, remove the variant component and just take the language part as LibreTranslate typically only supports the variant-less language. + // If the detected dominant language is a variant, remove the variant component and just take the language part as translation services typically only supports the variant-less language. if #available(iOS 16, *) { noteLanguage = Locale.LanguageCode(stringLiteral: lang).identifier(.alpha2) } else { @@ -109,7 +106,7 @@ struct TranslateView: View { if note_lang != currentLanguage { do { // If the note language is different from our language, send a translation request. - let translator = Translator(translate_url, apiKey: api_key) + let translator = Translator(damus_state.settings) translated_note = try await translator.translate(content, from: note_lang, to: currentLanguage) } 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. diff --git a/damus/Models/DeepLPlan.swift b/damus/Models/DeepLPlan.swift @@ -0,0 +1,35 @@ +// +// DeepLPlan.swift +// damus +// +// Created by Terry Yiu on 2/3/23. +// + +import Foundation + +enum DeepLPlan: 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 free + case pro + + var model: Model { + switch self { + case .free: + return .init(tag: self.rawValue, displayName: NSLocalizedString("Free", comment: "Dropdown option for selecting Free plan for DeepL translation service."), url: "https://api-free.deepl.com") + case .pro: + return .init(tag: self.rawValue, displayName: NSLocalizedString("Pro", comment: "Dropdown option for selecting Pro plan for DeepL translation service."), url: "https://api.deepl.com") + } + } + + static var allModels: [Model] { + return Self.allCases.map { $0.model } + } +} diff --git a/damus/Models/LibreTranslateServer.swift b/damus/Models/LibreTranslateServer.swift @@ -17,7 +17,6 @@ enum LibreTranslateServer: String, CaseIterable, Identifiable { var url: String? } - case none case argosopentech case terraprint case vern @@ -25,8 +24,6 @@ enum LibreTranslateServer: String, CaseIterable, Identifiable { 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: diff --git a/damus/Models/TranslationService.swift b/damus/Models/TranslationService.swift @@ -0,0 +1,37 @@ +// +// TranslationService.swift +// damus +// +// Created by Terry Yiu on 2/3/23. +// + +import Foundation + +enum TranslationService: String, CaseIterable, Identifiable { + var id: String { self.rawValue } + + struct Model: Identifiable, Hashable { + var id: String { self.tag } + var tag: String + var displayName: String + } + + case none + case libretranslate + case deepl + + var model: Model { + switch self { + case .none: + return .init(tag: self.rawValue, displayName: NSLocalizedString("None", comment: "Dropdown option for selecting no translation service.")) + case .libretranslate: + return .init(tag: self.rawValue, displayName: NSLocalizedString("LibreTranslate (Open Source)", comment: "Dropdown option for selecting LibreTranslate as the translation service.")) + case .deepl: + return .init(tag: self.rawValue, displayName: NSLocalizedString("DeepL (Proprietary, Higher Accuracy)", comment: "Dropdown option for selecting DeepL as the translation service.")) + } + } + + static var allModels: [Model] { + return Self.allCases.map { $0.model } + } +} diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift @@ -34,7 +34,23 @@ func get_default_wallet(_ pubkey: String) -> Wallet { } } -func get_libretranslate_server(_ pubkey: String) -> LibreTranslateServer? { +private func get_translation_service(_ pubkey: String) -> TranslationService? { + guard let translation_service = UserDefaults.standard.string(forKey: "translation_service") else { + return nil + } + + return TranslationService(rawValue: translation_service) +} + +private func get_deepl_plan(_ pubkey: String) -> DeepLPlan? { + guard let server_name = UserDefaults.standard.string(forKey: "deepl_plan") else { + return nil + } + + return DeepLPlan(rawValue: server_name) +} + +private func get_libretranslate_server(_ pubkey: String) -> LibreTranslateServer? { guard let server_name = UserDefaults.standard.string(forKey: "libretranslate_server") else { return nil } @@ -42,7 +58,7 @@ func get_libretranslate_server(_ pubkey: String) -> LibreTranslateServer? { return LibreTranslateServer(rawValue: server_name) } -func get_libretranslate_url(_ pubkey: String, server: LibreTranslateServer) -> String? { +private func get_libretranslate_url(_ pubkey: String, server: LibreTranslateServer) -> String? { if let url = server.model.url { return url } @@ -69,6 +85,32 @@ class UserSettingsStore: ObservableObject { } } + @Published var translation_service: TranslationService { + didSet { + UserDefaults.standard.set(translation_service.rawValue, forKey: "translation_service") + } + } + + @Published var deepl_plan: DeepLPlan { + didSet { + UserDefaults.standard.set(deepl_plan.rawValue, forKey: "deepl_plan") + } + } + + @Published var deepl_api_key: String { + didSet { + do { + if deepl_api_key == "" { + try clearDeepLApiKey() + } else { + try saveDeepLApiKey(deepl_api_key) + } + } catch { + // No-op. + } + } + } + @Published var libretranslate_server: LibreTranslateServer { didSet { if oldValue == libretranslate_server { @@ -79,7 +121,7 @@ class UserSettingsStore: ObservableObject { libretranslate_api_key = "" - if libretranslate_server == .custom || libretranslate_server == .none { + if libretranslate_server == .custom { libretranslate_url = "" } else { libretranslate_url = libretranslate_server.model.url! @@ -114,18 +156,25 @@ class UserSettingsStore: ObservableObject { show_wallet_selector = should_show_wallet_selector(pubkey) left_handed = UserDefaults.standard.object(forKey: "left_handed") as? Bool ?? false - - if let server = get_libretranslate_server(pubkey) { - self.libretranslate_server = server - self.libretranslate_url = get_libretranslate_url(pubkey, server: server) ?? "" + + // Note from @tyiu: + // Default translation service 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). + if let translation_service = get_translation_service(pubkey) { + self.translation_service = translation_service + } else { + self.translation_service = .none + } + + if let libretranslate_server = get_libretranslate_server(pubkey) { + self.libretranslate_server = libretranslate_server + self.libretranslate_url = get_libretranslate_url(pubkey, server: libretranslate_server) ?? "" } 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 + // Choose a random server to distribute load. + libretranslate_server = .allCases.filter { $0 != .custom }.randomElement()! libretranslate_url = "" } @@ -134,15 +183,46 @@ class UserSettingsStore: ObservableObject { } catch { libretranslate_api_key = "" } + + if let deepl_plan = get_deepl_plan(pubkey) { + self.deepl_plan = deepl_plan + } else { + self.deepl_plan = .free + } + + do { + deepl_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusDeepLKeychainConfiguration()) + } catch { + deepl_api_key = "" + } } - func saveLibreTranslateApiKey(_ apiKey: String) throws { + private func saveLibreTranslateApiKey(_ apiKey: String) throws { try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusLibreTranslateKeychainConfiguration()) } - func clearLibreTranslateApiKey() throws { + private func clearLibreTranslateApiKey() throws { try Vault.deletePrivateKey(keychainConfiguration: DamusLibreTranslateKeychainConfiguration()) } + + private func saveDeepLApiKey(_ apiKey: String) throws { + try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusDeepLKeychainConfiguration()) + } + + private func clearDeepLApiKey() throws { + try Vault.deletePrivateKey(keychainConfiguration: DamusDeepLKeychainConfiguration()) + } + + func can_translate(_ pubkey: String) -> Bool { + switch translation_service { + case .none: + return false + case .libretranslate: + return URLComponents(string: libretranslate_url) != nil + case .deepl: + return deepl_api_key != "" + } + } } struct DamusLibreTranslateKeychainConfiguration: KeychainConfiguration { @@ -150,3 +230,9 @@ struct DamusLibreTranslateKeychainConfiguration: KeychainConfiguration { var accessGroup: String? = nil var accountName = "libretranslate_apikey" } + +struct DamusDeepLKeychainConfiguration: KeychainConfiguration { + var serviceName = "damus" + var accessGroup: String? = nil + var accountName = "deepl_apikey" +} diff --git a/damus/Util/Translator.swift b/damus/Util/Translator.swift @@ -0,0 +1,127 @@ +// +// Translator.swift +// damus +// +// Created by Terry Yiu on 2/4/23. +// + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct Translator { + private let userSettingsStore: UserSettingsStore + private let session = URLSession.shared + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(_ userSettingsStore: UserSettingsStore) { + self.userSettingsStore = userSettingsStore + } + + public func translate(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> String? { + switch userSettingsStore.translation_service { + case .libretranslate: + return try await translateWithLibreTranslate(text, from: sourceLanguage, to: targetLanguage) + case .deepl: + return try await translateWithDeepL(text, from: sourceLanguage, to: targetLanguage) + case .none: + return text + } + } + + private func translateWithLibreTranslate(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> String? { + let url = try makeURL(userSettingsStore.libretranslate_url, 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: userSettingsStore.libretranslate_api_key) + 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 translateWithDeepL(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> String? { + if userSettingsStore.deepl_api_key == "" { + return nil + } + + let url = try makeURL(userSettingsStore.deepl_plan.model.url, path: "/v2/translate") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("DeepL-Auth-Key \(userSettingsStore.deepl_api_key)", forHTTPHeaderField: "Authorization") + + struct RequestBody: Encodable { + let text: [String] + let source_lang: String + let target_lang: String + } + let body = RequestBody(text: [text], source_lang: sourceLanguage.uppercased(), target_lang: targetLanguage.uppercased()) + request.httpBody = try encoder.encode(body) + + struct Response: Decodable { + let translations: [DeepLTranslations] + } + struct DeepLTranslations: Decodable { + let detected_source_language: String + let text: String + } + + let response: Response = try await decodedData(for: request) + return response.translations.map { $0.text }.joined(separator: " ") + } + + private func makeURL(_ baseUrl: String, path: String) throws -> URL { + guard var components = URLComponents(string: baseUrl) else { + throw URLError(.badURL) + } + components.path = path + guard let url = components.url else { + throw URLError(.badURL) + } + return url + } + + private func decodedData<Output: Decodable>(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() } + ) + } +} diff --git a/damus/Views/ConfigView.swift b/damus/Views/ConfigView.swift @@ -16,7 +16,7 @@ struct ConfigView: View { @State var confirm_delete_account: Bool = false @State var show_privkey: Bool = false @State var has_authenticated_locally: Bool = false - @State var show_libretranslate_api_key: Bool = false + @State var show_api_key: Bool = false @State var privkey: String @State var privkey_copied: Bool = false @State var pubkey_copied: Bool = false @@ -125,36 +125,50 @@ 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: $settings.libretranslate_server) { - ForEach(LibreTranslateServer.allCases, id: \.self) { server in + Section(NSLocalizedString("Translations", comment: "Section title for selecting the translation service.")) { + Picker(NSLocalizedString("Service", comment: "Prompt selection of translation service provider."), selection: $settings.translation_service) { + ForEach(TranslationService.allCases, id: \.self) { server in Text(server.model.displayName) .tag(server.model.tag) } } - if settings.libretranslate_server != .none { - TextField(NSLocalizedString("URL", comment: "Example URL to LibreTranslate server"), text: $settings.libretranslate_url) + if settings.translation_service == .libretranslate { + Picker(NSLocalizedString("Server", comment: "Prompt selection of LibreTranslate server to perform machine translations on notes"), selection: $settings.libretranslate_server) { + ForEach(LibreTranslateServer.allCases, id: \.self) { server in + Text(server.model.displayName) + .tag(server.model.tag) + } + } + + if settings.libretranslate_server == .custom { + TextField(NSLocalizedString("URL", comment: "Example URL to LibreTranslate server"), text: $settings.libretranslate_url) + .disableAutocorrection(true) + .autocapitalization(UITextAutocapitalizationType.none) + } + + SecureField(NSLocalizedString("API Key (optional)", comment: "Prompt for optional entry of API Key to use translation server."), text: $settings.libretranslate_api_key) .disableAutocorrection(true) - .disabled(settings.libretranslate_server != .custom) + .disabled(settings.translation_service != .libretranslate) .autocapitalization(UITextAutocapitalizationType.none) - HStack { - if show_libretranslate_api_key { - TextField(NSLocalizedString("API Key (optional)", comment: "Example URL to LibreTranslate server"), text: $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: $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 - } + } + + if settings.translation_service == .deepl { + Picker(NSLocalizedString("Plan", comment: "Prompt selection of DeepL subscription plan to perform machine translations on notes"), selection: $settings.deepl_plan) { + ForEach(DeepLPlan.allCases, id: \.self) { server in + Text(server.model.displayName) + .tag(server.model.tag) } } + + SecureField(NSLocalizedString("API Key (required)", comment: "Prompt for required entry of API Key to use translation server."), text: $settings.deepl_api_key) + .disableAutocorrection(true) + .disabled(settings.translation_service != .deepl) + .autocapitalization(UITextAutocapitalizationType.none) + + if settings.deepl_api_key == "" { + Link(NSLocalizedString("Get API Key", comment: "Button to navigate to DeepL website to get a translation API key."), destination: URL(string: "https://www.deepl.com/pro-api")!) + } } } @@ -215,6 +229,81 @@ struct ConfigView: View { dismiss() } } + + var libretranslate_view: some View { + VStack { + Picker(NSLocalizedString("Server", comment: "Prompt selection of LibreTranslate server to perform machine translations on notes"), selection: $settings.libretranslate_server) { + ForEach(LibreTranslateServer.allCases, id: \.self) { server in + Text(server.model.displayName) + .tag(server.model.tag) + } + } + + TextField(NSLocalizedString("URL", comment: "Example URL to LibreTranslate server"), text: $settings.libretranslate_url) + .disableAutocorrection(true) + .disabled(settings.libretranslate_server != .custom) + .autocapitalization(UITextAutocapitalizationType.none) + HStack { + let libretranslate_api_key_placeholder = NSLocalizedString("API Key (optional)", comment: "Prompt for optional entry of API Key to use translation server.") + if show_api_key { + TextField(libretranslate_api_key_placeholder, text: $settings.libretranslate_api_key) + .disableAutocorrection(true) + .autocapitalization(UITextAutocapitalizationType.none) + if settings.libretranslate_api_key != "" { + Button(NSLocalizedString("Hide API Key", comment: "Button to hide the LibreTranslate server API key.")) { + show_api_key = false + } + } + } else { + SecureField(libretranslate_api_key_placeholder, text: $settings.libretranslate_api_key) + .disableAutocorrection(true) + .autocapitalization(UITextAutocapitalizationType.none) + if settings.libretranslate_api_key != "" { + Button(NSLocalizedString("Show API Key", comment: "Button to show the LibreTranslate server API key.")) { + show_api_key = true + } + } + } + } + } + } + + var deepl_view: some View { + VStack { + Picker(NSLocalizedString("Plan", comment: "Prompt selection of DeepL subscription plan to perform machine translations on notes"), selection: $settings.deepl_plan) { + ForEach(DeepLPlan.allCases, id: \.self) { server in + Text(server.model.displayName) + .tag(server.model.tag) + } + } + + HStack { + let deepl_api_key_placeholder = NSLocalizedString("API Key (required)", comment: "Prompt for required entry of API Key to use translation server.") + if show_api_key { + TextField(deepl_api_key_placeholder, text: $settings.deepl_api_key) + .disableAutocorrection(true) + .autocapitalization(UITextAutocapitalizationType.none) + if settings.deepl_api_key != "" { + Button(NSLocalizedString("Hide API Key", comment: "Button to hide the DeepL translation API key.")) { + show_api_key = false + } + } + } else { + SecureField(deepl_api_key_placeholder, text: $settings.deepl_api_key) + .disableAutocorrection(true) + .autocapitalization(UITextAutocapitalizationType.none) + if settings.deepl_api_key != "" { + Button(NSLocalizedString("Show API Key", comment: "Button to show the DeepL translation API key.")) { + show_api_key = true + } + } + } + if settings.deepl_api_key == "" { + Link(NSLocalizedString("Get API Key", comment: "Button to navigate to DeepL website to get a translation API key."), destination: URL(string: "https://www.deepl.com/pro-api")!) + } + } + } + } } struct ConfigView_Previews: PreviewProvider { diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift @@ -9,10 +9,6 @@ import SwiftUI import LinkPresentation import NaturalLanguage -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - struct NoteContentView: View { let damus_state: DamusState let event: NostrEvent @@ -154,83 +150,6 @@ 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 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<Output: Decodable>(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()