Automatically detect language of posts, improve language detection when posting (#800)

* Use language detection to translate posts

The source language of a post is now determined via Apples internal language
detection, translation from the transmitted language is still possible.

* Make language detection posting more accessible

Language recognition is now always applied before posting, even if the user has
explicitly selected a different language. However, the user is always asked in
which of the two languages he wants to post.

* Add localizations

* Remove language detection in the timeline for now

The language detection in the timeline is for now removed to increase
timeline-performance.

Signed-off-by: Paul Schuetz <pa.schuetz@web.de>

* Show translate button even if no language is sent

The translate-button is shown even if no language is sent with the post.

Signed-off-by: Paul Schuetz <pa.schuetz@web.de>

* Adjust to new commits on main

Adjustments are made in regards to new developments on main.

Signed-off-by: Paul Schuetz <pa.schuetz@web.de>

---------

Signed-off-by: Paul Schuetz <pa.schuetz@web.de>
Co-authored-by: Thomas Ricouard <ricouard77@gmail.com>
This commit is contained in:
Paul Schuetz 2023-02-12 18:23:29 +01:00 committed by GitHub
parent aab397f2bb
commit cd3c50e151
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 159 additions and 37 deletions

View File

@ -374,6 +374,8 @@
"status.editor.error.upload" = "Error en la pujada";
"status.editor.language-select.navigation-title" = "Selecciona la llengua";
"status.editor.language-select.recently-used" = "Recents";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "Edita la imatge";
"status.editor.media.image-description" = "Descripció de la imatge";
"status.editor.mode.edit" = "Edita la publicació";

View File

@ -375,6 +375,8 @@
"status.editor.error.upload" = "Fehler beim Hochladen";
"status.editor.language-select.navigation-title" = "Sprache auswählen";
"status.editor.language-select.recently-used" = "Kürzlich genutzt";
"status.editor.language-select.confirmation.detected-%@" = "Auf %@ posten (Erkannte Sprache)";
"status.editor.language-select.confirmation.selected-%@" = "Auf %@ posten (Ausgewählte Sprache)";
"status.editor.media.edit-image" = "Bild bearbeiten";
"status.editor.media.image-description" = "Bildbeschreibung";
"status.editor.mode.edit" = "Deinen Beitrag bearbeiten";

View File

@ -375,6 +375,8 @@
"status.editor.error.upload" = "Error uploading";
"status.editor.language-select.navigation-title" = "Select Language";
"status.editor.language-select.recently-used" = "Recently Used";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "Edit Image";
"status.editor.media.image-description" = "Image description";
"status.editor.mode.edit" = "Editing your post";

View File

@ -376,6 +376,8 @@
"status.editor.error.upload" = "Error uploading";
"status.editor.language-select.navigation-title" = "Select Language";
"status.editor.language-select.recently-used" = "Recently Used";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "Edit Image";
"status.editor.media.image-description" = "Image description";
"status.editor.mode.edit" = "Editing your post";

View File

@ -376,6 +376,8 @@
"status.editor.error.upload" = "Error subiendo";
"status.editor.language-select.navigation-title" = "Seleccionar idioma";
"status.editor.language-select.recently-used" = "Usado recientemente";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "Editar Imagen";
"status.editor.media.image-description" = "Descripción de la imagen";
"status.editor.mode.edit" = "Editando tu publicación";

View File

@ -375,6 +375,8 @@
"status.editor.error.upload" = "Errorea igotzerakoan";
"status.editor.language-select.navigation-title" = "Hautatu hizkuntza";
"status.editor.language-select.recently-used" = "Duela gutxi erabilitakoak";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "Editatu irudiak";
"status.editor.media.image-description" = "Irudiaren deskribapena";
"status.editor.mode.edit" = "Bidalketa editatzen";

View File

@ -371,6 +371,8 @@
"status.editor.error.upload" = "Erreur de téléchargement";
"status.editor.language-select.navigation-title" = "Sélectionner la langue";
"status.editor.language-select.recently-used" = "Utilisé récemment";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "Modifier l'image";
"status.editor.media.image-description" = "Description de l'image";
"status.editor.mode.edit" = "Modification de votre publication";

View File

@ -376,6 +376,8 @@
"status.editor.error.upload" = "Errore durante il caricamento";
"status.editor.language-select.navigation-title" = "Scegli la lingua";
"status.editor.language-select.recently-used" = "Usato recentemente";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "Modifica l'immagine";
"status.editor.media.image-description" = "Descrizione dell'immagine";
"status.editor.mode.edit" = "Modifica post";

View File

@ -375,6 +375,8 @@
"status.editor.error.upload" = "アップロードエラー";
"status.editor.language-select.navigation-title" = "言語設定";
"status.editor.language-select.recently-used" = "最近使用した";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "イメージの編集";
"status.editor.media.image-description" = "イメージの説明文";
"status.editor.mode.edit" = "投稿を編集する";

View File

@ -377,6 +377,8 @@
"status.editor.error.upload" = "전송 오류";
"status.editor.language-select.navigation-title" = "언어 선택";
"status.editor.language-select.recently-used" = "최근 사용";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "이미지 편집";
"status.editor.media.image-description" = "이미지 설명";
"status.editor.mode.edit" = "글 수정";

View File

@ -375,6 +375,8 @@
"status.editor.error.upload" = "Feil ved opplasting";
"status.editor.language-select.navigation-title" = "Velg språk";
"status.editor.language-select.recently-used" = "Nylig brukt";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "Rediger bilde";
"status.editor.media.image-description" = "Bildebeskrivelse";
"status.editor.mode.edit" = "Redigerer innlegget ditt";

View File

@ -369,6 +369,8 @@
"status.editor.error.upload" = "Fout tijdens uploaden";
"status.editor.language-select.navigation-title" = "Taal selecteren";
"status.editor.language-select.recently-used" = "Onlangs gebruikt";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "Afbeelding bewerken";
"status.editor.media.image-description" = "Omschrijving";
"status.editor.mode.edit" = "Post bewerken";

View File

@ -371,6 +371,8 @@
"status.editor.error.upload" = "Błąd wysyłania";
"status.editor.language-select.navigation-title" = "Wybierz język";
"status.editor.language-select.recently-used" = "Ostatnio użyty";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "Edytuj obrazek";
"status.editor.media.image-description" = "Opis obrazka";
"status.editor.mode.edit" = "Edycja twojego postu";

View File

@ -375,6 +375,8 @@
"status.editor.error.upload" = "Erro ao fazer upload";
"status.editor.language-select.navigation-title" = "Selecionar Idioma";
"status.editor.language-select.recently-used" = "Usado recentemente";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "Editar Imagem";
"status.editor.media.image-description" = "Descrição da imagem";
"status.editor.mode.edit" = "Editando sua postagem";

View File

@ -371,6 +371,8 @@
"status.editor.error.upload" = "Yüklerken Hata Oluştu";
"status.editor.language-select.navigation-title" = "Dil Seç";
"status.editor.language-select.recently-used" = "Son Kullanılanlar";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "Görüntüyü Düzenle";
"status.editor.media.image-description" = "Görüntü Açıklaması";
"status.editor.mode.edit" = "Gönderin Düzenleniyor";

View File

@ -376,6 +376,8 @@
"status.editor.error.upload" = "上传错误";
"status.editor.language-select.navigation-title" = "选择语言";
"status.editor.language-select.recently-used" = "最近使用";
"status.editor.language-select.confirmation.detected-%@" = "Post in %@ (Detected language)";
"status.editor.language-select.confirmation.selected-%@" = "Post in %@ (Selected language)";
"status.editor.media.edit-image" = "编辑图片";
"status.editor.media.image-description" = "图片描述";
"status.editor.mode.edit" = "正在编辑你的嘟文";

View File

@ -21,6 +21,7 @@ public struct StatusEditorView: View {
@FocusState private var isSpoilerTextFocused: Bool
@State private var isDismissAlertPresented: Bool = false
@State private var isLanguageConfirmPresented = false
public init(mode: StatusEditorViewModel.Mode) {
_viewModel = StateObject(wrappedValue: .init(mode: mode))
@ -104,12 +105,12 @@ public struct StatusEditorView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
Task {
let status = await viewModel.postStatus()
if status != nil {
dismiss()
NotificationCenter.default.post(name: NotificationsName.shareSheetClose,
object: nil)
}
viewModel.evaluateLanguages()
if let _ = viewModel.languageConfirmationDialogLanguages {
isLanguageConfirmPresented = true
} else {
await postStatus()
}
}
} label: {
if viewModel.isPosting {
@ -120,6 +121,9 @@ public struct StatusEditorView: View {
}
.disabled(!viewModel.canPost)
.keyboardShortcut(.return, modifiers: .command)
.confirmationDialog("", isPresented: $isLanguageConfirmPresented, actions: {
languageConfirmationDialog
})
}
ToolbarItem(placement: .navigationBarLeading) {
Button {
@ -156,6 +160,42 @@ public struct StatusEditorView: View {
.interactiveDismissDisabled(!viewModel.statusText.string.isEmpty)
}
@ViewBuilder
private var languageConfirmationDialog: some View {
if let dialogVals = viewModel.languageConfirmationDialogLanguages,
let detected = dialogVals["detected"],
let detectedLong = Locale.current.localizedString(forLanguageCode: detected),
let selected = dialogVals["selected"],
let selectedLong = Locale.current.localizedString(forLanguageCode: selected){
Button("status.editor.language-select.confirmation.detected-\(detectedLong)") {
viewModel.selectedLanguage = detected
Task {
await postStatus()
}
}
Button("status.editor.language-select.confirmation.selected-\(selectedLong)") {
viewModel.selectedLanguage = selected
Task {
await postStatus()
}
}
Button("action.cancel", role: .cancel) {
viewModel.languageConfirmationDialogLanguages = nil
}
} else {
EmptyView()
}
}
private func postStatus() async {
let status = await viewModel.postStatus()
if status != nil {
dismiss()
NotificationCenter.default.post(name: NotificationsName.shareSheetClose,
object: nil)
}
}
@ViewBuilder
private var spoilerTextView: some View {
if viewModel.spoilerOn {

View File

@ -14,6 +14,7 @@ public class StatusEditorViewModel: NSObject, ObservableObject {
var currentAccount: Account?
var theme: Theme?
var preferences: UserPreferences?
var languageConfirmationDialogLanguages: [String: String]?
var textView: UITextView? {
didSet {
@ -141,6 +142,17 @@ public class StatusEditorViewModel: NSObject, ObservableObject {
selectedLanguage = selectedLanguage ?? preference ?? currentAccount?.source?.language
}
func evaluateLanguages(){
if let detectedLang = detectLanguage(text: statusText.string),
let selectedLanguage = selectedLanguage,
selectedLanguage != detectedLang {
languageConfirmationDialogLanguages = ["detected": detectedLang,
"selected": selectedLanguage]
} else {
languageConfirmationDialogLanguages = nil;
}
}
func postStatus() async -> Status? {
guard let client else { return nil }
do {
@ -153,20 +165,6 @@ public class StatusEditorViewModel: NSObject, ObservableObject {
expires_in: pollDuration.rawValue)
}
if !hasExplicitlySelectedLanguage {
// Attempt language resolution using Natural Language
let recognizer = NLLanguageRecognizer()
recognizer.processString(statusText.string)
// Use languageHypotheses to get the probability with it
let hypotheses = recognizer.languageHypotheses(withMaximum: 1)
// Assert that 85% probability is enough :)
// A one word toot that is en/fr compatible is only ~50% confident, for instance
if let (language, probability) = hypotheses.first, probability > 0.85 {
// rawValue return the IETF BCP 47 language tag
selectedLanguage = language.rawValue
}
}
let data = StatusData(status: statusText.string,
visibility: visibility,
inReplyToId: mode.replyToStatus?.id,

View File

@ -0,0 +1,34 @@
import Foundation
import NaturalLanguage
private func stripToPureLanguage(inText: String) -> String {
let hashtagRegex = try! Regex("#[\\w]*")
let emojiRegex = try! Regex(":\\w*:")
let atRegex = try! Regex("@\\w*")
var resultStr = inText
[hashtagRegex, emojiRegex, atRegex].forEach { regex in
let splitArray = resultStr.split(separator: regex, omittingEmptySubsequences: true)
resultStr = splitArray.joined() as String
}
return resultStr.trimmingCharacters(in: .whitespacesAndNewlines)
}
func detectLanguage(text: String) -> String? {
let recognizer = NLLanguageRecognizer()
let strippedText = stripToPureLanguage(inText: text)
recognizer.processString(strippedText)
let hypotheses = recognizer.languageHypotheses(withMaximum: 1)
// Use the detected language only with >= 85 % confidence
if let (lang, confidence) = hypotheses.first, confidence >= 0.85 {
return lang.rawValue
} else {
return nil
}
}

View File

@ -82,9 +82,8 @@ struct StatusRowContextMenu: View {
await viewModel.translate(userLang: lang)
}
} label: {
if let statusLang = viewModel.status.language,
let languageName = Locale.current.localizedString(forLanguageCode: statusLang)
{
if let statusLang = viewModel.getStatusLang(),
let languageName = Locale.current.localizedString(forLanguageCode: statusLang) {
Label("status.action.translate-from-\(languageName)", systemImage: "captions.bubble")
} else {
Label("status.action.translate", systemImage: "captions.bubble")

View File

@ -345,14 +345,24 @@ public struct StatusRowView: View {
.accessibilityHidden(true)
}
private func shouldShowTranslateButton(status: AnyStatus) -> Bool {
let statusLang = viewModel.getStatusLang()
if let userLang = preferences.serverPreferences?.postLanguage,
preferences.showTranslateButton,
!status.content.asRawText.isEmpty,
viewModel.translation == nil
{
return userLang != statusLang
} else {
return false
}
}
@ViewBuilder
private func makeTranslateView(status: AnyStatus) -> some View {
if let userLang = preferences.serverPreferences?.postLanguage,
preferences.showTranslateButton,
status.language != nil,
userLang != status.language,
!status.content.asRawText.isEmpty,
viewModel.translation == nil
if let userLang = preferences.serverPreferences?.postLanguage,
shouldShowTranslateButton(status: status)
{
Button {
Task {
@ -362,13 +372,13 @@ public struct StatusRowView: View {
if viewModel.isLoadingTranslation {
ProgressView()
} else {
if let statusLanguage = status.language,
let languageName = Locale.current.localizedString(forLanguageCode: statusLanguage)
{
Text("status.action.translate-from-\(languageName)")
} else {
Text("status.action.translate")
}
if let statusLanguage = viewModel.getStatusLang(),
let languageName = Locale.current.localizedString(forLanguageCode: statusLanguage)
{
Text("status.action.translate-from-\(languageName)")
} else {
Text("status.action.translate")
}
}
}
.buttonStyle(.borderless)

View File

@ -2,6 +2,7 @@ import Env
import Models
import Network
import SwiftUI
import NaturalLanguage
import DesignSystem
@ -289,8 +290,16 @@ public class StatusRowViewModel: ObservableObject {
reblogsCount = status.reblog?.reblogsCount ?? status.reblogsCount
repliesCount = status.reblog?.repliesCount ?? status.repliesCount
}
func getStatusLang() -> String? {
status.language
}
func translate(userLang: String) async {
await translate(userLang: userLang, sourceLang: getStatusLang())
}
private func translate(userLang: String, sourceLang: String?) async {
guard let client else { return }
do {
withAnimation {