Browse Source

Add LibreTranslate integration for machine translating notes from other languages

master
Terry Yiu 2 years ago
parent
commit
4fbc9882ce
No known key found for this signature in database GPG Key ID: 108645AE8A19B71A
  1. 4
      damus.xcodeproj/project.pbxproj
  2. 2
      damus/Components/InvoiceView.swift
  3. 1
      damus/ContentView.swift
  4. 44
      damus/Models/LibreTranslateServer.swift
  5. 74
      damus/Models/UserSettingsStore.swift
  6. 6
      damus/Nostr/NostrEvent.swift
  7. 34
      damus/Views/ConfigView.swift
  8. 176
      damus/Views/NoteContentView.swift
  9. 2
      damus/Views/ProfileView.swift
  10. 2
      damus/Views/SideMenuView.swift

4
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 = "<group>"; };
3ACB685E297633BC00C46468 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = "<group>"; };
3ACBCB77295FE5C70037388A /* TimeAgoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeAgoTests.swift; sourceTree = "<group>"; };
3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreTranslateServer.swift; sourceTree = "<group>"; };
3AEB8003297CCEA800713A25 /* tr-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "tr-TR"; path = "tr-TR.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
3AEB8004297CCEA800713A25 /* tr-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "tr-TR"; path = "tr-TR.lproj/Localizable.strings"; sourceTree = "<group>"; };
3AEB8005297CCEA900713A25 /* tr-TR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "tr-TR"; path = "tr-TR.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
@ -582,6 +584,7 @@
7C45AE70297353390031D7BC /* KFImageModel.swift */,
4CF0ABD32980996B00D66079 /* Report.swift */,
4CF0ABDD2981A69500D66079 /* MutelistModel.swift */,
3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */,
);
path = Models;
sourceTree = "<group>";
@ -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 */,

2
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 {

1
damus/ContentView.swift

@ -292,6 +292,7 @@ struct ContentView: View {
.padding([.bottom], 8)
}
}
.environmentObject(user_settings)
.onAppear() {
self.connect()
//KingfisherManager.shared.cache.clearDiskCache()

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

74
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"
}

6
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 {

34
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)

176
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<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()

2
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

2
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

Loading…
Cancel
Save