Offer language detection

This commit is contained in:
Christian Schmidt 2024-08-27 20:01:15 +02:00
parent 97f6baf977
commit 5d939d70ca
15 changed files with 99 additions and 37 deletions

View File

@ -23,6 +23,6 @@ class Api::V1::Statuses::TranslationsController < Api::V1::Statuses::BaseControl
private private
def set_translation def set_translation
@translation = TranslateStatusService.new.call(@status, content_locale) @translation = TranslateStatusService.new.call(@status, params[:source_language], content_locale)
end end
end end

View File

@ -101,6 +101,7 @@ export function normalizeStatusTranslation(translation, status) {
const emojiMap = makeEmojiMap(status.get('emojis').toJS()); const emojiMap = makeEmojiMap(status.get('emojis').toJS());
const normalTranslation = { const normalTranslation = {
source_language: translation.source_language,
detected_source_language: translation.detected_source_language, detected_source_language: translation.detected_source_language,
language: translation.language, language: translation.language,
provider: translation.provider, provider: translation.provider,

View File

@ -333,10 +333,10 @@ export function toggleStatusCollapse(id, isCollapsed) {
}; };
} }
export const translateStatus = id => (dispatch) => { export const translateStatus = (id, source_language) => (dispatch) => {
dispatch(translateStatusRequest(id)); dispatch(translateStatusRequest(id));
api().post(`/api/v1/statuses/${id}/translate`).then(response => { api().post(`/api/v1/statuses/${id}/translate`, { source_language }).then(response => {
dispatch(translateStatusSuccess(id, response.data)); dispatch(translateStatusSuccess(id, response.data));
}).catch(error => { }).catch(error => {
dispatch(translateStatusFail(id, error)); dispatch(translateStatusFail(id, error));

View File

@ -106,6 +106,7 @@ class Status extends ImmutablePureComponent {
onToggleHidden: PropTypes.func, onToggleHidden: PropTypes.func,
onToggleCollapsed: PropTypes.func, onToggleCollapsed: PropTypes.func,
onTranslate: PropTypes.func, onTranslate: PropTypes.func,
onUndoStatusTranslation: PropTypes.func,
onInteractionModal: PropTypes.func, onInteractionModal: PropTypes.func,
muted: PropTypes.bool, muted: PropTypes.bool,
hidden: PropTypes.bool, hidden: PropTypes.bool,
@ -200,8 +201,12 @@ class Status extends ImmutablePureComponent {
this.props.onToggleCollapsed(this._properStatus(), isCollapsed); this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
}; };
handleTranslate = () => { handleTranslate = (sourceLanguage) => {
this.props.onTranslate(this._properStatus()); this.props.onTranslate(this._properStatus(), sourceLanguage);
};
handleUndoStatusTranslation = () => {
this.props.onUndoStatusTranslation(this._properStatus());
}; };
getAttachmentAspectRatio () { getAttachmentAspectRatio () {
@ -579,6 +584,7 @@ class Status extends ImmutablePureComponent {
status={status} status={status}
onClick={this.handleClick} onClick={this.handleClick}
onTranslate={this.handleTranslate} onTranslate={this.handleTranslate}
onUndoStatusTranslation={this.handleUndoStatusTranslation}
collapsible collapsible
onCollapsedToggle={this.handleCollapsedToggle} onCollapsedToggle={this.handleCollapsedToggle}
{...statusContentProps} {...statusContentProps}

View File

@ -30,32 +30,47 @@ class TranslateButton extends PureComponent {
static propTypes = { static propTypes = {
translation: ImmutablePropTypes.map, translation: ImmutablePropTypes.map,
onClick: PropTypes.func, onTranslate: PropTypes.func,
onDetectLanguage: PropTypes.func,
onUndoStatusTranslation: PropTypes.func,
}; };
render () { render () {
const { translation, onClick } = this.props; const { translation, onTranslate, onDetectLanguage, onUndoStatusTranslation } = this.props;
if (translation) { if (translation) {
const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language')); const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language'));
const languageName = language ? language[2] : translation.get('detected_source_language'); const languageName = language ? language[2] : translation.get('detected_source_language');
const provider = translation.get('provider'); const provider = translation.get('provider');
const languageDetectionButton = onDetectLanguage && (
<>
<button className='link-button' onClick={onDetectLanguage}>
<FormattedMessage id='status.detect_language' defaultMessage='Detect language' />
</button>
·
</>
);
return ( return (
<div className='translate-button'> <div className='translate-button'>
<div className='translate-button__meta'> <div className='translate-button__meta'>
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} /> <FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
</div> </div>
<button className='link-button' onClick={onClick}> <div className='translate-button__buttons'>
<FormattedMessage id='status.show_original' defaultMessage='Show original' /> {languageDetectionButton}
</button>
<button className='link-button' onClick={onUndoStatusTranslation}>
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
</button>
</div>
</div> </div>
); );
} }
return ( return (
<button className='status__content__translate-button' onClick={onClick}> <button className='status__content__translate-button' onClick={onTranslate}>
<FormattedMessage id='status.translate' defaultMessage='Translate' /> <FormattedMessage id='status.translate' defaultMessage='Translate' />
</button> </button>
); );
@ -73,6 +88,7 @@ class StatusContent extends PureComponent {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
statusContent: PropTypes.string, statusContent: PropTypes.string,
onTranslate: PropTypes.func, onTranslate: PropTypes.func,
onUndoStatusTranslation: PropTypes.func,
onClick: PropTypes.func, onClick: PropTypes.func,
collapsible: PropTypes.bool, collapsible: PropTypes.bool,
onCollapsedToggle: PropTypes.func, onCollapsedToggle: PropTypes.func,
@ -215,6 +231,10 @@ class StatusContent extends PureComponent {
this.props.onTranslate(); this.props.onTranslate();
}; };
handleDetectLanguage = () => {
this.props.onTranslate('und');
};
setRef = (c) => { setRef = (c) => {
this.node = c; this.node = c;
}; };
@ -224,9 +244,13 @@ class StatusContent extends PureComponent {
const renderReadMore = this.props.onClick && status.get('collapsed'); const renderReadMore = this.props.onClick && status.get('collapsed');
const contentLocale = intl.locale.replace(/[_-].*/, ''); const contentLocale = intl.locale.replace(/[_-].*/, '');
const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); const sourceLanguage = status.get('language') || 'und';
const targetLanguages = this.props.languages?.get(sourceLanguage);
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale); const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
const languageDetectionTargetLanguages = this.props.languages?.get('und');
const renderDetectLanguage = sourceLanguage !== 'und' && languageDetectionTargetLanguages?.includes(contentLocale) && status.get('translation')?.get('source_language') !== 'und';
const content = { __html: statusContent ?? getStatusContent(status) }; const content = { __html: statusContent ?? getStatusContent(status) };
const language = status.getIn(['translation', 'language']) || status.get('language'); const language = status.getIn(['translation', 'language']) || status.get('language');
const classNames = classnames('status__content', { const classNames = classnames('status__content', {
@ -241,7 +265,12 @@ class StatusContent extends PureComponent {
); );
const translateButton = renderTranslate && ( const translateButton = renderTranslate && (
<TranslateButton onClick={this.handleTranslate} translation={status.get('translation')} /> <TranslateButton
onTranslate={this.handleTranslate}
onDetectLanguage={renderDetectLanguage && this.handleDetectLanguage}
onUndoStatusTranslation={this.props.onUndoStatusTranslation}
translation={status.get('translation')}
/>
); );
const poll = !!status.get('poll') && ( const poll = !!status.get('poll') && (

View File

@ -126,12 +126,12 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
}); });
}, },
onTranslate (status) { onTranslate (status, sourceLanguage) {
if (status.get('translation')) { dispatch(translateStatus(status.get('id'), sourceLanguage));
dispatch(undoStatusTranslation(status.get('id'), status.get('poll'))); },
} else {
dispatch(translateStatus(status.get('id'))); onUndoStatusTranslation (status) {
} dispatch(undoStatusTranslation(status.get('id'), status.get('poll')));
}, },
onDirect (account) { onDirect (account) {

View File

@ -36,6 +36,7 @@ class DetailedStatus extends ImmutablePureComponent {
onOpenVideo: PropTypes.func.isRequired, onOpenVideo: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired, onToggleHidden: PropTypes.func.isRequired,
onTranslate: PropTypes.func.isRequired, onTranslate: PropTypes.func.isRequired,
onUndoStatusTranslation: PropTypes.func.isRequired,
measureHeight: PropTypes.bool, measureHeight: PropTypes.bool,
onHeightChange: PropTypes.func, onHeightChange: PropTypes.func,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
@ -103,9 +104,14 @@ class DetailedStatus extends ImmutablePureComponent {
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
}; };
handleTranslate = () => { handleTranslate = (sourceLanguage) => {
const { onTranslate, status } = this.props; const { onTranslate, status } = this.props;
onTranslate(status); onTranslate(status, sourceLanguage);
};
handleUndoStatusTranslation = () => {
const { onUndoStatusTranslation, status } = this.props;
onUndoStatusTranslation(status);
}; };
_properStatus () { _properStatus () {
@ -285,6 +291,7 @@ class DetailedStatus extends ImmutablePureComponent {
<StatusContent <StatusContent
status={status} status={status}
onTranslate={this.handleTranslate} onTranslate={this.handleTranslate}
onUndoStatusTranslation={this.handleUndoStatusTranslation}
{...statusContentProps} {...statusContentProps}
/> />

View File

@ -392,14 +392,16 @@ class Status extends ImmutablePureComponent {
} }
}; };
handleTranslate = status => { handleTranslate = (status, sourceLanguage) => {
const { dispatch } = this.props; const { dispatch } = this.props;
if (status.get('translation')) { dispatch(translateStatus(status.get('id'), sourceLanguage));
dispatch(undoStatusTranslation(status.get('id'), status.get('poll'))); };
} else {
dispatch(translateStatus(status.get('id'))); handleUndoStatusTranslation = status => {
} const { dispatch } = this.props;
dispatch(undoStatusTranslation(status.get('id'), status.get('poll')));
}; };
handleBlockClick = (status) => { handleBlockClick = (status) => {
@ -675,6 +677,7 @@ class Status extends ImmutablePureComponent {
onOpenMedia={this.handleOpenMedia} onOpenMedia={this.handleOpenMedia}
onToggleHidden={this.handleToggleHidden} onToggleHidden={this.handleToggleHidden}
onTranslate={this.handleTranslate} onTranslate={this.handleTranslate}
onUndoStatusTranslation={this.handleUndoStatusTranslation}
domain={domain} domain={domain}
showMedia={this.state.showMedia} showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility} onToggleMediaVisibility={this.handleToggleMediaVisibility}

View File

@ -779,6 +779,7 @@
"status.copy": "Copy link to post", "status.copy": "Copy link to post",
"status.delete": "Delete", "status.delete": "Delete",
"status.detailed_status": "Detailed conversation view", "status.detailed_status": "Detailed conversation view",
"status.detect_language": "Detect language",
"status.direct": "Privately mention @{name}", "status.direct": "Privately mention @{name}",
"status.direct_indicator": "Private mention", "status.direct_indicator": "Private mention",
"status.edit": "Edit", "status.edit": "Edit",

View File

@ -1312,6 +1312,17 @@ body > [data-popper-placement] {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
color: $dark-text-color; color: $dark-text-color;
.translate-button__buttons {
margin-right: -6px;
float: right;
white-space: nowrap;
.link-button {
display: inline;
margin: 0 6px;
}
}
} }
.status__content__spoiler-link { .status__content__spoiler-link {

View File

@ -11,6 +11,7 @@ class TranslationService::DeepL < TranslationService
end end
def translate(texts, source_language, target_language) def translate(texts, source_language, target_language)
source_language = nil if source_language == 'und'
form = { text: texts, source_lang: source_language&.upcase, target_lang: target_language, tag_handling: 'html' } form = { text: texts, source_lang: source_language&.upcase, target_lang: target_language, tag_handling: 'html' }
request(:post, '/v2/translate', form: form) do |res| request(:post, '/v2/translate', form: form) do |res|
transform_response(res.body_with_limit) transform_response(res.body_with_limit)
@ -18,7 +19,7 @@ class TranslationService::DeepL < TranslationService
end end
def languages def languages
source_languages = [nil] + fetch_languages('source') source_languages = ['und'] + fetch_languages('source')
# In DeepL, EN and PT are deprecated in favor of EN-GB/EN-US and PT-BR/PT-PT, so # In DeepL, EN and PT are deprecated in favor of EN-GB/EN-US and PT-BR/PT-PT, so
# they are supported but not returned by the API. # they are supported but not returned by the API.

View File

@ -9,7 +9,8 @@ class TranslationService::LibreTranslate < TranslationService
end end
def translate(texts, source_language, target_language) def translate(texts, source_language, target_language)
body = Oj.dump(q: texts, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key) source_language = 'auto' if source_language.in? [nil, 'und']
body = Oj.dump(q: texts, source: source_language, target: target_language, format: 'html', api_key: @api_key)
request(:post, '/translate', body: body) do |res| request(:post, '/translate', body: body) do |res|
transform_response(res.body_with_limit, source_language) transform_response(res.body_with_limit, source_language)
end end
@ -20,7 +21,7 @@ class TranslationService::LibreTranslate < TranslationService
languages = Oj.load(res.body_with_limit).to_h do |language| languages = Oj.load(res.body_with_limit).to_h do |language|
[language['code'], language['targets'].without(language['code'])] [language['code'], language['targets'].without(language['code'])]
end end
languages[nil] = languages.values.flatten.uniq.sort languages['und'] = languages.values.flatten.uniq.sort
languages languages
end end
end end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Translation < ActiveModelSerializers::Model class Translation < ActiveModelSerializers::Model
attributes :status, :detected_source_language, :language, :provider, attributes :status, :source_language, :detected_source_language, :language, :provider,
:content, :spoiler_text, :poll_options, :media_attachments :content, :spoiler_text, :poll_options, :media_attachments
class Option < ActiveModelSerializers::Model class Option < ActiveModelSerializers::Model

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::TranslationSerializer < ActiveModel::Serializer class REST::TranslationSerializer < ActiveModel::Serializer
attributes :detected_source_language, :language, :provider, :spoiler_text, :content attributes :source_language, :detected_source_language, :language, :provider, :spoiler_text, :content
class PollSerializer < ActiveModel::Serializer class PollSerializer < ActiveModel::Serializer
attribute :id attribute :id

View File

@ -6,15 +6,16 @@ class TranslateStatusService < BaseService
include ERB::Util include ERB::Util
include FormattingHelper include FormattingHelper
def call(status, target_language) def call(status, source_language, target_language)
@status = status @status = status
@source_texts = source_texts @source_texts = source_texts
@source_language = source_language || @status.language.presence || 'und'
@target_language = target_language @target_language = target_language
raise Mastodon::NotPermittedError unless permitted? raise Mastodon::NotPermittedError unless permitted?
status_translation = Rails.cache.fetch("v2:translations/#{@status.language}/#{@target_language}/#{content_hash}", expires_in: CACHE_TTL) do status_translation = Rails.cache.fetch("v3:translations/#{@source_language}/#{@target_language}/#{content_hash}", expires_in: CACHE_TTL) do
translations = translation_backend.translate(@source_texts.values, @status.language, @target_language) translations = translation_backend.translate(@source_texts.values, @source_language, @target_language)
build_status_translation(translations) build_status_translation(translations)
end end
@ -32,11 +33,11 @@ class TranslateStatusService < BaseService
def permitted? def permitted?
return false unless @status.distributable? && TranslationService.configured? return false unless @status.distributable? && TranslationService.configured?
languages[@status.language]&.include?(@target_language) languages[@source_language]&.include?(@target_language)
end end
def languages def languages
Rails.cache.fetch('translation_service/languages', expires_in: 7.days, race_condition_ttl: 1.hour) { TranslationService.configured.languages } Rails.cache.fetch('v2:translation_service/languages', expires_in: 7.days, race_condition_ttl: 1.hour) { TranslationService.configured.languages }
end end
def content_hash def content_hash
@ -61,6 +62,7 @@ class TranslateStatusService < BaseService
def build_status_translation(translations) def build_status_translation(translations)
status_translation = Translation.new( status_translation = Translation.new(
source_language: @source_language,
detected_source_language: translations.first&.detected_source_language, detected_source_language: translations.first&.detected_source_language,
language: @target_language, language: @target_language,
provider: translations.first&.provider, provider: translations.first&.provider,