Compare commits

...

2 Commits

Author SHA1 Message Date
74e099de8e WIP non-working commit 2023-04-06 10:41:47 -04:00
85d5c4eda5 Refactor auto-translations and add caching 2023-04-05 23:35:55 -04:00
12 changed files with 287 additions and 112 deletions

View File

@@ -17,6 +17,7 @@
3A3040F329A91366008A0F29 /* ProfileViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A3040F229A91366008A0F29 /* ProfileViewTests.swift */; };
3A30410129AB12AA008A0F29 /* EventGroupViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A30410029AB12AA008A0F29 /* EventGroupViewTests.swift */; };
3A4325A82961E11400BFCD9D /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 3A4325AA2961E11400BFCD9D /* Localizable.stringsdict */; };
3A48E23B29D518F000BA313D /* Translations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A48E23A29D518F000BA313D /* Translations.swift */; };
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 */; };
@@ -323,6 +324,7 @@
3A41E559299D52BE001FA465 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/InfoPlist.strings; sourceTree = "<group>"; };
3A41E55A299D52BE001FA465 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
3A41E55B299D52BE001FA465 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = id; path = id.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
3A48E23A29D518F000BA313D /* Translations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Translations.swift; sourceTree = "<group>"; };
3A4F3320297CCFEE004B5F72 /* fr-FR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr-FR"; path = "fr-FR.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
3A4F3321297CCFEE004B5F72 /* fr-FR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr-FR"; path = "fr-FR.lproj/Localizable.strings"; sourceTree = "<group>"; };
3A4F3322297CCFEE004B5F72 /* fr-FR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "fr-FR"; path = "fr-FR.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
@@ -821,6 +823,7 @@
3AA59D1C2999B0400061C48E /* DraftsModel.swift */,
4C54AA0629A540BA003E4487 /* NotificationsModel.swift */,
4CD348EE29C3659D00497EB2 /* ImageUploadModel.swift */,
3A48E23A29D518F000BA313D /* Translations.swift */,
);
path = Models;
sourceTree = "<group>";
@@ -1663,6 +1666,7 @@
4CACA9D5280C31E100D9BBE8 /* ReplyView.swift in Sources */,
F7908E92298B0F0700AB113A /* RelayDetailView.swift in Sources */,
4C3A1D332960DB0500558C0F /* Markdown.swift in Sources */,
3A48E23B29D518F000BA313D /* Translations.swift in Sources */,
4CE879552996BAB900F758CC /* RelayPaidDetail.swift in Sources */,
4CF0ABD42980996B00D66079 /* Report.swift in Sources */,
4C06670B28FDE64700038D2A /* damus.c in Sources */,

View File

@@ -14,20 +14,71 @@ struct TranslateView: View {
let size: EventViewKind
@State var checkingTranslationStatus: Bool = false
@State var currentLanguage: String = "en"
@State var noteLanguage: String? = nil
@State var translated_note: String? = nil
@State var show_translated_note: Bool = false
@State var translated_artifacts: NoteArtifacts? = nil
@State var translatable: Bool = true
@State var noteLanguage: String?
@State var show_translated_note: Bool
@State var translated_artifacts: NoteArtifacts?
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind) {
self.damus_state = damus_state
self.event = event
self.size = size
self._noteLanguage = State(initialValue: damus_state.translations.detectLanguage(event, state: damus_state))
if let translationWithLanguage = damus_state.translations.cachedTranslation(event) {
self._noteLanguage = State(initialValue: translationWithLanguage.language)
let translatedBlocks = event.get_blocks(content: translationWithLanguage.translation)
self._translated_artifacts = State.init(initialValue: render_blocks(blocks: translatedBlocks, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey))
} else {
self._translated_artifacts = State(initialValue: nil)
}
self._show_translated_note = State(initialValue: damus_state.settings.auto_translate)
}
var TranslateButton: some View {
Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) {
show_translated_note = true
processTranslation()
}
.translate_button_style()
}
func processTranslation() {
guard noteLanguage != nil && !checkingTranslationStatus && translatable else {
return
}
checkingTranslationStatus = true
show_translated_note = true
Task {
let translationWithLanguage = await damus_state.translations.translate(event, state: damus_state)
DispatchQueue.main.async {
guard translationWithLanguage != nil else {
noteLanguage = damus_state.translations.targetLanguage
checkingTranslationStatus = false
show_translated_note = false
translatable = false
return
}
noteLanguage = translationWithLanguage!.language
// Render translated note.
let translatedBlocks = event.get_blocks(content: translationWithLanguage!.translation)
translated_artifacts = render_blocks(blocks: translatedBlocks, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey)
translatable = true
checkingTranslationStatus = false
}
}
}
func Translated(lang: String, artifacts: NoteArtifacts) -> some View {
return Group {
@@ -42,11 +93,15 @@ struct TranslateView: View {
func MainContent(note_lang: String) -> some View {
return Group {
let languageName = Locale.current.localizedString(forLanguageCode: note_lang)
if let languageName, let translated_artifacts, show_translated_note {
Translated(lang: languageName, artifacts: translated_artifacts)
} else if !damus_state.settings.auto_translate {
TranslateButton
if translatable {
let languageName = Locale.current.localizedString(forLanguageCode: note_lang)
if let languageName, let translated_artifacts, show_translated_note {
Translated(lang: languageName, artifacts: translated_artifacts)
} else if !damus_state.settings.auto_translate {
TranslateButton
} else {
EmptyView()
}
} else {
EmptyView()
}
@@ -55,63 +110,17 @@ struct TranslateView: View {
var body: some View {
Group {
if let note_lang = noteLanguage, noteLanguage != currentLanguage {
if let note_lang = noteLanguage, note_lang != damus_state.translations.targetLanguage {
MainContent(note_lang: note_lang)
.task {
if show_translated_note {
processTranslation()
}
}
} else {
Text("")
}
}
.task {
guard noteLanguage == nil && !checkingTranslationStatus && damus_state.settings.can_translate(damus_state.pubkey) else {
return
}
checkingTranslationStatus = true
if #available(iOS 16, *) {
currentLanguage = Locale.current.language.languageCode?.identifier ?? "en"
} else {
currentLanguage = Locale.current.languageCode ?? "en"
}
noteLanguage = event.note_language(damus_state.keypair.privkey) ?? currentLanguage
guard let note_lang = noteLanguage else {
noteLanguage = currentLanguage
translated_note = nil
checkingTranslationStatus = false
return
}
if !preferredLanguages.contains(note_lang) {
do {
// If the note language is different from our preferred languages, send a translation request.
let translator = Translator(damus_state.settings)
let originalContent = event.get_content(damus_state.keypair.privkey)
translated_note = try await translator.translate(originalContent, from: note_lang, to: currentLanguage)
if originalContent == translated_note {
// If the translation is the same as the original, don't bother showing it.
noteLanguage = currentLanguage
translated_note = nil
}
} 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.
noteLanguage = currentLanguage
translated_note = nil
}
}
if let translated = translated_note {
// Render translated note.
let translatedBlocks = event.get_blocks(content: translated)
translated_artifacts = render_blocks(blocks: translatedBlocks, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey)
}
checkingTranslationStatus = false
show_translated_note = damus_state.settings.auto_translate
}
}
}

View File

@@ -609,6 +609,8 @@ struct ContentView: View {
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
let settings = UserSettingsStore()
self.damus_state = DamusState(pool: pool,
keypair: keypair,
likes: EventCounter(our_pubkey: pubkey),
@@ -620,7 +622,7 @@ struct ContentView: View {
previews: PreviewCache(),
zaps: Zaps(our_pubkey: pubkey),
lnurls: LNUrls(),
settings: UserSettingsStore(),
settings: settings,
relay_filters: relay_filters,
relay_metadata: metadatas,
drafts: Drafts(),
@@ -628,7 +630,8 @@ struct ContentView: View {
bookmarks: BookmarksManager(pubkey: pubkey),
postbox: PostBox(pool: pool),
bootstrap_relays: bootstrap_relays,
replies: ReplyCounter(our_pubkey: pubkey)
replies: ReplyCounter(our_pubkey: pubkey),
translations: Translations(settings)
)
home.damus_state = self.damus_state!

View File

@@ -29,7 +29,7 @@ struct DamusState {
let postbox: PostBox
let bootstrap_relays: [String]
let replies: ReplyCounter
let translations: Translations
var pubkey: String {
return keypair.pubkey
}
@@ -39,6 +39,7 @@ struct DamusState {
}
static var empty: DamusState {
return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), tips: TipCounter(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache(), zaps: Zaps(our_pubkey: ""), lnurls: LNUrls(), settings: UserSettingsStore(), relay_filters: RelayFilters(our_pubkey: ""), relay_metadata: RelayMetadatas(), drafts: Drafts(), events: EventCache(), bookmarks: BookmarksManager(pubkey: ""), postbox: PostBox(pool: RelayPool()), bootstrap_relays: [], replies: ReplyCounter(our_pubkey: ""))
let settings = UserSettingsStore()
return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), tips: TipCounter(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache(), zaps: Zaps(our_pubkey: ""), lnurls: LNUrls(), settings: UserSettingsStore(), relay_filters: RelayFilters(our_pubkey: ""), relay_metadata: RelayMetadatas(), drafts: Drafts(), events: EventCache(), bookmarks: BookmarksManager(pubkey: ""), postbox: PostBox(pool: RelayPool()), bootstrap_relays: [], replies: ReplyCounter(our_pubkey: ""), translations: Translations(settings))
}
}

View File

@@ -1,5 +1,5 @@
//
// DraftsModel.swift
// DraftModel.swift
// damus
//
// Created by Terry Yiu on 2/12/23.

View File

@@ -0,0 +1,150 @@
//
// Translations.swift
// damus
//
// Created by Terry Yiu on 3/29/23.
//
import Foundation
import NaturalLanguage
class Translations: ObservableObject {
private static let languageDetectionMinConfidence = 0.5
@Published var translations: [NostrEvent: String] = [:]
@Published var languages: [NostrEvent: String] = [:]
let settings: UserSettingsStore
let translator: Translator
let targetLanguage = currentLanguage()
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
init(_ settings: UserSettingsStore) {
self.settings = settings
self.translator = Translator(settings)
}
/**
Attempts to detect the language of the content of a given nostr event using Apple's offline NaturalLanguage API.
The detected language will be returned only if it has a 50% or more confidence.
This is a best effort guess and could be incorrect.
*/
func detectLanguage(_ event: NostrEvent, state: DamusState) -> String? {
if let cachedLanguage = languages[event] {
return cachedLanguage
}
// Rely on Apple's NLLanguageRecognizer to tell us which language it thinks the note is in
// and filter on only the text portions of the content as URLs and hashtags confuse the language recognizer.
let originalBlocks = event.blocks(state.keypair.privkey)
let originalOnlyText = originalBlocks.compactMap { $0.is_text }.joined(separator: " ")
// Only accept language recognition hypothesis if there's at least a 50% probability that it's accurate.
let languageRecognizer = NLLanguageRecognizer()
languageRecognizer.processString(originalOnlyText)
guard let locale = languageRecognizer.languageHypotheses(withMaximum: 1).first(where: { $0.value >= Translations.languageDetectionMinConfidence })?.key.rawValue else {
return nil
}
// Remove the variant component and just take the language part as translation services typically only supports the variant-less language.
// Moreover, speakers of one variant can generally understand other variants.
let language = localeToLanguage(locale)
languages[event] = language
return language
}
/**
Returns true if the given translation is effectively the same as the original note, ignoring whitespaces and new lines.
*/
private func translationSameAsOriginal(_ translation: String, event: NostrEvent, state: DamusState) -> Bool {
return translation.trimmingCharacters(in: .whitespacesAndNewlines) == event.get_content(state.keypair.privkey).trimmingCharacters(in: .whitespacesAndNewlines)
}
func hasCachedTranslation(_ event: NostrEvent) -> Bool {
return languages[event] != nil
}
func cachedTranslation(_ event: NostrEvent) -> TranslationWithLanguage? {
if let cachedLanguage = languages[event] {
if let cachedTranslation = translations[event] {
return TranslationWithLanguage(translation: cachedTranslation, language: cachedLanguage)
} else {
return nil
}
} else {
return nil
}
}
func translate(_ event: NostrEvent, state: DamusState) async -> TranslationWithLanguage? {
guard shouldTranslate(event, state: state) else {
return nil
}
guard let noteLanguage = detectLanguage(event, state: state) else {
return nil
}
if languages[event] != nil {
return cachedTranslation(event)
}
do {
guard let translationWithLanguage = try await translator.translate(event.get_content(state.keypair.privkey), from: noteLanguage, to: targetLanguage) else {
return nil
}
// If the translated content is identical to the original content, don't return the translation.
if translationSameAsOriginal(translationWithLanguage.translation, event: event, state: state) {
// Nil out the translation as it's the same as the original.
translations[event] = nil
// Leave an entry so that we don't attempt to translate it again in the future.
languages[event] = targetLanguage
return nil
} else {
translations[event] = translationWithLanguage.translation
languages[event] = translationWithLanguage.language
return translationWithLanguage
}
} catch {
return nil
}
}
func shouldTranslate(_ event: NostrEvent, state: DamusState) -> Bool {
// Do not translate self-authored content because if the language recognizer guesses the wrong language for your own note,
// it's annoying and unexpected for the translation to show up.
if event.pubkey == state.pubkey && state.is_privkey_user {
return false
}
// Avoid translating if no translation service is configured.
switch settings.translation_service {
case .none:
return false
case .libretranslate:
if URLComponents(string: settings.libretranslate_url) == nil {
return false
}
case .deepl:
if settings.deepl_api_key == "" {
return false
}
}
// If translation was attempted before, use the results of the cached translation to determine if it should be shown.
if languages[event] != nil {
return translations[event] != nil
}
// Avoid translating notes if language cannot be detected or if it is in one of the user's preferred languages.
guard let noteLanguage = detectLanguage(event, state: state), !preferredLanguages.contains(noteLanguage) else {
return false
}
return true
}
}

View File

@@ -336,17 +336,6 @@ class UserSettingsStore: ObservableObject {
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 {

View File

@@ -260,25 +260,6 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
return event_is_reply(self, privkey: privkey)
}
func note_language(_ privkey: String?) -> String? {
// Rely on Apple's NLLanguageRecognizer to tell us which language it thinks the note is in
// and filter on only the text portions of the content as URLs and hashtags confuse the language recognizer.
let originalBlocks = blocks(privkey)
let originalOnlyText = originalBlocks.compactMap { $0.is_text }.joined(separator: " ")
// Only accept language recognition hypothesis if there's at least a 50% probability that it's accurate.
let languageRecognizer = NLLanguageRecognizer()
languageRecognizer.processString(originalOnlyText)
guard let locale = languageRecognizer.languageHypotheses(withMaximum: 1).first(where: { $0.value >= 0.5 })?.key.rawValue else {
return nil
}
// Remove the variant component and just take the language part as translation services typically only supports the variant-less language.
// Moreover, speakers of one variant can generally understand other variants.
return localeToLanguage(locale)
}
public var referenced_ids: [ReferencedId] {
return get_referenced_ids(key: "e")
}

View File

@@ -22,6 +22,14 @@ func localizedStringFormat(key: String, locale: Locale?) -> String {
return bundle.localizedString(forKey: key, value: fallback, table: nil)
}
func currentLanguage() -> String {
if #available(iOS 16, *) {
return Locale.current.language.languageCode?.identifier ?? "en"
} else {
return Locale.current.languageCode ?? "en"
}
}
/**
Removes the variant part of a locale code so that it contains only the language code.
*/

View File

@@ -20,18 +20,28 @@ public struct Translator {
self.userSettingsStore = userSettingsStore
}
public func translate(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> String? {
/**
Translates a string from source language to target language.
If the translation provider supports its own language detection, it may determine the source language by itself that could be
different from what is passed in as the sourceLanguage argument.
The source language that is actually used in the translation will be returned as part of the TranslationWithLanguage object.
If the translation was unable to be fetched for whatever reason, nil is returned.
*/
public func translate(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> TranslationWithLanguage? {
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)
return try await translateWithDeepL(text, to: targetLanguage)
case .none:
return text
return nil
}
}
private func translateWithLibreTranslate(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> String? {
/**
Translates a string from sourceLanguage to targetLanguage using LibreTranslate. We do not rely on LibreTranslate's language detection API as it requires a separate API call. Instead, we rely on the passed in sourceLanguage argument.
*/
private func translateWithLibreTranslate(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> TranslationWithLanguage? {
let url = try makeURL(userSettingsStore.libretranslate_url, path: "/translate")
var request = URLRequest(url: url)
@@ -51,10 +61,15 @@ public struct Translator {
let translatedText: String
}
let response: Response = try await decodedData(for: request)
return response.translatedText
let translation = response.translatedText
return TranslationWithLanguage(translation: translation, language: targetLanguage)
}
private func translateWithDeepL(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> String? {
/**
Translates a string to targetLanguage using DeepL. We do not accept a sourceLanguage as an argument as DeepL performs language detection within the translate API, its models are generally fairly accurate, and does not require a separate API call like LibreTranslate.
*/
private func translateWithDeepL(_ text: String, to targetLanguage: String) async throws -> TranslationWithLanguage? {
if userSettingsStore.deepl_api_key == "" {
return nil
}
@@ -68,10 +83,9 @@ public struct Translator {
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())
let body = RequestBody(text: [text], target_lang: targetLanguage.uppercased())
request.httpBody = try encoder.encode(body)
struct Response: Decodable {
@@ -83,7 +97,13 @@ public struct Translator {
}
let response: Response = try await decodedData(for: request)
return response.translations.map { $0.text }.joined(separator: " ")
if response.translations.isEmpty {
return nil
}
let translation = response.translations.map { $0.text }.joined(separator: " ")
return TranslationWithLanguage(translation: translation, language: response.translations.first!.detected_source_language)
}
private func makeURL(_ baseUrl: String, path: String) throws -> URL {
@@ -104,6 +124,11 @@ public struct Translator {
}
}
public struct TranslationWithLanguage {
let translation: String
let language: String
}
private extension URLSession {
func data(for request: URLRequest) async throws -> Data {
var task: URLSessionDataTask?

View File

@@ -29,6 +29,7 @@ struct NoteContentView: View {
let size: EventViewKind
let preview_height: CGFloat?
let options: EventViewOptions
let translatable: Bool
@State var artifacts: NoteArtifacts
@State var preview: LinkViewRepresentable?
@@ -39,6 +40,7 @@ struct NoteContentView: View {
self.show_images = show_images
self.size = size
self.options = options
self.translatable = damus_state.translations.shouldTranslate(event, state: damus_state)
self._artifacts = State(initialValue: artifacts)
self.preview_height = lookup_cached_preview_size(previews: damus_state.previews, evid: event.id)
self._preview = State(initialValue: load_cached_preview(previews: damus_state.previews, evid: event.id))
@@ -100,7 +102,7 @@ struct NoteContentView: View {
}
}
if size == .selected || damus_state.settings.auto_translate {
if translatable {
if with_padding {
translateView
.padding(.horizontal)

View File

@@ -15,8 +15,6 @@ struct SearchHomeView: View {
@State var search: String = ""
@FocusState private var isFocused: Bool
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
var SearchInput: some View {
HStack {
HStack{
@@ -54,12 +52,17 @@ struct SearchHomeView: View {
return true
}
// If we can't determine the note's language with 50%+ confidence, lean on the side of caution and show it anyway.
guard let noteLanguage = $0.note_language(damus_state.keypair.privkey) else {
// Always show your own posts.
if $0.pubkey == damus_state.pubkey {
return true
}
return preferredLanguages.contains(noteLanguage)
// If we can't determine the note's language with 50%+ confidence, lean on the side of caution and show it anyway.
guard let noteLanguage = damus_state.translations.detectLanguage($0, state: damus_state) else {
return true
}
return damus_state.translations.preferredLanguages.contains(noteLanguage)
}
)
.refreshable {