Compare commits

...

1 Commits

Author SHA1 Message Date
cd9e8bf9b1 Add DeepL translation integration 2023-02-04 15:02:18 -05:00
9 changed files with 429 additions and 130 deletions

View File

@@ -15,6 +15,9 @@
3AA247FD297E3CFF0090C62D /* RepostsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FC297E3CFF0090C62D /* RepostsModel.swift */; }; 3AA247FD297E3CFF0090C62D /* RepostsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FC297E3CFF0090C62D /* RepostsModel.swift */; };
3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FE297E3D900090C62D /* RepostsView.swift */; }; 3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FE297E3D900090C62D /* RepostsView.swift */; };
3AA24802297E3DC20090C62D /* RepostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA24801297E3DC20090C62D /* RepostView.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 */; }; 3ACB685C297633BC00C46468 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3ACB685A297633BC00C46468 /* InfoPlist.strings */; };
3ACB685F297633BC00C46468 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3ACB685D297633BC00C46468 /* Localizable.strings */; }; 3ACB685F297633BC00C46468 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3ACB685D297633BC00C46468 /* Localizable.strings */; };
3ACBCB78295FE5C70037388A /* TimeAgoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACBCB77295FE5C70037388A /* TimeAgoTests.swift */; }; 3ACBCB78295FE5C70037388A /* TimeAgoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACBCB77295FE5C70037388A /* TimeAgoTests.swift */; };
@@ -244,9 +247,12 @@
3AA247FC297E3CFF0090C62D /* RepostsModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepostsModel.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 3AC524F0298C000B00693EBF /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
@@ -617,6 +623,8 @@
4CF0ABD32980996B00D66079 /* Report.swift */, 4CF0ABD32980996B00D66079 /* Report.swift */,
4CF0ABDD2981A69500D66079 /* MutelistModel.swift */, 4CF0ABDD2981A69500D66079 /* MutelistModel.swift */,
3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */, 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */,
3AAA95C9298DF87B00F3D526 /* TranslationService.swift */,
3AAA95CB298E07E900F3D526 /* DeepLPlan.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -729,6 +737,7 @@
4CB883A52975F83C00DC99E7 /* LNUrlPayRequest.swift */, 4CB883A52975F83C00DC99E7 /* LNUrlPayRequest.swift */,
4CB883A72975FC1800DC99E7 /* Zaps.swift */, 4CB883A72975FC1800DC99E7 /* Zaps.swift */,
4CB883B5297730E400DC99E7 /* LNUrls.swift */, 4CB883B5297730E400DC99E7 /* LNUrls.swift */,
3AB72AB8298ECF30004BB58C /* Translator.swift */,
); );
path = Util; path = Util;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -1106,6 +1115,7 @@
4C7FF7D52823313F009601DB /* Mentions.swift in Sources */, 4C7FF7D52823313F009601DB /* Mentions.swift in Sources */,
4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */, 4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */,
4C987B57283FD07F0042CE38 /* FollowersModel.swift in Sources */, 4C987B57283FD07F0042CE38 /* FollowersModel.swift in Sources */,
3AB72AB9298ECF30004BB58C /* Translator.swift in Sources */,
4C363A9028247A1D006E126D /* NostrLink.swift in Sources */, 4C363A9028247A1D006E126D /* NostrLink.swift in Sources */,
4C0A3F8C280F5FCA000448DE /* ChatroomView.swift in Sources */, 4C0A3F8C280F5FCA000448DE /* ChatroomView.swift in Sources */,
4C477C9E282C3A4800033AA3 /* TipCounter.swift in Sources */, 4C477C9E282C3A4800033AA3 /* TipCounter.swift in Sources */,
@@ -1126,6 +1136,7 @@
4C75EFB328049D640006080F /* NostrEvent.swift in Sources */, 4C75EFB328049D640006080F /* NostrEvent.swift in Sources */,
4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */, 4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */,
4C363A8428233689006E126D /* Parser.swift in Sources */, 4C363A8428233689006E126D /* Parser.swift in Sources */,
3AAA95CA298DF87B00F3D526 /* TranslationService.swift in Sources */,
4CE4F9E328528C5200C00DD9 /* AddRelayView.swift in Sources */, 4CE4F9E328528C5200C00DD9 /* AddRelayView.swift in Sources */,
4C363A9A28283854006E126D /* Reply.swift in Sources */, 4C363A9A28283854006E126D /* Reply.swift in Sources */,
BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */, BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */,
@@ -1234,6 +1245,7 @@
4CF0ABD42980996B00D66079 /* Report.swift in Sources */, 4CF0ABD42980996B00D66079 /* Report.swift in Sources */,
4C0A3F97280F8E02000448DE /* ThreadView.swift in Sources */, 4C0A3F97280F8E02000448DE /* ThreadView.swift in Sources */,
4C06670B28FDE64700038D2A /* damus.c in Sources */, 4C06670B28FDE64700038D2A /* damus.c in Sources */,
3AAA95CC298E07E900F3D526 /* DeepLPlan.swift in Sources */,
4FE60CDD295E1C5E00105A1F /* Wallet.swift in Sources */, 4FE60CDD295E1C5E00105A1F /* Wallet.swift in Sources */,
3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */, 3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */,
3AE45AF6297BB2E700C1D842 /* LibreTranslateServer.swift in Sources */, 3AE45AF6297BB2E700C1D842 /* LibreTranslateServer.swift in Sources */,

View File

@@ -71,10 +71,7 @@ struct TranslateView: View {
} }
} }
.task { .task {
let translate_url = damus_state.settings.libretranslate_url guard noteLanguage == nil && !checkingTranslationStatus && damus_state.settings.can_translate(damus_state.pubkey) else {
let api_key = damus_state.settings.libretranslate_api_key
guard noteLanguage == nil && !checkingTranslationStatus && translate_url != "" else {
return return
} }
@@ -91,7 +88,7 @@ struct TranslateView: View {
noteLanguage = NLLanguageRecognizer.dominantLanguage(for: content)?.rawValue ?? currentLanguage noteLanguage = NLLanguageRecognizer.dominantLanguage(for: content)?.rawValue ?? currentLanguage
if let lang = noteLanguage, noteLanguage != 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, *) { if #available(iOS 16, *) {
noteLanguage = Locale.LanguageCode(stringLiteral: lang).identifier(.alpha2) noteLanguage = Locale.LanguageCode(stringLiteral: lang).identifier(.alpha2)
} else { } else {
@@ -109,7 +106,7 @@ struct TranslateView: View {
if note_lang != currentLanguage { if note_lang != currentLanguage {
do { do {
// If the note language is different from our language, send a translation request. // 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) translated_note = try await translator.translate(content, from: note_lang, to: currentLanguage)
} catch { } 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. // 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.

View File

@@ -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 }
}
}

View File

@@ -17,7 +17,6 @@ enum LibreTranslateServer: String, CaseIterable, Identifiable {
var url: String? var url: String?
} }
case none
case argosopentech case argosopentech
case terraprint case terraprint
case vern case vern
@@ -25,8 +24,6 @@ enum LibreTranslateServer: String, CaseIterable, Identifiable {
var model: Model { var model: Model {
switch self { switch self {
case .none:
return .init(tag: self.rawValue, displayName: NSLocalizedString("None", comment: "Dropdown option for selecting no translation server."), url: nil)
case .argosopentech: case .argosopentech:
return .init(tag: self.rawValue, displayName: "translate.argosopentech.com", url: "https://translate.argosopentech.com") return .init(tag: self.rawValue, displayName: "translate.argosopentech.com", url: "https://translate.argosopentech.com")
case .terraprint: case .terraprint:

View File

@@ -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 }
}
}

View File

@@ -22,7 +22,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 { guard let server_name = UserDefaults.standard.string(forKey: "libretranslate_server") else {
return nil return nil
} }
@@ -30,7 +46,7 @@ func get_libretranslate_server(_ pubkey: String) -> LibreTranslateServer? {
return LibreTranslateServer(rawValue: server_name) 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 { if let url = server.model.url {
return url return url
} }
@@ -57,6 +73,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 { @Published var libretranslate_server: LibreTranslateServer {
didSet { didSet {
if oldValue == libretranslate_server { if oldValue == libretranslate_server {
@@ -67,7 +109,7 @@ class UserSettingsStore: ObservableObject {
libretranslate_api_key = "" libretranslate_api_key = ""
if libretranslate_server == .custom || libretranslate_server == .none { if libretranslate_server == .custom {
libretranslate_url = "" libretranslate_url = ""
} else { } else {
libretranslate_url = libretranslate_server.model.url! libretranslate_url = libretranslate_server.model.url!
@@ -103,17 +145,24 @@ class UserSettingsStore: ObservableObject {
left_handed = UserDefaults.standard.object(forKey: "left_handed") as? Bool ?? false 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) ?? ""
} else {
// Note from @tyiu: // Note from @tyiu:
// Default server is disabled by default for now until we gain some confidence that it is working well in production. // 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. // 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. // 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. // 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). // Opportunity: spin up dedicated trusted LibreTranslate server that requires an API key for any access (or higher rate limit access).
libretranslate_server = .none 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 {
// Choose a random server to distribute load.
libretranslate_server = .allCases.filter { $0 != .custom }.randomElement()!
libretranslate_url = "" libretranslate_url = ""
} }
@@ -122,15 +171,46 @@ class UserSettingsStore: ObservableObject {
} catch { } catch {
libretranslate_api_key = "" libretranslate_api_key = ""
} }
if let deepl_plan = get_deepl_plan(pubkey) {
self.deepl_plan = deepl_plan
} else {
self.deepl_plan = .free
} }
func saveLibreTranslateApiKey(_ apiKey: String) throws { do {
deepl_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusDeepLKeychainConfiguration())
} catch {
deepl_api_key = ""
}
}
private func saveLibreTranslateApiKey(_ apiKey: String) throws {
try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusLibreTranslateKeychainConfiguration()) try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusLibreTranslateKeychainConfiguration())
} }
func clearLibreTranslateApiKey() throws { private func clearLibreTranslateApiKey() throws {
try Vault.deletePrivateKey(keychainConfiguration: DamusLibreTranslateKeychainConfiguration()) 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 { struct DamusLibreTranslateKeychainConfiguration: KeychainConfiguration {
@@ -138,3 +218,9 @@ struct DamusLibreTranslateKeychainConfiguration: KeychainConfiguration {
var accessGroup: String? = nil var accessGroup: String? = nil
var accountName = "libretranslate_apikey" var accountName = "libretranslate_apikey"
} }
struct DamusDeepLKeychainConfiguration: KeychainConfiguration {
var serviceName = "damus"
var accessGroup: String? = nil
var accountName = "deepl_apikey"
}

127
damus/Util/Translator.swift Normal file
View File

@@ -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() }
)
}
}

View File

@@ -14,7 +14,7 @@ struct ConfigView: View {
@State var confirm_logout: Bool = false @State var confirm_logout: Bool = false
@State var confirm_delete_account: Bool = false @State var confirm_delete_account: Bool = false
@State var show_privkey: Bool = false @State var show_privkey: Bool = false
@State var show_libretranslate_api_key: Bool = false @State var show_api_key: Bool = false
@State var privkey: String @State var privkey: String
@State var privkey_copied: Bool = false @State var privkey_copied: Bool = false
@State var pubkey_copied: Bool = false @State var pubkey_copied: Bool = false
@@ -84,7 +84,15 @@ struct ConfigView: View {
} }
} }
Section(NSLocalizedString("LibreTranslate Translations", comment: "Section title for selecting the server that hosts the LibreTranslate machine translation API.")) { 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.translation_service == .libretranslate {
Picker(NSLocalizedString("Server", comment: "Prompt selection of LibreTranslate server to perform machine translations on notes"), selection: $settings.libretranslate_server) { 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 ForEach(LibreTranslateServer.allCases, id: \.self) { server in
Text(server.model.displayName) Text(server.model.displayName)
@@ -92,27 +100,33 @@ struct ConfigView: View {
} }
} }
if settings.libretranslate_server != .none { if settings.libretranslate_server == .custom {
TextField(NSLocalizedString("URL", comment: "Example URL to LibreTranslate server"), text: $settings.libretranslate_url) TextField(NSLocalizedString("URL", comment: "Example URL to LibreTranslate server"), text: $settings.libretranslate_url)
.disableAutocorrection(true) .disableAutocorrection(true)
.disabled(settings.libretranslate_server != .custom)
.autocapitalization(UITextAutocapitalizationType.none) .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) SecureField(NSLocalizedString("API Key (optional)", comment: "Prompt for optional entry of API Key to use translation server."), text: $settings.libretranslate_api_key)
.disableAutocorrection(true) .disableAutocorrection(true)
.disabled(settings.translation_service != .libretranslate)
.autocapitalization(UITextAutocapitalizationType.none) .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) 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) .disableAutocorrection(true)
.disabled(settings.translation_service != .deepl)
.autocapitalization(UITextAutocapitalizationType.none) .autocapitalization(UITextAutocapitalizationType.none)
Button(NSLocalizedString("Show API Key", comment: "Button to hide the LibreTranslate server API key.")) {
show_libretranslate_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")!)
}
} }
} }
} }
@@ -174,6 +188,81 @@ struct ConfigView: View {
dismiss() 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 { struct ConfigView_Previews: PreviewProvider {

View File

@@ -9,10 +9,6 @@ import SwiftUI
import LinkPresentation import LinkPresentation
import NaturalLanguage import NaturalLanguage
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
struct NoteContentView: View { struct NoteContentView: View {
let damus_state: DamusState let damus_state: DamusState
let event: NostrEvent 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 { struct NoteContentView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let state = test_damus_state() let state = test_damus_state()