From 69057467cba138d2c9e459f565e88ea5979f61b0 Mon Sep 17 00:00:00 2001 From: Christian Schmidt <github@chsc.dk> Date: Thu, 1 Jun 2023 00:10:21 +0200 Subject: [PATCH] Translate CW, poll options and media descriptions (#24175) Co-authored-by: Claire <claire.github-309c@sitedethib.com> --- .../mastodon/actions/importer/normalizer.js | 38 ++- app/javascript/mastodon/actions/statuses.js | 3 +- .../mastodon/components/media_attachments.jsx | 15 +- .../mastodon/components/media_gallery.jsx | 12 +- app/javascript/mastodon/components/poll.jsx | 12 +- app/javascript/mastodon/components/status.jsx | 30 ++- .../mastodon/components/status_content.jsx | 20 +- .../mastodon/containers/status_container.jsx | 2 +- .../status/components/detailed_status.jsx | 14 +- .../mastodon/features/status/index.jsx | 2 +- .../features/ui/components/audio_modal.jsx | 12 +- .../features/ui/components/media_modal.jsx | 7 +- .../features/ui/components/video_modal.jsx | 12 +- app/javascript/mastodon/reducers/polls.js | 29 +++ app/javascript/mastodon/reducers/statuses.js | 26 +- app/lib/emoji_formatter.rb | 11 +- app/lib/translation_service/deepl.rb | 19 +- .../translation_service/libre_translate.rb | 19 +- app/models/translation.rb | 14 ++ .../rest/translation_serializer.rb | 35 ++- app/services/translate_status_service.rb | 83 ++++++- .../statuses/translations_controller_spec.rb | 2 +- spec/lib/translation_service/deepl_spec.rb | 26 +- .../libre_translate_spec.rb | 34 ++- .../services/translate_status_service_spec.rb | 226 ++++++++++++++++++ 25 files changed, 603 insertions(+), 100 deletions(-) create mode 100644 app/models/translation.rb create mode 100644 spec/services/translate_status_service_spec.rb diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 61062fd2c..3232e12a2 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -6,7 +6,7 @@ import { unescapeHTML } from '../../utils/html'; const domParser = new DOMParser(); -const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { +const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => { obj[`:${emoji.shortcode}:`] = emoji; return obj; }, {}); @@ -20,7 +20,7 @@ export function searchTextFromRawStatus (status) { export function normalizeAccount(account) { account = { ...account }; - const emojiMap = makeEmojiMap(account); + const emojiMap = makeEmojiMap(account.emojis); const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name; account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap); @@ -86,7 +86,7 @@ export function normalizeStatus(status, normalOldStatus) { const spoilerText = normalStatus.spoiler_text || ''; const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); - const emojiMap = makeEmojiMap(normalStatus); + const emojiMap = makeEmojiMap(normalStatus.emojis); normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); @@ -97,22 +97,48 @@ export function normalizeStatus(status, normalOldStatus) { return normalStatus; } +export function normalizeStatusTranslation(translation, status) { + const emojiMap = makeEmojiMap(status.get('emojis').toJS()); + + const normalTranslation = { + detected_source_language: translation.detected_source_language, + language: translation.language, + provider: translation.provider, + contentHtml: emojify(translation.content, emojiMap), + spoilerHtml: emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap), + spoiler_text: translation.spoiler_text, + }; + + return normalTranslation; +} + export function normalizePoll(poll) { const normalPoll = { ...poll }; - const emojiMap = makeEmojiMap(normalPoll); + const emojiMap = makeEmojiMap(poll.emojis); normalPoll.options = poll.options.map((option, index) => ({ ...option, voted: poll.own_votes && poll.own_votes.includes(index), - title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap), + titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap), })); return normalPoll; } +export function normalizePollOptionTranslation(translation, poll) { + const emojiMap = makeEmojiMap(poll.get('emojis').toJS()); + + const normalTranslation = { + ...translation, + titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap), + }; + + return normalTranslation; +} + export function normalizeAnnouncement(announcement) { const normalAnnouncement = { ...announcement }; - const emojiMap = makeEmojiMap(normalAnnouncement); + const emojiMap = makeEmojiMap.emojis(normalAnnouncement); normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap); diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 84a1271b8..3aed80735 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -343,7 +343,8 @@ export const translateStatusFail = (id, error) => ({ error, }); -export const undoStatusTranslation = id => ({ +export const undoStatusTranslation = (id, pollId) => ({ type: STATUS_TRANSLATE_UNDO, id, + pollId, }); diff --git a/app/javascript/mastodon/components/media_attachments.jsx b/app/javascript/mastodon/components/media_attachments.jsx index d2f171243..7b945a0ea 100644 --- a/app/javascript/mastodon/components/media_attachments.jsx +++ b/app/javascript/mastodon/components/media_attachments.jsx @@ -51,8 +51,9 @@ export default class MediaAttachments extends ImmutablePureComponent { }; render () { - const { status, lang, width, height } = this.props; + const { status, width, height } = this.props; const mediaAttachments = status.get('media_attachments'); + const language = status.getIn(['language', 'translation']) || status.get('language') || this.props.lang; if (mediaAttachments.size === 0) { return null; @@ -60,14 +61,15 @@ export default class MediaAttachments extends ImmutablePureComponent { if (mediaAttachments.getIn([0, 'type']) === 'audio') { const audio = mediaAttachments.get(0); + const description = audio.getIn(['translation', 'description']) || audio.get('description'); return ( <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} > {Component => ( <Component src={audio.get('url')} - alt={audio.get('description')} - lang={lang || status.get('language')} + alt={description} + lang={language} width={width} height={height} poster={audio.get('preview_url') || status.getIn(['account', 'avatar_static'])} @@ -81,6 +83,7 @@ export default class MediaAttachments extends ImmutablePureComponent { ); } else if (mediaAttachments.getIn([0, 'type']) === 'video') { const video = mediaAttachments.get(0); + const description = video.getIn(['translation', 'description']) || video.get('description'); return ( <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > @@ -90,8 +93,8 @@ export default class MediaAttachments extends ImmutablePureComponent { frameRate={video.getIn(['meta', 'original', 'frame_rate'])} blurhash={video.get('blurhash')} src={video.get('url')} - alt={video.get('description')} - lang={lang || status.get('language')} + alt={description} + lang={language} width={width} height={height} inline @@ -107,7 +110,7 @@ export default class MediaAttachments extends ImmutablePureComponent { {Component => ( <Component media={mediaAttachments} - lang={lang || status.get('language')} + lang={language} sensitive={status.get('sensitive')} defaultWidth={width} height={height} diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx index 152426656..1044b729b 100644 --- a/app/javascript/mastodon/components/media_gallery.jsx +++ b/app/javascript/mastodon/components/media_gallery.jsx @@ -105,10 +105,12 @@ class Item extends PureComponent { badges.push(<span key='alt' className='media-gallery__gifv__label'>ALT</span>); } + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); + if (attachment.get('type') === 'unknown') { return ( <div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}> - <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} lang={lang} target='_blank' rel='noopener noreferrer'> + <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={description} lang={lang} target='_blank' rel='noopener noreferrer'> <Blurhash hash={attachment.get('blurhash')} className='media-gallery__preview' @@ -146,8 +148,8 @@ class Item extends PureComponent { src={previewUrl} srcSet={srcSet} sizes={sizes} - alt={attachment.get('description')} - title={attachment.get('description')} + alt={description} + title={description} lang={lang} style={{ objectPosition: `${x}% ${y}%` }} onLoad={this.handleImageLoad} @@ -163,8 +165,8 @@ class Item extends PureComponent { <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> <video className='media-gallery__item-gifv-thumbnail' - aria-label={attachment.get('description')} - title={attachment.get('description')} + aria-label={description} + title={description} lang={lang} role='application' src={attachment.get('url')} diff --git a/app/javascript/mastodon/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx index 84e4e660a..fd2efd59c 100644 --- a/app/javascript/mastodon/components/poll.jsx +++ b/app/javascript/mastodon/components/poll.jsx @@ -138,10 +138,12 @@ class Poll extends ImmutablePureComponent { const active = !!this.state.selected[`${optionIndex}`]; const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex)); - let titleEmojified = option.get('title_emojified'); - if (!titleEmojified) { + const title = option.getIn(['translation', 'title']) || option.get('title'); + let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml'); + + if (!titleHtml) { const emojiMap = makeEmojiMap(poll); - titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap); + titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); } return ( @@ -163,7 +165,7 @@ class Poll extends ImmutablePureComponent { role={poll.get('multiple') ? 'checkbox' : 'radio'} onKeyPress={this.handleOptionKeyPress} aria-checked={active} - aria-label={option.get('title')} + aria-label={title} lang={lang} data-index={optionIndex} /> @@ -182,7 +184,7 @@ class Poll extends ImmutablePureComponent { <span className='poll__option__text translate' lang={lang} - dangerouslySetInnerHTML={{ __html: titleEmojified }} + dangerouslySetInnerHTML={{ __html: titleHtml }} /> {!!voted && <span className='poll__voted'> diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 3f3c292ea..8f188a638 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -27,12 +27,18 @@ import { RelativeTimestamp } from './relative_timestamp'; import StatusActionBar from './status_action_bar'; import StatusContent from './status_content'; +const domParser = new DOMParser(); + export const textForScreenReader = (intl, status, rebloggedByText = false) => { const displayName = status.getIn(['account', 'display_name']); + const spoilerText = status.getIn(['translation', 'spoiler_text']) || status.get('spoiler_text'); + const contentHtml = status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); + const contentText = domParser.parseFromString(contentHtml, 'text/html').documentElement.textContent; + const values = [ displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName, - status.get('spoiler_text') && status.get('hidden') ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length), + spoilerText && status.get('hidden') ? spoilerText : contentText, intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }), status.getIn(['account', 'acct']), ]; @@ -199,12 +205,14 @@ class Status extends ImmutablePureComponent { handleOpenVideo = (options) => { const status = this._properStatus(); - this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), status.get('language'), options); + const lang = status.getIn(['translation', 'language']) || status.get('language'); + this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, options); }; handleOpenMedia = (media, index) => { const status = this._properStatus(); - this.props.onOpenMedia(status.get('id'), media, index, status.get('language')); + const lang = status.getIn(['translation', 'language']) || status.get('language'); + this.props.onOpenMedia(status.get('id'), media, index, lang); }; handleHotkeyOpenMedia = e => { @@ -214,7 +222,7 @@ class Status extends ImmutablePureComponent { e.preventDefault(); if (status.get('media_attachments').size > 0) { - const lang = status.get('language'); + const lang = status.getIn(['translation', 'language']) || status.get('language'); if (status.getIn(['media_attachments', 0, 'type']) === 'video') { onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, { startTime: 0 }); } else { @@ -420,6 +428,8 @@ class Status extends ImmutablePureComponent { if (pictureInPicture.get('inUse')) { media = <PictureInPicturePlaceholder />; } else if (status.get('media_attachments').size > 0) { + const language = status.getIn(['translation', 'language']) || status.get('language'); + if (this.props.muted) { media = ( <AttachmentList @@ -429,14 +439,15 @@ class Status extends ImmutablePureComponent { ); } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); media = ( <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} > {Component => ( <Component src={attachment.get('url')} - alt={attachment.get('description')} - lang={status.get('language')} + alt={description} + lang={language} poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} @@ -456,6 +467,7 @@ class Status extends ImmutablePureComponent { ); } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); media = ( <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > @@ -465,8 +477,8 @@ class Status extends ImmutablePureComponent { frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])} blurhash={attachment.get('blurhash')} src={attachment.get('url')} - alt={attachment.get('description')} - lang={status.get('language')} + alt={description} + lang={language} inline sensitive={status.get('sensitive')} onOpenVideo={this.handleOpenVideo} @@ -483,7 +495,7 @@ class Status extends ImmutablePureComponent { {Component => ( <Component media={status.get('media_attachments')} - lang={status.get('language')} + lang={language} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.handleOpenMedia} diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 4c8ae7e15..3b3a191d6 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -231,11 +231,11 @@ class StatusContent extends PureComponent { const renderReadMore = this.props.onClick && status.get('collapsed'); const contentLocale = intl.locale.replace(/[_-].*/, ''); const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); - const renderTranslate = this.props.onTranslate && this.context.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('contentHtml').length > 0 && targetLanguages?.includes(contentLocale); + const renderTranslate = this.props.onTranslate && this.context.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale); - const content = { __html: status.get('translation') ? status.getIn(['translation', 'content']) : status.get('contentHtml') }; - const spoilerContent = { __html: status.get('spoilerHtml') }; - const lang = status.get('translation') ? intl.locale : status.get('language'); + const content = { __html: status.getIn(['translation', 'contentHtml']) || status.get('contentHtml') }; + const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') }; + const language = status.getIn(['translation', 'language']) || status.get('language'); const classNames = classnames('status__content', { 'status__content--with-action': this.props.onClick && this.context.router, 'status__content--with-spoiler': status.get('spoiler_text').length > 0, @@ -253,7 +253,7 @@ class StatusContent extends PureComponent { ); const poll = !!status.get('poll') && ( - <PollContainer pollId={status.get('poll')} lang={status.get('language')} /> + <PollContainer pollId={status.get('poll')} lang={language} /> ); if (status.get('spoiler_text').length > 0) { @@ -274,24 +274,24 @@ class StatusContent extends PureComponent { return ( <div className={classNames} ref={this.setRef} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> - <span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={lang} /> + <span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={language} /> {' '} <button type='button' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick} aria-expanded={!hidden}>{toggleText}</button> </p> {mentionsPlaceholder} - <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} lang={lang} dangerouslySetInnerHTML={content} /> + <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} lang={language} dangerouslySetInnerHTML={content} /> {!hidden && poll} - {!hidden && translateButton} + {translateButton} </div> ); } else if (this.props.onClick) { return ( <> <div className={classNames} ref={this.setRef} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> - <div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} /> + <div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} /> {poll} {translateButton} @@ -303,7 +303,7 @@ class StatusContent extends PureComponent { } else { return ( <div className={classNames} ref={this.setRef} tabIndex={0} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> - <div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} /> + <div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} /> {poll} {translateButton} diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index 3026dde0a..6167b404f 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -180,7 +180,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ onTranslate (status) { if (status.get('translation')) { - dispatch(undoStatusTranslation(status.get('id'))); + dispatch(undoStatusTranslation(status.get('id'), status.get('poll'))); } else { dispatch(translateStatus(status.get('id'))); } diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx index 187e04ad1..83a566710 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.jsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx @@ -133,17 +133,20 @@ class DetailedStatus extends ImmutablePureComponent { outerStyle.height = `${this.state.height}px`; } + const language = status.getIn(['translation', 'language']) || status.get('language'); + if (pictureInPicture.get('inUse')) { media = <PictureInPicturePlaceholder />; } else if (status.get('media_attachments').size > 0) { if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); media = ( <Audio src={attachment.get('url')} - alt={attachment.get('description')} - lang={status.get('language')} + alt={description} + lang={language} duration={attachment.getIn(['meta', 'original', 'duration'], 0)} poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} @@ -158,6 +161,7 @@ class DetailedStatus extends ImmutablePureComponent { ); } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); media = ( <Video @@ -165,8 +169,8 @@ class DetailedStatus extends ImmutablePureComponent { frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])} blurhash={attachment.get('blurhash')} src={attachment.get('url')} - alt={attachment.get('description')} - lang={status.get('language')} + alt={description} + lang={language} width={300} height={150} inline @@ -182,7 +186,7 @@ class DetailedStatus extends ImmutablePureComponent { standalone sensitive={status.get('sensitive')} media={status.get('media_attachments')} - lang={status.get('language')} + lang={language} height={300} onOpenMedia={this.props.onOpenMedia} visible={this.props.showMedia} diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 195c6b523..c31607c8e 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -430,7 +430,7 @@ class Status extends ImmutablePureComponent { const { dispatch } = this.props; if (status.get('translation')) { - dispatch(undoStatusTranslation(status.get('id'))); + dispatch(undoStatusTranslation(status.get('id'), status.get('poll'))); } else { dispatch(translateStatus(status.get('id'))); } diff --git a/app/javascript/mastodon/features/ui/components/audio_modal.jsx b/app/javascript/mastodon/features/ui/components/audio_modal.jsx index b8e6ee5e4..5baed2b3f 100644 --- a/app/javascript/mastodon/features/ui/components/audio_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/audio_modal.jsx @@ -8,7 +8,7 @@ import Audio from 'mastodon/features/audio'; import Footer from 'mastodon/features/picture_in_picture/components/footer'; const mapStateToProps = (state, { statusId }) => ({ - language: state.getIn(['statuses', statusId, 'language']), + status: state.getIn(['statuses', statusId]), accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']), }); @@ -17,7 +17,7 @@ class AudioModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, statusId: PropTypes.string.isRequired, - language: PropTypes.string, + status: ImmutablePropTypes.map.isRequired, accountStaticAvatar: PropTypes.string.isRequired, options: PropTypes.shape({ autoPlay: PropTypes.bool, @@ -27,15 +27,17 @@ class AudioModal extends ImmutablePureComponent { }; render () { - const { media, language, accountStaticAvatar, statusId, onClose } = this.props; + const { media, status, accountStaticAvatar, onClose } = this.props; const options = this.props.options || {}; + const language = status.getIn(['translation', 'language']) || status.get('language'); + const description = media.getIn(['translation', 'description']) || media.get('description'); return ( <div className='modal-root__modal audio-modal'> <div className='audio-modal__container'> <Audio src={media.get('url')} - alt={media.get('description')} + alt={description} lang={language} duration={media.getIn(['meta', 'original', 'duration'], 0)} height={150} @@ -48,7 +50,7 @@ class AudioModal extends ImmutablePureComponent { </div> <div className='media-modal__overlay'> - {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + {status && <Footer statusId={status.get('id')} withOpenButton onClose={onClose} />} </div> </div> ); diff --git a/app/javascript/mastodon/features/ui/components/media_modal.jsx b/app/javascript/mastodon/features/ui/components/media_modal.jsx index fad08b675..d38dc1804 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/media_modal.jsx @@ -145,6 +145,7 @@ class MediaModal extends ImmutablePureComponent { const content = media.map((image) => { const width = image.getIn(['meta', 'original', 'width']) || null; const height = image.getIn(['meta', 'original', 'height']) || null; + const description = image.getIn(['translation', 'description']) || image.get('description'); if (image.get('type') === 'image') { return ( @@ -153,7 +154,7 @@ class MediaModal extends ImmutablePureComponent { src={image.get('url')} width={width} height={height} - alt={image.get('description')} + alt={description} lang={lang} key={image.get('url')} onClick={this.toggleNavigation} @@ -176,7 +177,7 @@ class MediaModal extends ImmutablePureComponent { volume={volume || 1} onCloseVideo={onClose} detailed - alt={image.get('description')} + alt={description} lang={lang} key={image.get('url')} /> @@ -188,7 +189,7 @@ class MediaModal extends ImmutablePureComponent { width={width} height={height} key={image.get('url')} - alt={image.get('description')} + alt={description} lang={lang} onClick={this.toggleNavigation} /> diff --git a/app/javascript/mastodon/features/ui/components/video_modal.jsx b/app/javascript/mastodon/features/ui/components/video_modal.jsx index 2cc88c043..48c6301a5 100644 --- a/app/javascript/mastodon/features/ui/components/video_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/video_modal.jsx @@ -9,7 +9,7 @@ import Footer from 'mastodon/features/picture_in_picture/components/footer'; import Video from 'mastodon/features/video'; const mapStateToProps = (state, { statusId }) => ({ - language: state.getIn(['statuses', statusId, 'language']), + status: state.getIn(['statuses', statusId]), }); class VideoModal extends ImmutablePureComponent { @@ -17,7 +17,7 @@ class VideoModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, statusId: PropTypes.string, - language: PropTypes.string, + status: ImmutablePropTypes.map, options: PropTypes.shape({ startTime: PropTypes.number, autoPlay: PropTypes.bool, @@ -38,8 +38,10 @@ class VideoModal extends ImmutablePureComponent { } render () { - const { media, statusId, language, onClose } = this.props; + const { media, status, onClose } = this.props; const options = this.props.options || {}; + const language = status.getIn(['translation', 'language']) || status.get('language'); + const description = media.getIn(['translation', 'description']) || media.get('description'); return ( <div className='modal-root__modal video-modal'> @@ -55,13 +57,13 @@ class VideoModal extends ImmutablePureComponent { onCloseVideo={onClose} autoFocus detailed - alt={media.get('description')} + alt={description} lang={language} /> </div> <div className='media-modal__overlay'> - {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + {status && <Footer statusId={status.get('id')} withOpenButton onClose={onClose} />} </div> </div> ); diff --git a/app/javascript/mastodon/reducers/polls.js b/app/javascript/mastodon/reducers/polls.js index 901fdc449..5e8e775da 100644 --- a/app/javascript/mastodon/reducers/polls.js +++ b/app/javascript/mastodon/reducers/polls.js @@ -2,14 +2,43 @@ import { Map as ImmutableMap, fromJS } from 'immutable'; import { POLLS_IMPORT } from 'mastodon/actions/importer'; +import { normalizePollOptionTranslation } from '../actions/importer/normalizer'; +import { STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_UNDO } from '../actions/statuses'; + const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); +const statusTranslateSuccess = (state, pollTranslation) => { + return state.withMutations(map => { + if (pollTranslation) { + const poll = state.get(pollTranslation.id); + + pollTranslation.options.forEach((item, index) => { + map.setIn([pollTranslation.id, 'options', index, 'translation'], fromJS(normalizePollOptionTranslation(item, poll))); + }); + } + }); +}; + +const statusTranslateUndo = (state, id) => { + return state.withMutations(map => { + const options = map.getIn([id, 'options']); + + if (options) { + options.forEach((item, index) => map.deleteIn([id, 'options', index, 'translation'])); + } + }); +}; + const initialState = ImmutableMap(); export default function polls(state = initialState, action) { switch(action.type) { case POLLS_IMPORT: return importPolls(state, action.polls); + case STATUS_TRANSLATE_SUCCESS: + return statusTranslateSuccess(state, action.translation.poll); + case STATUS_TRANSLATE_UNDO: + return statusTranslateUndo(state, action.pollId); default: return state; } diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index fc5d31ab7..3c3d3d711 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -1,6 +1,7 @@ import { Map as ImmutableMap, fromJS } from 'immutable'; import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; +import { normalizeStatusTranslation } from '../actions/importer/normalizer'; import { REBLOG_REQUEST, REBLOG_FAIL, @@ -36,6 +37,27 @@ const deleteStatus = (state, id, references) => { return state.delete(id); }; +const statusTranslateSuccess = (state, id, translation) => { + return state.withMutations(map => { + map.setIn([id, 'translation'], fromJS(normalizeStatusTranslation(translation, map.get(id)))); + + const list = map.getIn([id, 'media_attachments']); + if (translation.media_attachments && list) { + translation.media_attachments.forEach(item => { + const index = list.findIndex(i => i.get('id') === item.id); + map.setIn([id, 'media_attachments', index, 'translation'], fromJS({ description: item.description })); + }); + } + }); +}; + +const statusTranslateUndo = (state, id) => { + return state.withMutations(map => { + map.deleteIn([id, 'translation']); + map.getIn([id, 'media_attachments']).forEach((item, index) => map.deleteIn([id, 'media_attachments', index, 'translation'])); + }); +}; + const initialState = ImmutableMap(); export default function statuses(state = initialState, action) { @@ -87,9 +109,9 @@ export default function statuses(state = initialState, action) { case TIMELINE_DELETE: return deleteStatus(state, action.id, action.references); case STATUS_TRANSLATE_SUCCESS: - return state.setIn([action.id, 'translation'], fromJS(action.translation)); + return statusTranslateSuccess(state, action.id, action.translation); case STATUS_TRANSLATE_UNDO: - return state.deleteIn([action.id, 'translation']); + return statusTranslateUndo(state, action.id); default: return state; } diff --git a/app/lib/emoji_formatter.rb b/app/lib/emoji_formatter.rb index dd9a0e5d7..8c3856d89 100644 --- a/app/lib/emoji_formatter.rb +++ b/app/lib/emoji_formatter.rb @@ -12,6 +12,7 @@ class EmojiFormatter # @param [Hash] options # @option options [Boolean] :animate # @option options [String] :style + # @option options [String] :raw_shortcode def initialize(html, custom_emojis, options = {}) raise ArgumentError unless html.html_safe? @@ -43,7 +44,7 @@ class EmojiFormatter next unless (char_after.nil? || !DISALLOWED_BOUNDING_REGEX.match?(char_after)) && (emoji = emoji_map[shortcode]) result << Nokogiri::XML::Text.new(text[last_index..shortname_start_index - 1], tree.document) if shortname_start_index.positive? - result << Nokogiri::HTML.fragment(image_for_emoji(shortcode, emoji)) + result << Nokogiri::HTML.fragment(tag_for_emoji(shortcode, emoji)) last_index = i + 1 elsif text[i] == ':' && (i.zero? || !DISALLOWED_BOUNDING_REGEX.match?(text[i - 1])) @@ -75,7 +76,9 @@ class EmojiFormatter end end - def image_for_emoji(shortcode, emoji) + def tag_for_emoji(shortcode, emoji) + return content_tag(:span, ":#{shortcode}:", translate: 'no') if raw_shortcode? + original_url, static_url = emoji image_tag( @@ -103,4 +106,8 @@ class EmojiFormatter def animate? @options[:animate] || @options.key?(:style) end + + def raw_shortcode? + @options[:raw_shortcode] + end end diff --git a/app/lib/translation_service/deepl.rb b/app/lib/translation_service/deepl.rb index afcb7ecb2..925a1cf17 100644 --- a/app/lib/translation_service/deepl.rb +++ b/app/lib/translation_service/deepl.rb @@ -10,8 +10,8 @@ class TranslationService::DeepL < TranslationService @api_key = api_key end - def translate(text, source_language, target_language) - form = { text: text, source_lang: source_language&.upcase, target_lang: target_language, tag_handling: 'html' } + def translate(texts, source_language, target_language) + form = { text: texts, source_lang: source_language&.upcase, target_lang: target_language, tag_handling: 'html' } request(:post, '/v2/translate', form: form) do |res| transform_response(res.body_with_limit) end @@ -67,12 +67,17 @@ class TranslationService::DeepL < TranslationService end end - def transform_response(str) - json = Oj.load(str, mode: :strict) + def transform_response(json) + data = Oj.load(json, mode: :strict) + raise UnexpectedResponseError unless data.is_a?(Hash) - raise UnexpectedResponseError unless json.is_a?(Hash) - - Translation.new(text: json.dig('translations', 0, 'text'), detected_source_language: json.dig('translations', 0, 'detected_source_language')&.downcase, provider: 'DeepL.com') + data['translations'].map do |translation| + Translation.new( + text: translation['text'], + detected_source_language: translation['detected_source_language']&.downcase, + provider: 'DeepL.com' + ) + end rescue Oj::ParseError raise UnexpectedResponseError end diff --git a/app/lib/translation_service/libre_translate.rb b/app/lib/translation_service/libre_translate.rb index 8bb194a9c..de43d7c88 100644 --- a/app/lib/translation_service/libre_translate.rb +++ b/app/lib/translation_service/libre_translate.rb @@ -8,8 +8,8 @@ class TranslationService::LibreTranslate < TranslationService @api_key = api_key end - def translate(text, source_language, target_language) - body = Oj.dump(q: text, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key) + 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) request(:post, '/translate', body: body) do |res| transform_response(res.body_with_limit, source_language) end @@ -44,12 +44,17 @@ class TranslationService::LibreTranslate < TranslationService end end - def transform_response(str, source_language) - json = Oj.load(str, mode: :strict) + def transform_response(json, source_language) + data = Oj.load(json, mode: :strict) + raise UnexpectedResponseError unless data.is_a?(Hash) - raise UnexpectedResponseError unless json.is_a?(Hash) - - Translation.new(text: json['translatedText'], detected_source_language: source_language, provider: 'LibreTranslate') + data['translatedText'].map.with_index do |text, index| + Translation.new( + text: text, + detected_source_language: data.dig('detectedLanguage', index, 'language') || source_language, + provider: 'LibreTranslate' + ) + end rescue Oj::ParseError raise UnexpectedResponseError end diff --git a/app/models/translation.rb b/app/models/translation.rb new file mode 100644 index 000000000..7f8469c86 --- /dev/null +++ b/app/models/translation.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Translation < ActiveModelSerializers::Model + attributes :status, :detected_source_language, :language, :provider, + :content, :spoiler_text, :poll_options, :media_attachments + + class Option < ActiveModelSerializers::Model + attributes :title + end + + class MediaAttachment < ActiveModelSerializers::Model + attributes :id, :description + end +end diff --git a/app/serializers/rest/translation_serializer.rb b/app/serializers/rest/translation_serializer.rb index 05ededc95..40e2d28fb 100644 --- a/app/serializers/rest/translation_serializer.rb +++ b/app/serializers/rest/translation_serializer.rb @@ -1,9 +1,38 @@ # frozen_string_literal: true class REST::TranslationSerializer < ActiveModel::Serializer - attributes :content, :detected_source_language, :provider + attributes :detected_source_language, :language, :provider, :spoiler_text, :content - def content - object.text + class PollSerializer < ActiveModel::Serializer + attribute :id + has_many :options + + def id + object.status.preloadable_poll.id.to_s + end + + def options + object.poll_options + end + + class OptionSerializer < ActiveModel::Serializer + attributes :title + end + end + + has_one :poll, serializer: PollSerializer + + class MediaAttachmentSerializer < ActiveModel::Serializer + attributes :id, :description + + def id + object.id.to_s + end + end + + has_many :media_attachments, serializer: MediaAttachmentSerializer + + def poll + object if object.status.preloadable_poll end end diff --git a/app/services/translate_status_service.rb b/app/services/translate_status_service.rb index 796f13a0d..c2b40433e 100644 --- a/app/services/translate_status_service.rb +++ b/app/services/translate_status_service.rb @@ -3,16 +3,24 @@ class TranslateStatusService < BaseService CACHE_TTL = 1.day.freeze + include ERB::Util include FormattingHelper def call(status, target_language) @status = status - @content = status_content_format(@status) + @source_texts = source_texts @target_language = target_language raise Mastodon::NotPermittedError unless permitted? - Rails.cache.fetch("translations/#{@status.language}/#{@target_language}/#{content_hash}", expires_in: CACHE_TTL) { translation_backend.translate(@content, @status.language, @target_language) } + status_translation = Rails.cache.fetch("v2:translations/#{@status.language}/#{@target_language}/#{content_hash}", expires_in: CACHE_TTL) do + translations = translation_backend.translate(@source_texts.values, @status.language, @target_language) + build_status_translation(translations) + end + + status_translation.status = @status + + status_translation end private @@ -22,7 +30,7 @@ class TranslateStatusService < BaseService end def permitted? - return false unless @status.distributable? && @status.content.present? && TranslationService.configured? + return false unless @status.distributable? && TranslationService.configured? languages[@status.language]&.include?(@target_language) end @@ -32,6 +40,73 @@ class TranslateStatusService < BaseService end def content_hash - Digest::SHA256.base64digest(@content) + Digest::SHA256.base64digest(@source_texts.transform_keys { |key| key.respond_to?(:id) ? "#{key.class}-#{key.id}" : key }.to_json) + end + + def source_texts + texts = {} + texts[:content] = wrap_emoji_shortcodes(status_content_format(@status)) if @status.content.present? + texts[:spoiler_text] = wrap_emoji_shortcodes(html_escape(@status.spoiler_text)) if @status.spoiler_text.present? + + @status.preloadable_poll&.loaded_options&.each do |option| + texts[option] = wrap_emoji_shortcodes(html_escape(option.title)) + end + + @status.media_attachments.each do |media_attachment| + texts[media_attachment] = html_escape(media_attachment.description) + end + + texts + end + + def build_status_translation(translations) + status_translation = Translation.new( + detected_source_language: translations.first&.detected_source_language, + language: @target_language, + provider: translations.first&.provider, + content: '', + spoiler_text: '', + poll_options: [], + media_attachments: [] + ) + + @source_texts.keys.each_with_index do |source, index| + translation = translations[index] + + case source + when :content + status_translation.content = unwrap_emoji_shortcodes(translation.text).to_html + when :spoiler_text + status_translation.spoiler_text = unwrap_emoji_shortcodes(translation.text).content + when Poll::Option + status_translation.poll_options << Translation::Option.new( + title: unwrap_emoji_shortcodes(translation.text).content + ) + when MediaAttachment + status_translation.media_attachments << Translation::MediaAttachment.new( + id: source.id, + description: html_entities.decode(translation.text) + ) + end + end + + status_translation + end + + def wrap_emoji_shortcodes(text) + EmojiFormatter.new(text, @status.emojis, { raw_shortcode: true }).to_s + end + + def unwrap_emoji_shortcodes(html) + fragment = Nokogiri::HTML.fragment(html) + fragment.css('span[translate="no"]').each do |element| + element.remove_attribute('translate') + element.replace(element.children) if element.attributes.empty? + end + fragment + end + + def html_entities + HTMLEntities.new end end diff --git a/spec/controllers/api/v1/statuses/translations_controller_spec.rb b/spec/controllers/api/v1/statuses/translations_controller_spec.rb index 8495779bf..989e94750 100644 --- a/spec/controllers/api/v1/statuses/translations_controller_spec.rb +++ b/spec/controllers/api/v1/statuses/translations_controller_spec.rb @@ -19,7 +19,7 @@ describe Api::V1::Statuses::TranslationsController do before do translation = TranslationService::Translation.new(text: 'Hello') - service = instance_double(TranslationService::DeepL, translate: translation) + service = instance_double(TranslationService::DeepL, translate: [translation]) allow(TranslationService).to receive(:configured?).and_return(true) allow(TranslationService).to receive(:configured).and_return(service) Rails.cache.write('translation_service/languages', { 'es' => ['en'] }) diff --git a/spec/lib/translation_service/deepl_spec.rb b/spec/lib/translation_service/deepl_spec.rb index 2363f8f13..5a1d0f094 100644 --- a/spec/lib/translation_service/deepl_spec.rb +++ b/spec/lib/translation_service/deepl_spec.rb @@ -22,7 +22,10 @@ RSpec.describe TranslationService::DeepL do .with(body: 'text=Hasta+la+vista&source_lang=ES&target_lang=en&tag_handling=html') .to_return(body: '{"translations":[{"detected_source_language":"ES","text":"See you soon"}]}') - translation = service.translate('Hasta la vista', 'es', 'en') + translations = service.translate(['Hasta la vista'], 'es', 'en') + expect(translations.size).to eq 1 + + translation = translations.first expect(translation.detected_source_language).to eq 'es' expect(translation.provider).to eq 'DeepL.com' expect(translation.text).to eq 'See you soon' @@ -31,12 +34,27 @@ RSpec.describe TranslationService::DeepL do it 'returns translation with auto-detected source language' do stub_request(:post, 'https://api.deepl.com/v2/translate') .with(body: 'text=Guten+Tag&source_lang&target_lang=en&tag_handling=html') - .to_return(body: '{"translations":[{"detected_source_language":"DE","text":"Good Morning"}]}') + .to_return(body: '{"translations":[{"detected_source_language":"DE","text":"Good morning"}]}') - translation = service.translate('Guten Tag', nil, 'en') + translations = service.translate(['Guten Tag'], nil, 'en') + expect(translations.size).to eq 1 + + translation = translations.first expect(translation.detected_source_language).to eq 'de' expect(translation.provider).to eq 'DeepL.com' - expect(translation.text).to eq 'Good Morning' + expect(translation.text).to eq 'Good morning' + end + + it 'returns translation of multiple texts' do + stub_request(:post, 'https://api.deepl.com/v2/translate') + .with(body: 'text=Guten+Morgen&text=Gute+Nacht&source_lang=DE&target_lang=en&tag_handling=html') + .to_return(body: '{"translations":[{"detected_source_language":"DE","text":"Good morning"},{"detected_source_language":"DE","text":"Good night"}]}') + + translations = service.translate(['Guten Morgen', 'Gute Nacht'], 'de', 'en') + expect(translations.size).to eq 2 + + expect(translations.first.text).to eq 'Good morning' + expect(translations.last.text).to eq 'Good night' end end diff --git a/spec/lib/translation_service/libre_translate_spec.rb b/spec/lib/translation_service/libre_translate_spec.rb index fbd726a7e..90966a8eb 100644 --- a/spec/lib/translation_service/libre_translate_spec.rb +++ b/spec/lib/translation_service/libre_translate_spec.rb @@ -31,24 +31,42 @@ RSpec.describe TranslationService::LibreTranslate do describe '#translate' do it 'returns translation with specified source language' do stub_request(:post, 'https://libretranslate.example.com/translate') - .with(body: '{"q":"Hasta la vista","source":"es","target":"en","format":"html","api_key":"my-api-key"}') - .to_return(body: '{"translatedText": "See you"}') + .with(body: '{"q":["Hasta la vista"],"source":"es","target":"en","format":"html","api_key":"my-api-key"}') + .to_return(body: '{"translatedText": ["See you"]}') - translation = service.translate('Hasta la vista', 'es', 'en') - expect(translation.detected_source_language).to eq 'es' + translations = service.translate(['Hasta la vista'], 'es', 'en') + expect(translations.size).to eq 1 + + translation = translations.first + expect(translation.detected_source_language).to be 'es' expect(translation.provider).to eq 'LibreTranslate' expect(translation.text).to eq 'See you' end it 'returns translation with auto-detected source language' do stub_request(:post, 'https://libretranslate.example.com/translate') - .with(body: '{"q":"Guten Morgen","source":"auto","target":"en","format":"html","api_key":"my-api-key"}') - .to_return(body: '{"detectedLanguage":{"confidence":92,"language":"de"},"translatedText":"Good morning"}') + .with(body: '{"q":["Guten Morgen"],"source":"auto","target":"en","format":"html","api_key":"my-api-key"}') + .to_return(body: '{"detectedLanguage": [{"confidence": 92, "language": "de"}], "translatedText": ["Good morning"]}') - translation = service.translate('Guten Morgen', nil, 'en') - expect(translation.detected_source_language).to be_nil + translations = service.translate(['Guten Morgen'], nil, 'en') + expect(translations.size).to eq 1 + + translation = translations.first + expect(translation.detected_source_language).to eq 'de' expect(translation.provider).to eq 'LibreTranslate' expect(translation.text).to eq 'Good morning' end + + it 'returns translation of multiple texts' do + stub_request(:post, 'https://libretranslate.example.com/translate') + .with(body: '{"q":["Guten Morgen","Gute Nacht"],"source":"de","target":"en","format":"html","api_key":"my-api-key"}') + .to_return(body: '{"translatedText": ["Good morning", "Good night"]}') + + translations = service.translate(['Guten Morgen', 'Gute Nacht'], 'de', 'en') + expect(translations.size).to eq 2 + + expect(translations.first.text).to eq 'Good morning' + expect(translations.last.text).to eq 'Good night' + end end end diff --git a/spec/services/translate_status_service_spec.rb b/spec/services/translate_status_service_spec.rb new file mode 100644 index 000000000..074f55544 --- /dev/null +++ b/spec/services/translate_status_service_spec.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TranslateStatusService, type: :service do + subject(:service) { described_class.new } + + let(:status) { Fabricate(:status, text: text, spoiler_text: spoiler_text, language: 'en', preloadable_poll: poll, media_attachments: media_attachments) } + let(:text) { 'Hello' } + let(:spoiler_text) { '' } + let(:poll) { nil } + let(:media_attachments) { [] } + + before do + Fabricate(:custom_emoji, shortcode: 'highfive') + end + + describe '#call' do + before do + translation_service = TranslationService.new + allow(translation_service).to receive(:languages).and_return({ 'en' => ['es'] }) + allow(translation_service).to receive(:translate) do |texts| + texts.map do |text| + TranslationService::Translation.new( + text: text.gsub('Hello', 'Hola').gsub('higfive', 'cincoaltos'), + detected_source_language: 'en', + provider: 'Dummy' + ) + end + end + + allow(TranslationService).to receive(:configured?).and_return(true) + allow(TranslationService).to receive(:configured).and_return(translation_service) + end + + it 'returns translated status content' do + expect(service.call(status, 'es').content).to eq '<p>Hola</p>' + end + + it 'returns source language' do + expect(service.call(status, 'es').detected_source_language).to eq 'en' + end + + it 'returns translation provider' do + expect(service.call(status, 'es').provider).to eq 'Dummy' + end + + it 'returns original status' do + expect(service.call(status, 'es').status).to eq status + end + + describe 'status has content with custom emoji' do + let(:text) { 'Hello & :highfive:' } + + it 'does not translate shortcode' do + expect(service.call(status, 'es').content).to eq '<p>Hola & :highfive:</p>' + end + end + + describe 'status has no spoiler_text' do + it 'returns an empty string' do + expect(service.call(status, 'es').spoiler_text).to eq '' + end + end + + describe 'status has spoiler_text' do + let(:spoiler_text) { 'Hello & Hello!' } + + it 'translates the spoiler text' do + expect(service.call(status, 'es').spoiler_text).to eq 'Hola & Hola!' + end + end + + describe 'status has spoiler_text with custom emoji' do + let(:spoiler_text) { 'Hello :highfive:' } + + it 'does not translate shortcode' do + expect(service.call(status, 'es').spoiler_text).to eq 'Hola :highfive:' + end + end + + describe 'status has spoiler_text with unmatched custom emoji' do + let(:spoiler_text) { 'Hello :Hello:' } + + it 'translates the invalid shortcode' do + expect(service.call(status, 'es').spoiler_text).to eq 'Hola :Hola:' + end + end + + describe 'status has poll' do + let(:poll) { Fabricate(:poll, options: ['Hello 1', 'Hello 2']) } + + it 'translates the poll option title' do + status_translation = service.call(status, 'es') + expect(status_translation.poll_options.size).to eq 2 + expect(status_translation.poll_options.first.title).to eq 'Hola 1' + end + end + + describe 'status has media attachment' do + let(:media_attachments) { [Fabricate(:media_attachment, description: 'Hello & :highfive:')] } + + it 'translates the media attachment description' do + status_translation = service.call(status, 'es') + + media_attachment = status_translation.media_attachments.first + expect(media_attachment.id).to eq media_attachments.first.id + expect(media_attachment.description).to eq 'Hola & :highfive:' + end + end + end + + describe '#source_texts' do + before do + service.instance_variable_set(:@status, status) + end + + describe 'status only has content' do + it 'returns formatted content' do + expect(service.send(:source_texts)).to eq({ content: '<p>Hello</p>' }) + end + end + + describe 'status content contains custom emoji' do + let(:status) { Fabricate(:status, text: 'Hello :highfive:') } + + it 'returns formatted content' do + source_texts = service.send(:source_texts) + expect(source_texts[:content]).to eq '<p>Hello <span translate="no">:highfive:</span></p>' + end + end + + describe 'status content contains tags' do + let(:status) { Fabricate(:status, text: 'Hello #hola') } + + it 'returns formatted content' do + source_texts = service.send(:source_texts) + expect(source_texts[:content]).to include '<p>Hello <a' + expect(source_texts[:content]).to include '/tags/hola' + end + end + + describe 'status has spoiler text' do + let(:status) { Fabricate(:status, spoiler_text: 'Hello :highfive:') } + + it 'returns formatted spoiler text' do + source_texts = service.send(:source_texts) + expect(source_texts[:spoiler_text]).to eq 'Hello <span translate="no">:highfive:</span>' + end + end + + describe 'status has poll' do + let(:poll) { Fabricate(:poll, options: %w(Blue Green)) } + + it 'returns formatted poll options' do + source_texts = service.send(:source_texts) + expect(source_texts.size).to eq 3 + expect(source_texts.values).to eq %w(<p>Hello</p> Blue Green) + + expect(source_texts.keys.first).to eq :content + + option1 = source_texts.keys.second + expect(option1).to be_a Poll::Option + expect(option1.id).to eq '0' + expect(option1.title).to eq 'Blue' + + option2 = source_texts.keys.third + expect(option2).to be_a Poll::Option + expect(option2.id).to eq '1' + expect(option2.title).to eq 'Green' + end + end + + describe 'status has poll with custom emoji' do + let(:poll) { Fabricate(:poll, options: ['Blue', 'Green :highfive:']) } + + it 'returns formatted poll options' do + html = service.send(:source_texts).values.last + expect(html).to eq 'Green <span translate="no">:highfive:</span>' + end + end + + describe 'status has media attachments' do + let(:text) { '' } + let(:media_attachments) { [Fabricate(:media_attachment, description: 'Hello :highfive:')] } + + it 'returns media attachments without custom emoji rendering' do + source_texts = service.send(:source_texts) + expect(source_texts.size).to eq 1 + + key, text = source_texts.first + expect(key).to eq media_attachments.first + expect(text).to eq 'Hello :highfive:' + end + end + end + + describe '#wrap_emoji_shortcodes' do + before do + service.instance_variable_set(:@status, status) + end + + describe 'string contains custom emoji' do + let(:text) { ':highfive:' } + + it 'renders the emoji' do + html = service.send(:wrap_emoji_shortcodes, 'Hello :highfive:'.html_safe) + expect(html).to eq 'Hello <span translate="no">:highfive:</span>' + end + end + end + + describe '#unwrap_emoji_shortcodes' do + describe 'string contains custom emoji' do + it 'inserts the shortcode' do + fragment = service.send(:unwrap_emoji_shortcodes, '<p>Hello <span translate="no">:highfive:</span>!</p>') + expect(fragment.to_html).to eq '<p>Hello :highfive:!</p>' + end + + it 'preserves other attributes than translate=no' do + fragment = service.send(:unwrap_emoji_shortcodes, '<p>Hello <span translate="no" class="foo">:highfive:</span>!</p>') + expect(fragment.to_html).to eq '<p>Hello <span class="foo">:highfive:</span>!</p>' + end + end + end +end