FEATURE: Add translations to posts (#32564)

## 🔍 Overview
This update adds the ability for users to manually add translations to
specific posts. It adds a 🌐 icon on the post menu where you can click to
add translations for posts.

It also introduces a new site setting:
`content_localization_debug_allowed_groups` which is convenient when
debugging localized posts. It adds a globe icon in the post meta data
area along with a number of how many languages the post is translated
in. Hovering over the icon will show a tooltip with access to editing
and deleting the translated posts.

## 📸 Screenshots
<img width="1234" alt="Screenshot 2025-05-07 at 13 26 09"
src="https://github.com/user-attachments/assets/9d65374d-ee3e-4e8b-b171-b98db6f90f23"
/>
<img width="300" alt="Screenshot 2025-05-07 at 13 26 41"
src="https://github.com/user-attachments/assets/6ee9c5e6-16ed-4dab-97ec-9401804a4ac8"
/>
This commit is contained in:
Keegan George
2025-05-08 10:40:36 -07:00
committed by GitHub
parent 49802d667f
commit 6154fa6b45
35 changed files with 800 additions and 156 deletions

View File

@ -7,6 +7,7 @@ import discourseComputed from "discourse/lib/decorators";
import escape from "discourse/lib/escape";
import { iconHTML } from "discourse/lib/icon-library";
import {
ADD_TRANSLATION,
CREATE_SHARED_DRAFT,
CREATE_TOPIC,
EDIT,
@ -22,6 +23,7 @@ const TITLES = {
[CREATE_TOPIC]: "topic.create_long",
[CREATE_SHARED_DRAFT]: "composer.create_shared_draft",
[EDIT_SHARED_DRAFT]: "composer.edit_shared_draft",
[ADD_TRANSLATION]: "composer.translations.title",
};
@classNames("composer-action-title")

View File

@ -15,6 +15,7 @@ import DEditor from "discourse/components/d-editor";
import DEditorPreview from "discourse/components/d-editor-preview";
import Wrapper from "discourse/components/form-template-field/wrapper";
import PickFilesButton from "discourse/components/pick-files-button";
import PostTranslationEditor from "discourse/components/post-translation-editor";
import { ajax } from "discourse/lib/ajax";
import { tinyAvatar } from "discourse/lib/avatar-utils";
import { setupComposerPosition } from "discourse/lib/composer/composer-position";
@ -96,6 +97,8 @@ const DEBOUNCE_JIT_MS = 2000;
@classNameBindings("composer.showToolbar:toolbar-visible", ":wmd-controls")
export default class ComposerEditor extends Component {
@service composer;
@service siteSettings;
@service currentUser;
@tracked preview;
@ -947,6 +950,21 @@ export default class ComposerEditor extends Component {
this._selectedFormTemplateId = value;
}
get showTranslationEditor() {
if (
!this.siteSettings.experimental_content_localization ||
!this.currentUser.can_localize_content
) {
return false;
}
if (this.composer.model?.action === Composer.ADD_TRANSLATION) {
return true;
}
return false;
}
@action
async updateFormPreview() {
const formTemplateData = prepareFormTemplateData(
@ -1018,6 +1036,8 @@ export default class ComposerEditor extends Component {
/>
{{/if}}
</div>
{{else if this.showTranslationEditor}}
<PostTranslationEditor @setupEditor={{this.setupEditor}} />
{{else}}
<DEditor
@value={{this.composer.model.reply}}

View File

@ -780,7 +780,7 @@ export default class DEditor extends Component {
</div>
</div>
<DEditorPreview
@preview={{this.preview}}
@preview={{if @hijackPreview @hijackPreview this.preview}}
@forcePreview={{this.forcePreview}}
@onPreviewUpdated={{this.previewUpdated}}
@outletArgs={{this.outletArgs}}

View File

@ -0,0 +1,92 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DEditor from "discourse/components/d-editor";
import TextField from "discourse/components/text-field";
import { i18n } from "discourse-i18n";
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
export default class PostTranslationEditor extends Component {
@service composer;
@service siteSettings;
get availableLocales() {
return JSON.parse(this.siteSettings.available_locales);
}
findCurrentLocalization() {
return this.composer.model.post.post_localizations.find(
(localization) =>
localization.locale === this.composer.selectedTranslationLocale
);
}
@action
handleInput(event) {
this.composer.model.set("reply", event.target.value);
}
@action
updateSelectedLocale(locale) {
this.composer.selectedTranslationLocale = locale;
const currentLocalization = this.findCurrentLocalization();
if (currentLocalization) {
this.composer.model.set("reply", currentLocalization.raw);
}
}
<template>
<div>
<DropdownSelectBox
@nameProperty="name"
@valueProperty="value"
@value={{this.composer.selectedTranslationLocale}}
@content={{this.availableLocales}}
@onChange={{this.updateSelectedLocale}}
@options={{hash
icon="globe"
showCaret=true
filterable=true
disabled=this.composer.loading
placement="bottom-start"
translatedNone=(i18n "composer.translations.select")
}}
class="translation-selector-dropdown btn-small"
/>
</div>
{{#if this.composer.model.post.firstPost}}
<div class="topic-title-translator title-and-category with-preview">
<TextField
@value={{this.composer.model.title}}
@id="translated-topic-title"
@maxLength={{this.siteSettings.max_topic_title_length}}
@placeholder={{this.composer.model.topic.title}}
@disabled={{this.composer.loading}}
@autocomplete="off"
/>
</div>
{{/if}}
<div class="d-editor translation-editor">
<DEditor
@value={{readonly this.composer.model.reply}}
@change={{this.handleInput}}
@placeholder="composer.translations.placeholder"
@forcePreview={{true}}
@processPreview={{false}}
@loading={{this.composer.loading}}
@hijackPreview={{this.composer.hijackPreview}}
@disabled={{this.composer.disableTextarea}}
@onSetup={{@setupEditor}}
@disableSubmit={{this.composer.disableSubmit}}
@topicId={{this.composer.model.topic.id}}
@categoryId={{this.composer.model.category.id}}
@outletArgs={{hash composer=this.composer.model editorType="composer"}}
/>
</div>
</template>
}

View File

@ -23,6 +23,7 @@ import {
import { i18n } from "discourse-i18n";
import PostMenuButtonConfig from "./menu/button-config";
import PostMenuButtonWrapper from "./menu/button-wrapper";
import PostMenuAddTranslationButton from "./menu/buttons/add-translation";
import PostMenuAdminButton from "./menu/buttons/admin";
import PostMenuBookmarkButton from "./menu/buttons/bookmark";
import PostMenuCopyLinkButton from "./menu/buttons/copy-link";
@ -51,6 +52,7 @@ const buttonKeys = Object.freeze({
REPLIES: "replies",
REPLY: "reply",
SHARE: "share",
ADD_TRANSLATION: "addTranslation",
SHOW_MORE: "showMore",
});
@ -66,6 +68,7 @@ const coreButtonComponents = new Map([
[buttonKeys.REPLIES, PostMenuRepliesButton],
[buttonKeys.REPLY, PostMenuReplyButton],
[buttonKeys.SHARE, PostMenuShareButton],
[buttonKeys.ADD_TRANSLATION, PostMenuAddTranslationButton],
[buttonKeys.SHOW_MORE, PostMenuShowMoreButton],
]);

View File

@ -0,0 +1,53 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import Composer from "discourse/models/composer";
import { i18n } from "discourse-i18n";
export default class PostMenuAddTranslationButton extends Component {
@service composer;
@service currentUser;
@service siteSettings;
@tracked showComposer = false;
get originalPostContent() {
return `<div class='d-editor-translation-preview-wrapper'>
<span class='d-editor-translation-preview-wrapper__header'>
${i18n("composer.translations.original_content")}
</span>
${this.args.post.cooked}
</div>`;
}
@action
async addTranslation() {
if (
!this.currentUser ||
!this.siteSettings.experimental_content_localization ||
!this.currentUser.can_localize_content
) {
return;
}
await this.composer.open({
action: Composer.ADD_TRANSLATION,
draftKey: "translation",
warningsDisabled: true,
hijackPreview: this.originalPostContent,
post: this.args.post,
});
}
<template>
<DButton
class="post-action-menu__add-translation"
@title="post.localizations.add"
@icon="discourse-add-translation"
@action={{this.addTranslation}}
...attributes
/>
</template>
}

View File

@ -1,5 +1,6 @@
import Component from "@glimmer/component";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import PostMetaDataDate from "./meta-data/date";
import PostMetaDataEditsIndicator from "./meta-data/edits-indicator";
import PostMetaDataEmailIndicator from "./meta-data/email-indicator";
@ -8,9 +9,12 @@ import PostMetaDataPosterName from "./meta-data/poster-name";
import PostMetaDataReadIndicator from "./meta-data/read-indicator";
import PostMetaDataReplyToTab from "./meta-data/reply-to-tab";
import PostMetaDataSelectPost from "./meta-data/select-post";
import PostMetaDataTranslationIndicator from "./meta-data/translation-indicator";
import PostMetaDataWhisperIndicator from "./meta-data/whisper-indicator";
export default class PostMetaData extends Component {
@service currentUser;
get displayPosterName() {
return this.args.displayPosterName ?? true;
}
@ -23,6 +27,13 @@ export default class PostMetaData extends Component {
return PostMetaDataReplyToTab.shouldRender(this.args, null, getOwner(this));
}
get shouldDisplayTranslationIndicator() {
return (
this.currentUser?.can_debug_localizations &&
this.args.post?.has_post_localizations
);
}
<template>
<div class="topic-meta-data" role="heading" aria-level="2">
{{#if this.displayPosterName}}
@ -30,6 +41,10 @@ export default class PostMetaData extends Component {
{{/if}}
<div class="post-infos">
{{#if this.shouldDisplayTranslationIndicator}}
<PostMetaDataTranslationIndicator @post={{@post}} />
{{/if}}
{{#if @post.isWhisper}}
<PostMetaDataWhisperIndicator @post={{@post}} />
{{/if}}

View File

@ -0,0 +1,115 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import icon from "discourse/helpers/d-icon";
import { popupAjaxError } from "discourse/lib/ajax-error";
import Composer from "discourse/models/composer";
import PostLocalization from "discourse/models/post-localization";
import TopicLocalization from "discourse/models/topic-localization";
import { i18n } from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
export default class PostMetaDataTranslationIndicator extends Component {
@service composer;
@service currentUser;
@service siteSettings;
get postLocalizationsCount() {
return this.args.post?.post_localizations.length;
}
get originalPostContent() {
return `<div class='d-editor-translation-preview-wrapper'>
<span class='d-editor-translation-preview-wrapper__header'>
${i18n("composer.translations.original_content")}
</span>
${this.args.post.cooked}
</div>`;
}
@action
async editLocalization(locale) {
if (
!this.currentUser ||
!this.siteSettings.experimental_content_localization ||
!this.currentUser.can_localize_content
) {
return;
}
await this.composer.open({
action: Composer.ADD_TRANSLATION,
draftKey: "translation",
warningsDisabled: true,
hijackPreview: this.originalPostContent,
post: this.args.post,
selectedTranslationLocale: locale.locale,
});
this.composer.model.set("reply", locale.raw);
}
@action
async deleteLocalization(locale) {
try {
await PostLocalization.destroy(this.args.post.id, locale);
if (this.args.post.firstPost) {
await TopicLocalization.destroy(this.args.post.topic_id, locale);
}
} catch (error) {
popupAjaxError(error);
} finally {
window.location.reload();
}
}
<template>
<div class="post-info translations">
<DTooltip
@triggerClass="btn-flat"
@placement="bottom-start"
@arrow={{true}}
@identifier="post-meta-data-translation-indicator"
@interactive={{true}}
>
<:trigger>
<span class="translation-count">{{this.postLocalizationsCount}}</span>
{{icon "globe"}}
</:trigger>
<:content>
<table>
<thead>
<tr>
<th>{{i18n "post.localizations.table.locale"}}</th>
<th>{{i18n "post.localizations.table.actions"}}</th>
</tr>
</thead>
<tbody>
{{#each @post.post_localizations as |localization|}}
<tr>
<td>{{localization.locale}}</td>
<td>
<DButton
class="btn-primary btn-transparent"
@label="post.localizations.table.edit"
@action={{fn this.editLocalization localization}}
/>
</td>
<td>
<DButton
class="btn-danger btn-transparent"
@label="post.localizations.table.delete"
@action={{fn this.deleteLocalization localization.locale}}
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
</:content>
</DTooltip>
</div>
</template>
}

View File

@ -42,7 +42,8 @@ export const CREATE_TOPIC = "createTopic",
EDIT = "edit",
NEW_PRIVATE_MESSAGE_KEY = "new_private_message",
NEW_TOPIC_KEY = "new_topic",
EDIT_TOPIC_KEY = "topic_";
EDIT_TOPIC_KEY = "topic_",
ADD_TRANSLATION = "add_translation";
function isEdit(action) {
return action === EDIT || action === EDIT_SHARED_DRAFT;
@ -139,6 +140,7 @@ export default class Composer extends RestModel {
static PRIVATE_MESSAGE = PRIVATE_MESSAGE;
static REPLY = REPLY;
static EDIT = EDIT;
static ADD_TRANSLATION = ADD_TRANSLATION;
// Draft key
static NEW_PRIVATE_MESSAGE_KEY = NEW_PRIVATE_MESSAGE_KEY;
@ -1297,6 +1299,10 @@ export default class Composer extends RestModel {
"minimumPostLength"
)
canSaveDraft() {
if (this.action === Composer.ADD_TRANSLATION) {
return false;
}
if (this.draftSaving) {
return false;
}

View File

@ -0,0 +1,25 @@
import { ajax } from "discourse/lib/ajax";
import RestModel from "discourse/models/rest";
export default class PostLocalization extends RestModel {
static createOrUpdate(postId, locale, raw) {
return ajax("/post_localizations/create_or_update", {
type: "POST",
data: {
post_id: postId,
locale,
raw,
},
});
}
static destroy(postId, locale) {
return ajax("/post_localizations/destroy", {
type: "DELETE",
data: {
post_id: postId,
locale,
},
});
}
}

View File

@ -209,6 +209,8 @@ export default class Post extends RestModel {
@trackedPostProperty wiki;
@trackedPostProperty yours;
@trackedPostProperty user_custom_fields;
@trackedPostProperty has_post_localizations;
@trackedPostProperty post_localizations;
@alias("can_edit") canEdit; // for compatibility with existing code
@equal("trust_level", 0) new_user;

View File

@ -0,0 +1,25 @@
import { ajax } from "discourse/lib/ajax";
import RestModel from "discourse/models/rest";
export default class TopicLocalization extends RestModel {
static createOrUpdate(topicId, locale, title) {
return ajax("/topic_localizations/create_or_update", {
type: "POST",
data: {
topic_id: topicId,
locale,
title,
},
});
}
static destroy(topicId, locale) {
return ajax("/topic_localizations/destroy", {
type: "DELETE",
data: {
topic_id: topicId,
locale,
},
});
}
}

View File

@ -15,6 +15,7 @@ import {
cannotPostAgain,
durationTextFromSeconds,
} from "discourse/helpers/slow-mode";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { customPopupMenuOptions } from "discourse/lib/composer/custom-popup-menu-options";
import discourseDebounce from "discourse/lib/debounce";
import discourseComputed from "discourse/lib/decorators";
@ -45,6 +46,8 @@ import Composer, {
SAVE_LABELS,
} from "discourse/models/composer";
import Draft from "discourse/models/draft";
import PostLocalization from "discourse/models/post-localization";
import TopicLocalization from "discourse/models/topic-localization";
import { i18n } from "discourse-i18n";
async function loadDraft(store, opts = {}) {
@ -100,6 +103,7 @@ export default class ComposerService extends Service {
@service site;
@service siteSettings;
@service store;
@service toasts;
@tracked
showPreview = this.site.mobileView
@ -107,6 +111,7 @@ export default class ComposerService extends Service {
: (this.keyValueStore.get("composer.showPreview") || "true") === "true";
@tracked allowPreview = false;
@tracked selectedTranslationLocale = null;
checkedMessages = false;
messageCount = null;
showEditReason = false;
@ -362,6 +367,8 @@ export default class ComposerService extends Service {
return "composer.create_whisper";
} else if (privateMessage && modelAction === Composer.REPLY) {
return "composer.create_pm";
} else if (modelAction === Composer.ADD_TRANSLATION) {
return "composer.translations.save";
}
return SAVE_LABELS[modelAction];
@ -1036,6 +1043,10 @@ export default class ComposerService extends Service {
return;
}
if (this.model.action === Composer.ADD_TRANSLATION) {
return this.saveTranslation();
}
// Clear the warning state if we're not showing the checkbox anymore
if (!this.showWarning) {
this.set("model.isWarning", false);
@ -1275,6 +1286,44 @@ export default class ComposerService extends Service {
return promise;
}
async saveTranslation() {
this.set("model.loading", true);
this.set("lastValidatedAt", Date.now());
this.appEvents.trigger("composer-service:last-validated-at-updated", {
model: this.model,
});
try {
await PostLocalization.createOrUpdate(
this.model.post.id,
this.selectedTranslationLocale,
this.model.reply
);
if (this.model.post.firstPost) {
await TopicLocalization.createOrUpdate(
this.model.post.topic_id,
this.selectedTranslationLocale,
this.model.title
);
}
this.close();
this.toasts.success({
duration: 3000,
data: {
message: i18n("post.localizations.success"),
},
});
this.selectedTranslationLocale = null;
} catch (e) {
popupAjaxError(e);
} finally {
this.set("model.loading", false);
}
}
@action
postWasEnqueued(details) {
this.modal.show(PostEnqueuedModal, { model: details });
@ -1293,7 +1342,7 @@ export default class ComposerService extends Service {
@method open
@param {Object} opts Options for creating a post
@param {String} opts.action The action we're performing: edit, reply, createTopic, createSharedDraft, privateMessage
@param {String} opts.action The action we're performing: edit, reply, createTopic, createSharedDraft, privateMessage, addTranslation
@param {String} opts.draftKey
@param {Post} [opts.post] The post we're replying to
@param {Topic} [opts.topic] The topic we're replying to
@ -1306,6 +1355,8 @@ export default class ComposerService extends Service {
@param {String} [opts.draftSequence]
@param {Boolean} [opts.skipJumpOnSave] Option to skip navigating to the post when saved in this composer session
@param {Boolean} [opts.skipFormTemplate] Option to skip the form template even if configured for the category
@param {String} [opts.hijackPreview] Option to hijack the preview with custom content
@param {String} [opts.selectedTranslationLocale] The locale to use for the translation
**/
async open(opts = {}) {
if (!opts.draftKey) {
@ -1334,6 +1385,14 @@ export default class ComposerService extends Service {
this.set("skipFormTemplate", !!opts.skipFormTemplate);
if (opts.hijackPreview) {
this.set("hijackPreview", opts.hijackPreview);
}
if (opts.selectedTranslationLocale) {
this.selectedTranslationLocale = opts.selectedTranslationLocale;
}
// Scope the categories drop down to the category we opened the composer with.
if (opts.categoryId && !opts.disableScopedCategory) {
const category = Category.findById(opts.categoryId);

View File

@ -1006,6 +1006,31 @@ aside.quote {
}
}
.post-info.translations {
.fk-d-tooltip__trigger {
&-container {
display: flex;
align-items: center;
}
.translation-count {
padding-right: 0.25em;
}
.translation-count,
.d-icon {
color: var(--primary-med-or-secondary-med);
}
}
}
.post-meta-data-translation-indicator-content {
ul {
list-style: none;
margin: 0;
}
}
pre {
max-height: 2000px;

View File

@ -370,3 +370,39 @@
margin-bottom: 0;
height: 100%;
}
#reply-control.composer-action-add_translation {
.topic-title-translator {
display: flex;
align-items: center;
gap: 2rem;
input {
width: 48.5vw;
}
}
.translation-selector-dropdown {
margin-bottom: 0.5rem;
.select-kit-body {
max-height: 300px;
}
}
.d-editor-preview .d-editor-translation-preview-wrapper {
border: 1px dashed var(--primary-300);
border-radius: var(--d-border-radius);
padding: 1rem;
margin-top: 1rem;
&__header {
background: var(--primary-low);
padding: 0.25rem 0.5rem;
border-radius: var(--d-border-radius);
position: absolute;
top: 5.5rem;
font-size: var(--font-down-1);
}
}
}

View File

@ -3,30 +3,29 @@
class PostLocalizationsController < ApplicationController
before_action :ensure_logged_in
def create
def create_or_update
guardian.ensure_can_localize_content!
params.require(%i[post_id locale raw])
PostLocalizationCreator.create(
post_id: params[:post_id],
locale: params[:locale],
raw: params[:raw],
user: current_user,
)
render json: success_json, status: :created
end
def update
guardian.ensure_can_localize_content!
params.require(%i[post_id locale raw])
PostLocalizationUpdater.update(
post_id: params[:post_id],
locale: params[:locale],
raw: params[:raw],
user: current_user,
)
render json: success_json, status: :ok
localization = PostLocalization.find_by(post_id: params[:post_id], locale: params[:locale])
if localization
PostLocalizationUpdater.update(
post_id: params[:post_id],
locale: params[:locale],
raw: params[:raw],
user: current_user,
)
render json: success_json, status: :ok
else
PostLocalizationCreator.create(
post_id: params[:post_id],
locale: params[:locale],
raw: params[:raw],
user: current_user,
)
render json: success_json, status: :created
end
end
def destroy

View File

@ -3,30 +3,30 @@
class TopicLocalizationsController < ApplicationController
before_action :ensure_logged_in
def create
def create_or_update
guardian.ensure_can_localize_content!
params.require(%i[topic_id locale title])
TopicLocalizationCreator.create(
topic_id: params[:topic_id],
locale: params[:locale],
title: params[:title],
user: current_user,
)
render json: success_json, status: :created
end
def update
guardian.ensure_can_localize_content!
params.require(%i[topic_id locale title])
TopicLocalizationUpdater.update(
topic_id: params[:topic_id],
locale: params[:locale],
title: params[:title],
user: current_user,
)
render json: success_json, status: :ok
topic_localization =
TopicLocalization.find_by(topic_id: params[:topic_id], locale: params[:locale])
if topic_localization
TopicLocalizationUpdater.update(
topic_id: params[:topic_id],
locale: params[:locale],
title: params[:title],
user: current_user,
)
render json: success_json, status: :ok
else
TopicLocalizationCreator.create(
topic_id: params[:topic_id],
locale: params[:locale],
title: params[:title],
user: current_user,
)
render json: success_json, status: :created
end
end
def destroy

View File

@ -76,7 +76,9 @@ class CurrentUserSerializer < BasicUserSerializer
:login_method,
:has_unseen_features,
:can_see_emails,
:use_glimmer_post_stream_mode_auto_mode
:use_glimmer_post_stream_mode_auto_mode,
:can_localize_content,
:can_debug_localizations
delegate :user_stat, to: :object, private: true
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat
@ -324,4 +326,14 @@ class CurrentUserSerializer < BasicUserSerializer
def include_use_glimmer_post_stream_mode_auto_mode?
scope.user.in_any_groups?(SiteSetting.glimmer_post_stream_mode_auto_groups_map)
end
def can_localize_content
return false if !SiteSetting.experimental_content_localization
scope.user.in_any_groups?(SiteSetting.experimental_content_localization_allowed_groups_map)
end
def can_debug_localizations
return false if !SiteSetting.experimental_content_localization
scope.user.in_any_groups?(SiteSetting.content_localization_debug_allowed_groups_map)
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class PostLocalizationSerializer < ApplicationSerializer
attributes :id, :post_id, :locale, :raw
end

View File

@ -94,7 +94,9 @@ class PostSerializer < BasicPostSerializer
:user_suspended,
:user_status,
:mentioned_users,
:post_url
:post_url,
:has_post_localizations,
:post_localizations
def initialize(object, opts)
super(object, opts)
@ -649,6 +651,18 @@ class PostSerializer < BasicPostSerializer
SiteSetting.enable_user_status
end
def has_post_localizations
object.post_localizations.any?
end
def post_localizations
ActiveModel::ArraySerializer.new(
object.post_localizations,
each_serializer: PostLocalizationSerializer,
root: false,
).as_json
end
private
def can_review_topic?

View File

@ -2701,6 +2701,13 @@ en:
esc_label: "dismiss message"
ok_proceed: "Ok, proceed"
translations:
title: "Translating post"
select: "Select translation"
save: "Save translation"
original_content: "Original post content"
placeholder: "Enter translation for post here..."
group_mentioned_limit:
one: "<b>Warning!</b> You mentioned <a href='%{group_link}'>%{group}</a>, however this group has more members than the administrator configured mention limit of %{count} user. Nobody will be notified."
other: "<b>Warning!</b> You mentioned <a href='%{group_link}'>%{group}</a>, however this group has more members than the administrator configured mention limit of %{count} users. Nobody will be notified."
@ -3904,6 +3911,15 @@ en:
badge_granted_tooltip: "%{username} earned the '%{badge_name}' badge for this post!"
localizations:
table:
locale: "Locale"
actions: "Actions"
edit: "Edit"
delete: "Delete"
add: "Add new translation"
success: "Translations have been updated successfully"
errors:
create: "Sorry, there was an error creating your post. Please try again."
edit: "Sorry, there was an error editing your post. Please try again."

View File

@ -1228,8 +1228,12 @@ Discourse::Application.routes.draw do
put "merge_posts"
end
end
resources :post_localizations, only: %i[create update destroy]
resources :topic_localizations, only: %i[create update destroy]
post "/post_localizations/create_or_update", to: "post_localizations#create_or_update"
delete "/post_localizations/destroy", to: "post_localizations#destroy"
post "topic_localizations/create_or_update", to: "topic_localizations#create_or_update"
delete "topic_localizations/destroy", to: "topic_localizations#destroy"
resources :bookmarks, only: %i[create destroy update] do
put "toggle_pin"

View File

@ -269,6 +269,7 @@ basic:
- bookmark
- admin
- reply
- addTranslation
area: "posts_and_topics"
post_menu_hidden_items:
client: true
@ -285,6 +286,7 @@ basic:
- bookmark
- admin
- reply
- addTranslation
area: "posts_and_topics"
share_links:
client: true
@ -3975,3 +3977,10 @@ experimental:
allow_any: false
client: true
default: "1|2" # admin, moderator
content_localization_debug_allowed_groups:
type: group_list
list_type: compact
allow_any: false
client: true
default: "1|2" # admin, moderator
hidden: true

View File

@ -93,6 +93,7 @@ module SvgSprite
discourse-sparkles
discourse-table
discourse-threads
discourse-add-translation
download
earth-americas
ellipsis

View File

@ -936,6 +936,7 @@ class TopicView
:deleted_by,
:incoming_email,
:image_upload,
:post_localizations,
)
@posts = @posts.includes({ user: :user_status }) if SiteSetting.enable_user_status

View File

@ -199,6 +199,14 @@ RSpec.describe "posts" do
reviewable_score_pending_count: {
type: :integer,
},
has_post_localizations: {
type: :boolean,
},
post_localizations: {
type: :array,
items: {
},
},
},
},
},

View File

@ -209,6 +209,13 @@
"post_url": {
"type": "string"
},
"has_post_localizations": {
"type": "boolean"
},
"post_localizations": {
"type": "array",
"items": {}
},
"mentioned_users": {
"type": "array",
"items": {}

View File

@ -211,6 +211,13 @@
"post_url": {
"type": "string"
},
"has_post_localizations": {
"type": "boolean"
},
"post_localizations": {
"type": "array",
"items": {}
},
"mentioned_users": {
"type": "array",
"items": {}

View File

@ -219,6 +219,13 @@
"post_url": {
"type": "string"
},
"has_post_localizations": {
"type": "boolean"
},
"post_localizations": {
"type": "array",
"items": {}
},
"mentioned_users": {
"type": "array",
"items": {}

View File

@ -15,77 +15,95 @@ describe PostLocalizationsController do
sign_in(user)
end
describe "#create" do
it "creates a new localization" do
expect {
post "/post_localizations.json",
params: {
post_id: post_record.id,
locale: locale,
raw: raw,
}
}.to change { PostLocalization.count }.by(1)
expect(response.status).to eq(201)
localization = PostLocalization.last
expect(localization.locale).to eq(locale)
expect(localization.raw).to eq(raw)
expect(localization.post_id).to eq(post_record.id)
expect(localization.post_version).to eq(post_record.version)
expect(localization.localizer_user_id).to eq(user.id)
describe "#create_or_update" do
context "when localization does not exist" do
it "creates a new localization" do
expect {
post "/post_localizations/create_or_update.json",
params: {
post_id: post_record.id,
locale: locale,
raw: raw,
}
}.to change { PostLocalization.count }.by(1)
expect(response.status).to eq(201)
localization = PostLocalization.last
expect(localization).to have_attributes(
locale: locale,
raw: raw,
post_id: post_record.id,
post_version: post_record.version,
localizer_user_id: user.id,
)
end
end
it "returns forbidden if user not in allowed group" do
context "when localization already exists" do
it "updates the existing localization" do
localization = Fabricate(:post_localization, post: post_record, locale: locale, raw: "古い翻訳")
new_user = Fabricate(:user, groups: [group])
sign_in(new_user)
expect {
post "/post_localizations/create_or_update.json",
params: {
post_id: post_record.id,
locale: locale,
raw: raw,
}
}.not_to change { PostLocalization.count }
expect(response.status).to eq(200)
localization.reload
expect(localization.raw).to eq(raw)
expect(localization.localizer_user_id).to eq(new_user.id)
end
end
it "returns forbidden if user is not in allowed group" do
group.remove(user)
post "/post_localizations.json", params: { post_id: post_record.id, locale: locale, raw: raw }
post "/post_localizations/create_or_update.json",
params: {
post_id: post_record.id,
locale: locale,
raw: raw,
}
expect(response.status).to eq(403)
end
it "returns not found if post does not exist" do
post "/post_localizations.json", params: { post_id: -1, locale: locale, raw: raw }
expect(response.status).to eq(404)
end
end
post "/post_localizations/create_or_update.json",
params: {
post_id: -1,
locale: locale,
raw: raw,
}
describe "#update" do
fab!(:post_localization) { Fabricate(:post_localization, post: post_record, locale: "ja") }
it "updates an existing localization" do
put "/post_localizations/#{post_localization.id}.json",
params: {
post_id: post_record.id,
locale: locale,
raw: raw,
}
expect(response.status).to eq(200)
expect(PostLocalization.last.raw).to eq(raw)
end
it "returns 404 if localization is missing" do
put "/post_localizations.json", params: { post_id: post_record.id, locale: "de", raw: "何か" }
expect(response.status).to eq(404)
end
end
describe "#destroy" do
fab!(:post_localization) { Fabricate(:post_localization, post: post_record, locale: "ja") }
it "destroys the localization" do
Fabricate(:post_localization, post: post_record, locale: locale)
expect {
delete "/post_localizations/#{post_localization.id}.json",
delete "/post_localizations/destroy.json",
params: {
post_id: post_record.id,
locale: locale,
}
}.to change { PostLocalization.count }.by(-1)
expect(response.status).to eq(204)
end
it "returns 404 if localization is missing" do
delete "/post_localizations/289127813837.json",
params: {
post_id: post_record.id,
locale: "nope",
}
delete "/post_localizations/destroy.json", params: { post_id: post_record.id, locale: "nope" }
expect(response.status).to eq(404)
end
end

View File

@ -15,67 +15,68 @@ describe TopicLocalizationsController do
sign_in(user)
end
describe "#create" do
it "creates a new localization" do
expect {
post "/topic_localizations.json", params: { topic_id: topic.id, locale:, title: }
}.to change { TopicLocalization.count }.by(1)
expect(response.status).to eq(201)
expect(TopicLocalization.last).to have_attributes(
locale:,
title:,
topic_id: topic.id,
localizer_user_id: user.id,
)
describe "#create_or_update" do
context "when localization does not exist" do
it "creates a new localization" do
expect {
post "/topic_localizations/create_or_update.json",
params: {
topic_id: topic.id,
locale:,
title:,
}
}.to change { TopicLocalization.count }.by(1)
expect(response.status).to eq(201)
expect(TopicLocalization.last).to have_attributes(
locale:,
title:,
topic_id: topic.id,
localizer_user_id: user.id,
)
end
end
context "when localization already exists" do
it "updates the existing localization" do
topic_localization =
Fabricate(:topic_localization, topic: topic, locale: locale, title: "Old title")
new_user = Fabricate(:user, groups: [group])
sign_in(new_user)
expect {
post "/topic_localizations/create_or_update.json",
params: {
topic_id: topic.id,
locale: locale,
title: title,
}
}.not_to change { TopicLocalization.count }
expect(response.status).to eq(200)
topic_localization.reload
expect(topic_localization).to have_attributes(
locale: locale,
title: title,
localizer_user_id: new_user.id,
)
end
end
it "returns forbidden if user not in allowed group" do
group.remove(user)
expect {
post "/topic_localizations.json", params: { topic_id: topic.id, locale:, title: }
post "/topic_localizations/create_or_update.json",
params: {
topic_id: topic.id,
locale:,
title:,
}
}.not_to change { TopicLocalization.count }
expect(response.status).to eq(403)
end
it "returns not found if topic does not exist" do
post "/topic_localizations.json", params: { topic_id: -1, locale:, title: }
expect(response.status).to eq(404)
end
end
describe "#update" do
fab!(:topic_localization) { Fabricate(:topic_localization, topic:, locale: "ja") }
it "updates an existing localization" do
new_user = Fabricate(:user, groups: [group])
sign_in(new_user)
put "/topic_localizations/#{topic_localization.id}.json",
params: {
topic_id: topic.id,
locale:,
title:,
}
expect(response.status).to eq(200)
topic_localization.reload
expect(topic_localization).to have_attributes(locale:, title:, localizer_user_id: new_user.id)
end
it "returns forbidden if user not in allowed group" do
group.remove(user)
expect {
put "/topic_localizations/#{topic_localization.id}.json",
params: {
topic_id: topic.id,
locale:,
title:,
}
}.not_to change { topic_localization }
expect(response.status).to eq(403)
end
it "returns not found if localization is missing" do
put "/topic_localizations.json", params: { topic_id: topic.id, locale: "de", title: "何か" }
post "/topic_localizations/create_or_update.json", params: { topic_id: -1, locale:, title: }
expect(response.status).to eq(404)
end
end
@ -85,11 +86,7 @@ describe TopicLocalizationsController do
it "destroys the localization" do
expect {
delete "/topic_localizations/#{topic_localization.id}.json",
params: {
topic_id: topic.id,
locale:,
}
delete "/topic_localizations/destroy.json", params: { topic_id: topic.id, locale: "ja" }
}.to change { TopicLocalization.count }.by(-1)
expect(response.status).to eq(204)
end
@ -97,18 +94,14 @@ describe TopicLocalizationsController do
it "returns forbidden if user not allowed" do
group.remove(user)
expect {
delete "/topic_localizations/#{topic_localization.id}.json",
params: {
topic_id: topic.id,
locale:,
}
delete "/topic_localizations/destroy.json", params: { topic_id: topic.id, locale: "ja" }
}.not_to change { TopicLocalization.count }
expect(response.status).to eq(403)
end
it "returns not found if localization is missing" do
expect {
delete "/topic_localizations/219873918.json", params: { topic_id: 219_873_918, locale: }
delete "/topic_localizations/destroy.json", params: { topic_id: -1, locale: "ja" }
}.not_to change { TopicLocalization.count }
expect(response.status).to eq(404)
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
describe PostLocalizationSerializer do
fab!(:post_localization)
describe "serialized attributes" do
it "disaplays every attribute" do
serialized = described_class.new(post_localization, scope: Guardian.new, root: false)
expect(serialized).to have_attributes(
id: post_localization.id,
post_id: post_localization.post_id,
locale: post_localization.locale,
raw: post_localization.raw,
)
end
end
end

View File

@ -56,6 +56,8 @@ RSpec.describe WebHookPostSerializer do
:topic_filtered_posts_count,
:topic_archetype,
:category_slug,
:has_post_localizations,
:post_localizations,
)
end

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
describe "Post translations", type: :system do
fab!(:user)
fab!(:topic)
fab!(:post) { Fabricate(:post, topic: topic, user: user) }
let(:topic_page) { PageObjects::Pages::Topic.new }
let(:composer) { PageObjects::Components::Composer.new }
let(:translation_selector) do
PageObjects::Components::SelectKit.new(".translation-selector-dropdown")
end
before do
sign_in(user)
SiteSetting.experimental_content_localization = true
SiteSetting.experimental_content_localization_allowed_groups = Group::AUTO_GROUPS[:everyone]
SiteSetting.post_menu =
"read|like|copyLink|flag|edit|bookmark|delete|admin|reply|addTranslation"
end
it "allows a user to translate a post" do
topic_page.visit_topic(topic)
find("#post_#{post.post_number} .post-action-menu__add-translation").click
expect(composer).to be_opened
translation_selector.expand
translation_selector.select_row_by_value("fr")
find("#translated-topic-title").fill_in(with: "Ceci est un sujet de test 0")
composer.fill_content("Bonjour le monde")
composer.submit
post.reload
topic.reload
try_until_success do
expect(TopicLocalization.exists?(topic_id: topic.id, locale: "fr")).to be true
expect(PostLocalization.exists?(post_id: post.id, locale: "fr")).to be true
expect(PostLocalization.find_by(post_id: post.id, locale: "fr").raw).to eq("Bonjour le monde")
expect(TopicLocalization.find_by(topic_id: topic.id, locale: "fr").title).to eq(
"Ceci est un sujet de test 0",
)
end
end
end

View File

@ -92,4 +92,7 @@ Additional SVG icons
<path d="M256.483 512C247.794 512 240.071 509.107 234.278 503.321L79.8128 349.026C67.2624 337.454 67.2624 317.203 79.8128 305.631C91.3977 293.094 111.671 293.094 123.256 305.631L256.483 437.746L388.744 305.631C400.329 293.094 420.602 293.094 432.187 305.631C444.738 317.203 444.738 337.454 432.187 349.026L277.722 503.321C271.929 509.107 264.206 512 256.483 512Z"/>
<path d="M256.483 0C247.794 0 240.071 2.89303 234.278 8.67907L79.8128 162.974C67.2624 174.546 67.2624 194.797 79.8128 206.369C91.3977 218.906 111.671 218.906 123.256 206.369L256.483 74.2543L388.744 206.369C400.329 218.906 420.602 218.906 432.187 206.369C444.738 194.797 444.738 174.546 432.187 162.974L277.722 8.67907C271.929 2.89303 264.206 0 256.483 0Z"/>
</symbol>
<symbol id="discourse-add-translation" viewBox="0 0 574 520">
<clipPath id="a"><path d="m0 0h574v520h-574z"/></clipPath><g clip-path="url(#a)"><path d="m316.376 352c-4.141 13.095-6.376 27.036-6.376 41.5 0 17.3 3.195 33.853 9.027 49.103-.571 1.347-1.145 2.68-1.727 3.997-10.5 23.6-22.2 40.7-33.5 51.5-11.2 10.7-20.5 13.9-27.8 13.9s-16.6-3.2-27.8-13.8c-11.3-10.8-23-27.9-33.5-51.5v-.1c-11.6-26-20.9-58.2-27-94.6zm-181.076 0c10 63.9 29.8 117.4 55.3 151.6-78.4-20.7-142.0005-77.5-172.0004-151.6zm207.726 130.895c3.531 4.122 7.301 8.034 11.29 11.712-8.429 2.498-19.057 5.355-32.816 8.993 8.972-12.033 15.889-16.799 21.526-20.705zm-211.826-290.895c-2.1 20.6-3.2 42-3.2 64s1.1 43.4 3.2 64h-123.10039c-5.29998-20.5-8.09961-41.9-8.09961-64s2.79963-43.5 8.09961-64zm217.5 0c2.2 20.4 3.3 41.8 3.3 64 0 13.593-.452 26.886-1.283 39.833-7.348 7.282-13.878 15.388-19.442 24.167h-167.875c-2.2-20.4-3.3-41.8-3.3-64s1.2-43.6 3.3-64zm98.586 127.438c1.283 0 2.52.197 3.681.562h-7.362c1.162-.365 2.398-.562 3.681-.562zm56.614-127.438c5.3 20.5 8.1 41.9 8.1 64 0 5.302-.163 10.563-.481 15.781-19.121-10.077-40.904-15.781-64.019-15.781-22.983 0-44.648 5.64-63.688 15.609.123-5.167.188-10.371.188-15.609 0-22-1.1-43.4-3.2-64zm-313.3-183.59961c-25.5 34.20001-45.3 87.69991-55.3 151.59961h-116.7004c29.9999-74.0999 93.6004-130.8995 172.0004-151.59961zm65.4-8.40039c7.3 0 16.6 3.19995 27.8 13.7998 11.3 10.8 23 27.9001 33.5 51.5 11.6 26 20.9 58.2002 27 94.7002h-176.6c6.1-36.4 15.5-68.6002 27-94.7002 10.5-23.5999 22.2-40.7 33.5-51.5 11.2-10.59984 20.5-13.7998 27.8-13.7998zm65.4 8.40039c78.3 20.70011 142 77.49981 171.9 151.59961h-116.6c-10-63.8997-29.8-117.3996-55.3-151.59961z"/><path d="m447.5 267c69.864 0 126.5 56.636 126.5 126.5s-56.636 126.5-126.5 126.5-126.5-56.636-126.5-126.5 56.636-126.5 126.5-126.5zm-.197 58.362c-6.224 0-11.252 5.029-11.252 11.253v50.635h-50.636c-6.224 0-11.252 5.029-11.252 11.253s5.028 11.252 11.252 11.252h50.636v50.636c0 6.223 5.028 11.252 11.252 11.252s11.251-5.029 11.252-11.252v-50.636h50.635c6.224 0 11.252-5.028 11.252-11.252s-5.028-11.253-11.252-11.253h-50.635v-50.635c0-6.224-5.028-11.253-11.252-11.253z"/></g>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB