diff --git a/public/language/en-GB/search.json b/public/language/en-GB/search.json index b404ae1fc8..ca4397b1fd 100644 --- a/public/language/en-GB/search.json +++ b/public/language/en-GB/search.json @@ -105,6 +105,10 @@ "show-results-as": "Show results as", "show-results-as-topics": "Show results as topics", "show-results-as-posts": "Show results as posts", + "translate-text": "Translate text", + "translation-loading": "Translating...", + "detected-language": "Detected language: %1", + "translation-unavailable": "Translation unavailable right now. Showing original text.", "see-more-results": "See more results (%1)", "search-in-category": "Search in \"%1\"" } diff --git a/public/language/en-US/search.json b/public/language/en-US/search.json index 0e8f179e87..9a1fc4571d 100644 --- a/public/language/en-US/search.json +++ b/public/language/en-US/search.json @@ -105,6 +105,10 @@ "show-results-as": "Show results as", "show-results-as-topics": "Show results as topics", "show-results-as-posts": "Show results as posts", + "translate-text": "Translate text", + "translation-loading": "Translating...", + "detected-language": "Detected language: %1", + "translation-unavailable": "Translation unavailable right now. Showing original text.", "see-more-results": "See more results (%1)", "search-in-category": "Search in \"%1\"" } \ No newline at end of file diff --git a/public/language/en-x-pirate/search.json b/public/language/en-x-pirate/search.json index 0e8f179e87..9a1fc4571d 100644 --- a/public/language/en-x-pirate/search.json +++ b/public/language/en-x-pirate/search.json @@ -105,6 +105,10 @@ "show-results-as": "Show results as", "show-results-as-topics": "Show results as topics", "show-results-as-posts": "Show results as posts", + "translate-text": "Translate text", + "translation-loading": "Translating...", + "detected-language": "Detected language: %1", + "translation-unavailable": "Translation unavailable right now. Showing original text.", "see-more-results": "See more results (%1)", "search-in-category": "Search in \"%1\"" } \ No newline at end of file diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index dfef0aa0cf..07062ac6a8 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -178,6 +178,8 @@ paths: $ref: 'write/posts/pid/index.yaml' /posts/{pid}/raw: $ref: 'write/posts/pid/raw.yaml' + /posts/{pid}/translate: + $ref: 'write/posts/pid/translate.yaml' /posts/{pid}/summary: $ref: 'write/posts/pid/summary.yaml' /posts/{pid}/state: diff --git a/public/openapi/write/posts/pid/translate.yaml b/public/openapi/write/posts/pid/translate.yaml new file mode 100644 index 0000000000..f31ce331e5 --- /dev/null +++ b/public/openapi/write/posts/pid/translate.yaml @@ -0,0 +1,47 @@ +get: + tags: + - posts + summary: translate post content to English + description: | + This operation requests translation metadata for a post. + + The response includes detected source language, target language, + translated text, and whether translation was applied. + parameters: + - in: path + name: pid + schema: + type: string + required: true + description: a valid post id + example: 2 + responses: + '200': + description: Post translation data successfully retrieved. + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + pid: + type: string + description: post id + sourceLanguage: + type: string + description: detected source language + example: Non-English + targetLanguage: + type: string + description: translation target language + example: English + translatedText: + type: string + description: translated text (or original text on fallback) + wasTranslated: + type: boolean + description: whether translation was applied diff --git a/public/src/client/topic/postTools.js b/public/src/client/topic/postTools.js index 640d936f16..347d5a03c6 100644 --- a/public/src/client/topic/postTools.js +++ b/public/src/client/topic/postTools.js @@ -289,6 +289,61 @@ define('forum/topic/postTools', [ postContainer.on('click', '[component="post/chat"]', function () { openChat($(this)); }); + + postContainer.on('click', '[component="post/translate"]', async function (e) { + e.preventDefault(); + await translatePost($(this)); + }); + } + + async function translatePost(button) { + const postEl = button.parents('[data-pid]'); + const pid = postEl.attr('data-pid'); + const container = postEl.find('[component="post/translation/container"]'); + const metaEl = postEl.find('[component="post/translation/meta"]'); + const textEl = postEl.find('[component="post/translation/text"]'); + + if (!pid || !container.length) { + return; + } + + if (container.attr('data-loaded') === '1') { + container.toggleClass('hidden'); + return; + } + + if (button.attr('data-loading') === '1') { + return; + } + + try { + button.attr('data-loading', '1'); + const loadingText = await translator.translate('[[search:translation-loading]]'); + metaEl.text(loadingText); + textEl.text(''); + container.removeClass('hidden'); + + const result = await api.get(`/posts/${encodeURIComponent(pid)}/translate`); + const language = result && result.sourceLanguage ? result.sourceLanguage : 'Unknown'; + const translated = result && result.translatedText ? result.translatedText : ''; + const didTranslate = !!(result && result.wasTranslated); + + const languageText = await translator.translate(`[[search:detected-language, ${language}]]`); + metaEl.text(languageText); + + if (didTranslate || translated) { + textEl.text(translated || ''); + } else { + textEl.text(await translator.translate('[[search:translation-unavailable]]')); + } + + container.attr('data-loaded', '1'); + } catch (err) { + container.addClass('hidden'); + alerts.error(err); + } finally { + button.removeAttr('data-loading'); + } } async function onReplyClicked(button, tid) { diff --git a/src/api/posts.js b/src/api/posts.js index c9a570dec4..cb64080fd0 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -85,6 +85,53 @@ postsAPI.getRaw = async (caller, { pid }) => { return result.postData.content; }; +postsAPI.getTranslation = async (caller, { pid, targetLanguage = 'English' }) => { + const userPrivileges = await privileges.posts.get([pid], caller.uid); + const userPrivilege = userPrivileges[0]; + if (!userPrivilege || !userPrivilege['topics:read']) { + return null; + } + + const postData = await posts.getPostFields(pid, ['content', 'deleted', 'uid']); + const selfPost = caller.uid && caller.uid === parseInt(postData.uid, 10); + if (postData.deleted && !(userPrivilege.isAdminOrMod || selfPost)) { + return null; + } + + const sourceText = String(postData.content || '').trim(); + const translatorUrl = (process.env.TRANSLATOR_URL || 'http://localhost:5001').replace(/\/+$/, ''); + const timeoutMs = parseInt(process.env.LLM_TRANSLATE_TIMEOUT_MS, 10) || 5000; + try { + const url = `${translatorUrl}/?content=${encodeURIComponent(sourceText)}`; + const response = await fetch(url, { + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + throw new Error(`Translator service failed with status ${response.status}`); + } + const data = await response.json(); + const isEnglish = !!data.is_english; + const translatedText = String(data.translated_content || sourceText); + const sourceLanguage = isEnglish ? 'English' : 'Non-English'; + + return { + pid: pid, + sourceLanguage: sourceLanguage, + targetLanguage: targetLanguage, + translatedText: translatedText, + wasTranslated: !isEnglish, + }; + } catch (err) { + return { + pid: pid, + sourceLanguage: 'Unknown', + targetLanguage: targetLanguage, + translatedText: sourceText, + wasTranslated: false, + }; + } +}; + postsAPI.edit = async function (caller, data) { if (!data || !data.pid || (meta.config.minimumPostLength !== 0 && !data.content)) { throw new Error('[[error:invalid-data]]'); diff --git a/src/controllers/write/posts.js b/src/controllers/write/posts.js index 9e8053d17d..611cbf3edd 100644 --- a/src/controllers/write/posts.js +++ b/src/controllers/write/posts.js @@ -74,6 +74,18 @@ Posts.getRaw = async (req, res) => { helpers.formatApiResponse(200, res, { content }); }; +Posts.getTranslation = async (req, res) => { + const translation = await api.posts.getTranslation(req, { + pid: req.params.pid, + targetLanguage: 'English', + }); + if (translation === null) { + return helpers.formatApiResponse(404, res, new Error('[[error:no-post]]')); + } + + helpers.formatApiResponse(200, res, translation); +}; + Posts.edit = async (req, res) => { const editResult = await api.posts.edit(req, { ...req.body, diff --git a/src/routes/write/posts.js b/src/routes/write/posts.js index ed2c372461..dec1d09edd 100644 --- a/src/routes/write/posts.js +++ b/src/routes/write/posts.js @@ -17,6 +17,7 @@ module.exports = function () { setupApiRoute(router, 'get', '/:pid/index', [middleware.assert.post], controllers.write.posts.getIndex); setupApiRoute(router, 'get', '/:pid/raw', [middleware.assert.post], controllers.write.posts.getRaw); + setupApiRoute(router, 'get', '/:pid/translate', [middleware.assert.post], controllers.write.posts.getTranslation); setupApiRoute(router, 'get', '/:pid/summary', [middleware.assert.post], controllers.write.posts.getSummary); setupApiRoute(router, 'put', '/:pid/state', middlewares, controllers.write.posts.restore); diff --git a/vendor/nodebb-theme-harmony-2.1.35/templates/partials/topic/post.tpl b/vendor/nodebb-theme-harmony-2.1.35/templates/partials/topic/post.tpl index 0ba7023ae7..16486348db 100644 --- a/vendor/nodebb-theme-harmony-2.1.35/templates/partials/topic/post.tpl +++ b/vendor/nodebb-theme-harmony-2.1.35/templates/partials/topic/post.tpl @@ -83,6 +83,13 @@
{posts.content}
+
+ + +
{{{ if posts.user.signature }}} diff --git a/vendor/nodebb-theme-harmony-main/templates/partials/topic/post.tpl b/vendor/nodebb-theme-harmony-main/templates/partials/topic/post.tpl index 0ba7023ae7..16486348db 100644 --- a/vendor/nodebb-theme-harmony-main/templates/partials/topic/post.tpl +++ b/vendor/nodebb-theme-harmony-main/templates/partials/topic/post.tpl @@ -83,6 +83,13 @@
{posts.content}
+
+ + +
{{{ if posts.user.signature }}}