diff --git a/public/openapi/components/schemas/PostObject.yaml b/public/openapi/components/schemas/PostObject.yaml index 4da9fe96a3..bcc5c4f843 100644 --- a/public/openapi/components/schemas/PostObject.yaml +++ b/public/openapi/components/schemas/PostObject.yaml @@ -35,6 +35,8 @@ PostObject: type: boolean translatedContent: type: string + isTranslating: + type: boolean sourceContent: type: string nullable: true @@ -214,6 +216,8 @@ PostDataObject: type: boolean translatedContent: type: string + isTranslating: + type: boolean timestamp: type: number anonymous: diff --git a/public/openapi/components/schemas/TopicObject.yaml b/public/openapi/components/schemas/TopicObject.yaml index 05ccf82e6f..fbb662f57c 100644 --- a/public/openapi/components/schemas/TopicObject.yaml +++ b/public/openapi/components/schemas/TopicObject.yaml @@ -175,6 +175,8 @@ TopicObject: type: number isEnglish: type: boolean + isTranslating: + type: boolean nullable: true tags: type: array diff --git a/public/openapi/read/categories.yaml b/public/openapi/read/categories.yaml index f2369a29d6..36bd6abec3 100644 --- a/public/openapi/read/categories.yaml +++ b/public/openapi/read/categories.yaml @@ -99,6 +99,8 @@ get: type: boolean translatedContent: type: string + isTranslating: + type: boolean timestampISO: type: string description: An ISO 8601 formatted date string (complementing `timestamp`) @@ -178,7 +180,9 @@ get: isEnglish: type: boolean translatedContent: - type: string + type: string + isTranslating: + type: boolean sourceContent: type: string nullable: true diff --git a/public/openapi/read/index.yaml b/public/openapi/read/index.yaml index 8b58c6c21c..61db3ed079 100644 --- a/public/openapi/read/index.yaml +++ b/public/openapi/read/index.yaml @@ -81,6 +81,8 @@ get: type: boolean translatedContent: type: string + isTranslating: + type: boolean timestampISO: type: string description: An ISO 8601 formatted date string (complementing `timestamp`) @@ -181,7 +183,9 @@ get: isEnglish: type: boolean translatedContent: - type: string + type: string + isTranslating: + type: boolean sourceContent: type: string nullable: true diff --git a/public/openapi/read/unread.yaml b/public/openapi/read/unread.yaml index fff455fc15..9feacacd9a 100644 --- a/public/openapi/read/unread.yaml +++ b/public/openapi/read/unread.yaml @@ -207,6 +207,8 @@ get: type: number isEnglish: type: boolean + isTranslating: + type: boolean tags: type: array items: diff --git a/public/openapi/write/posts/pid.yaml b/public/openapi/write/posts/pid.yaml index c1c572e340..cde84e803a 100644 --- a/public/openapi/write/posts/pid.yaml +++ b/public/openapi/write/posts/pid.yaml @@ -39,6 +39,8 @@ get: type: boolean translatedContent: type: string + isTranslating: + type: boolean timestamp: type: number flagId: diff --git a/public/src/client/topic/events.js b/public/src/client/topic/events.js index 1df0db3464..783fc66f68 100644 --- a/public/src/client/topic/events.js +++ b/public/src/client/topic/events.js @@ -47,6 +47,7 @@ define('forum/topic/events', [ 'event:new_notification': onNewNotification, 'event:new_post': posts.onNewPost, + 'event:post_translated': onPostTranslated, }; Events.init = function () { @@ -272,6 +273,36 @@ define('forum/topic/events', [ }).toggleClass('downvoted', data.downvote); } + function onPostTranslated(data) { + var postEl = $('[component="post"][data-pid="' + data.pid + '"]'); + if (!postEl.length) { + return; + } + + var contentEl = postEl.find('[component="post/content"]'); + // Remove loading spinner + contentEl.find('.translation-loading').remove(); + + if (data.isEnglish === 'true' || data.isEnglish === true) { + // English — remove any translation UI + contentEl.find('.sensitive-content-message').remove(); + contentEl.find('.translated-content').remove(); + } else { + // Not English — show toggle button and populate translation + contentEl.find('.sensitive-content-message').remove(); + contentEl.find('.translated-content').remove(); + + // Build elements safely using jQuery to prevent XSS + var msgDiv = $('
'); + var btn = $('').text('Click here to view the translated message.'); + msgDiv.append(btn); + + var transDiv = $('').text(data.translatedContent); + + contentEl.append(msgDiv).append(transDiv); + } + } + function onNewNotification(data) { const tid = ajaxify.data.tid; if (data && data.tid && String(data.tid) === String(tid)) { diff --git a/src/posts/create.js b/src/posts/create.js index 2e6caa4fbc..80789967ff 100644 --- a/src/posts/create.js +++ b/src/posts/create.js @@ -10,6 +10,7 @@ const groups = require('../groups'); const activitypub = require('../activitypub'); const utils = require('../utils'); const translate = require('../translate'); +const websockets = require('../socket.io'); module.exports = function (Posts) { Posts.create = async function (data) { @@ -19,9 +20,6 @@ module.exports = function (Posts) { const timestamp = data.timestamp || Date.now(); const isMain = data.isMain || false; let hasAttachment = false; - const [isEnglish, translatedContent] = await translate.translate(data); - - if (!uid && parseInt(uid, 10) !== 0) { throw new Error('[[error:invalid-uid]]'); } @@ -31,7 +29,7 @@ module.exports = function (Posts) { } const pid = data.pid || await db.incrObjectField('global', 'nextPid'); - let postData = { pid, uid, tid, content, sourceContent, timestamp, isEnglish, translatedContent}; + let postData = { pid, uid, tid, content, sourceContent, timestamp, isEnglish: 'loading', translatedContent: '', isTranslating: true }; if (data.toPid) { postData.toPid = data.toPid; @@ -75,6 +73,22 @@ module.exports = function (Posts) { ({ post: postData } = await plugins.hooks.fire('filter:post.create', { post: postData, data: data })); await db.setObject(`post:${postData.pid}`, postData); + // Fire-and-forget translation + const savedPid = postData.pid; + const savedTid = tid; + translate.translate(postData).then(([isEng, transContent]) => { + Posts.setPostFields(savedPid, { isEnglish: isEng, translatedContent: transContent, isTranslating: false }); + if (websockets.server) { + websockets.in(`topic_${savedTid}`).emit('event:post_translated', { + pid: savedPid, + isEnglish: isEng, + translatedContent: transContent, + }); + } + }).catch(() => { + Posts.setPostFields(savedPid, { isEnglish: 'true', translatedContent: '', isTranslating: false }); + }); + const topicData = await topics.getTopicFields(tid, ['cid', 'pinned']); postData.cid = topicData.cid; await Promise.all([ diff --git a/src/posts/data.js b/src/posts/data.js index bb47359722..ef95603c3b 100644 --- a/src/posts/data.js +++ b/src/posts/data.js @@ -101,7 +101,13 @@ function modifyPost(post, fields) { } } // Mark post as "English" if decided by translator service or if it has no info - post.isEnglish = post.isEnglish == 'true' || post.isEnglish === undefined; + if (post.isEnglish === 'loading') { + // keep as 'loading' — translation in progress + } else { + post.isEnglish = post.isEnglish == 'true' || post.isEnglish === undefined; + } + // Ensure isTranslating is a boolean (stored as string in DB) + post.isTranslating = post.isTranslating === true || post.isTranslating === 'true'; // If translatedContent is undefined, default to empty string (no translation needed for English posts) if (post.translatedContent === undefined) { post.translatedContent = ''; diff --git a/src/posts/summary.js b/src/posts/summary.js index 508caf9940..f73035266c 100644 --- a/src/posts/summary.js +++ b/src/posts/summary.js @@ -12,6 +12,8 @@ const categories = require('../categories'); const utils = require('../utils'); module.exports = function (Posts) { + // called when you reply to a post (Topics.reply -> onNewPost -> getPostSummaryByPids) + // among the other billion pipelines Posts.getPostSummaryByPids = async function (pids, uid, options) { if (!Array.isArray(pids) || !pids.length) { return []; @@ -22,7 +24,7 @@ module.exports = function (Posts) { options.escape = options.hasOwnProperty('escape') ? options.escape : false; options.extraFields = options.hasOwnProperty('extraFields') ? options.extraFields : []; - const fields = ['pid', 'tid', 'toPid', 'url', 'content', 'sourceContent', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle', 'anonymous', 'isEnglish', 'translatedContent'].concat(options.extraFields); + const fields = ['pid', 'tid', 'toPid', 'url', 'content', 'sourceContent', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle', 'anonymous', 'isEnglish', 'translatedContent', 'isTranslating'].concat(options.extraFields); let posts = await Posts.getPostsFields(pids, fields); posts = posts.filter(Boolean); diff --git a/src/translate/index.js b/src/translate/index.js index 4fcaf78128..cd45487f32 100644 --- a/src/translate/index.js +++ b/src/translate/index.js @@ -1,9 +1,20 @@ - -/* eslint-disable strict */ -//var request = require('request'); +'use strict'; +const nconf = require('nconf'); const translatorApi = module.exports; -translatorApi.translate = function (postData) { - return ['is_english',postData]; +const TRANSLATOR_URL = nconf.get('llm_endpoint') || 'http://localhost:5000'; + +translatorApi.translate = async function (postData) { + try { + const response = await fetch( + `${TRANSLATOR_URL}/?content=${encodeURIComponent(postData.content)}` + ); + const data = await response.json(); + const isEnglish = data.is_english ? 'true' : 'false'; + const translatedContent = data.translated_content || ''; + return [isEnglish, translatedContent]; + } catch (err) { + return ['true', '']; + } }; diff --git a/test/translate.js b/test/translate.js new file mode 100644 index 0000000000..8f3358a0a6 --- /dev/null +++ b/test/translate.js @@ -0,0 +1,64 @@ +'use strict'; + +const assert = require('assert'); + +const db = require('./mocks/databasemock'); + +describe('Translate', () => { + let originalFetch; + + beforeEach(() => { + originalFetch = global.fetch; + // Clear the module cache so each test gets a fresh require + delete require.cache[require.resolve('../src/translate/index')]; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('should return [true, ""] when fetch throws a network error', async () => { + global.fetch = async () => { + throw new Error('network error'); + }; + const translate = require('../src/translate/index'); + const result = await translate.translate({ content: 'hello world' }); + assert.deepStrictEqual(result, ['true', '']); + }); + + it('should return [false, translated text] for non-English response', async () => { + global.fetch = async () => ({ + json: async () => ({ is_english: false, translated_content: 'hello world' }), + }); + const translate = require('../src/translate/index'); + const result = await translate.translate({ content: 'hola mundo' }); + assert.deepStrictEqual(result, ['false', 'hello world']); + }); + + it('should return [true, ""] for English response', async () => { + global.fetch = async () => ({ + json: async () => ({ is_english: true, translated_content: '' }), + }); + const translate = require('../src/translate/index'); + const result = await translate.translate({ content: 'hello world' }); + assert.deepStrictEqual(result, ['true', '']); + }); + + it('should return [true, ""] for emoji content with English response', async () => { + global.fetch = async () => ({ + json: async () => ({ is_english: true, translated_content: '' }), + }); + const translate = require('../src/translate/index'); + const result = await translate.translate({ content: '😀🎉' }); + assert.deepStrictEqual(result, ['true', '']); + }); + + it('should return [true, ""] when response contains malformed JSON', async () => { + global.fetch = async () => ({ + json: async () => { throw new SyntaxError('Unexpected token'); }, + }); + const translate = require('../src/translate/index'); + const result = await translate.translate({ content: 'hello world' }); + assert.deepStrictEqual(result, ['true', '']); + }); +}); 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 87192eea4f..778ca37039 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,14 +83,20 @@