From bf1136b453c7628e7b48a001d0c192ab00e45c04 Mon Sep 17 00:00:00 2001 From: anthony-tom Date: Thu, 26 Mar 2026 23:49:54 -0400 Subject: [PATCH 1/2] feat(posts): add per-post Translate text button backed by translator microservice Adds a translate action under each topic post and a new GET /api/v3/posts/:pid/translate backend route that calls the external translator service (TRANSLATOR_URL) with timeout and safe fallback to original text when unavailable. --- public/language/en-GB/search.json | 4 ++ public/language/en-US/search.json | 4 ++ public/language/en-x-pirate/search.json | 4 ++ public/src/client/topic/postTools.js | 55 +++++++++++++++++++ src/api/posts.js | 47 ++++++++++++++++ src/controllers/write/posts.js | 12 ++++ src/routes/write/posts.js | 1 + .../templates/partials/topic/post.tpl | 7 +++ .../templates/partials/topic/post.tpl | 7 +++ 9 files changed, 141 insertions(+) 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/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}
+
+ + +