Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions public/openapi/components/schemas/PostObject.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ PostObject:
type: boolean
translatedContent:
type: string
isTranslating:
type: boolean
sourceContent:
type: string
nullable: true
Expand Down Expand Up @@ -214,6 +216,8 @@ PostDataObject:
type: boolean
translatedContent:
type: string
isTranslating:
type: boolean
timestamp:
type: number
anonymous:
Expand Down
2 changes: 2 additions & 0 deletions public/openapi/components/schemas/TopicObject.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ TopicObject:
type: number
isEnglish:
type: boolean
isTranslating:
type: boolean
nullable: true
tags:
type: array
Expand Down
6 changes: 5 additions & 1 deletion public/openapi/read/categories.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -178,7 +180,9 @@ get:
isEnglish:
type: boolean
translatedContent:
type: string
type: string
isTranslating:
type: boolean
sourceContent:
type: string
nullable: true
Expand Down
6 changes: 5 additions & 1 deletion public/openapi/read/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -181,7 +183,9 @@ get:
isEnglish:
type: boolean
translatedContent:
type: string
type: string
isTranslating:
type: boolean
sourceContent:
type: string
nullable: true
Expand Down
2 changes: 2 additions & 0 deletions public/openapi/read/unread.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ get:
type: number
isEnglish:
type: boolean
isTranslating:
type: boolean
tags:
type: array
items:
Expand Down
2 changes: 2 additions & 0 deletions public/openapi/write/posts/pid.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ get:
type: boolean
translatedContent:
type: string
isTranslating:
type: boolean
timestamp:
type: number
flagId:
Expand Down
31 changes: 31 additions & 0 deletions public/src/client/topic/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ define('forum/topic/events', [

'event:new_notification': onNewNotification,
'event:new_post': posts.onNewPost,
'event:post_translated': onPostTranslated,
};

Events.init = function () {
Expand Down Expand Up @@ -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 = $('<div class="sensitive-content-message"></div>');
var btn = $('<a class="btn btn-sm btn-primary view-translated-btn"></a>').text('Click here to view the translated message.');
msgDiv.append(btn);

var transDiv = $('<div class="translated-content" style="display:none;"></div>').text(data.translatedContent);
Comment thread
cirex-web marked this conversation as resolved.

contentEl.append(msgDiv).append(transDiv);
}
}

function onNewNotification(data) {
const tid = ajaxify.data.tid;
if (data && data.tid && String(data.tid) === String(tid)) {
Expand Down
22 changes: 18 additions & 4 deletions src/posts/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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]]');
}
Expand All @@ -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;
Expand Down Expand Up @@ -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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error handling good

});

const topicData = await topics.getTopicFields(tid, ['cid', 'pinned']);
postData.cid = topicData.cid;
await Promise.all([
Expand Down
8 changes: 7 additions & 1 deletion src/posts/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down
4 changes: 3 additions & 1 deletion src/posts/summary.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand All @@ -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);
Expand Down
21 changes: 16 additions & 5 deletions src/translate/index.js
Original file line number Diff line number Diff line change
@@ -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', ''];
}
};
64 changes: 64 additions & 0 deletions test/translate.js
Original file line number Diff line number Diff line change
@@ -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', '']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,20 @@

<div class="content text-break" component="post/content" itemprop="text">
{posts.content}
{{{if !posts.isEnglish }}}
{{{if posts.isTranslating }}}
<div class="sensitive-content-message translation-loading">
<i class="fa fa-spinner fa-spin"></i> Translation in progress...
</div>
<div class="translated-content" style="display:none;"></div>
{{{end}}}
{{{if (!posts.isEnglish && !posts.isTranslating) }}}
<div class="sensitive-content-message">
<a class="btn btn-sm btn-primary view-translated-btn">Click here to view the translated message.</a>
<a class="btn btn-sm btn-primary view-translated-btn">Click here to view the translated message.</a>
</div>
<div class="translated-content" style="display:none;">
{posts.translatedContent}
{posts.translatedContent}
</div>
{{{end}}}
{{{end}}}
</div>

<div component="post/footer" class="post-footer border-bottom pb-2">
Expand Down
Loading