Merge remote-tracking branch 'origin/main' into entrega/directiva-20261110
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -26,13 +26,27 @@ class TranslationApiController extends Controller
|
|||||||
public function translate(Request $request): JsonResponse
|
public function translate(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'text' => 'required|string|max:10000',
|
'text' => is_array($request->input('text'))
|
||||||
|
? 'required|array|min:1|max:8'
|
||||||
|
: 'required|string|max:10000',
|
||||||
|
'text.*' => 'required|string|max:10000',
|
||||||
'source_lang' => 'required|string|max:20',
|
'source_lang' => 'required|string|max:20',
|
||||||
'target_lang' => 'required|string|max:20',
|
'target_lang' => 'required|string|max:20',
|
||||||
'is_html' => 'boolean',
|
'is_html' => 'boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$isBatch = is_array($data['text']);
|
||||||
|
if ($isBatch && mb_strlen(implode('', $data['text'])) > 10000) {
|
||||||
|
return response()->json(['message' => 'O lote de tradução excede o limite permitido.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
if ($data['source_lang'] === $data['target_lang']) {
|
if ($data['source_lang'] === $data['target_lang']) {
|
||||||
return response()->json(['original_text' => $data['text'], 'translated_text' => $data['text'], 'source_lang' => $data['source_lang'], 'target_lang' => $data['target_lang']])->header('Cache-Control', 'no-store');
|
return response()->json([
|
||||||
|
'original_text' => $data['text'],
|
||||||
|
'translated_text' => $data['text'],
|
||||||
|
'source_lang' => $data['source_lang'],
|
||||||
|
'target_lang' => $data['target_lang'],
|
||||||
|
])->header('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = ['q' => $data['text'], 'source' => $data['source_lang'], 'target' => $data['target_lang'], 'format' => ($data['is_html'] ?? false) ? 'html' : 'text'];
|
$payload = ['q' => $data['text'], 'source' => $data['source_lang'], 'target' => $data['target_lang'], 'format' => ($data['is_html'] ?? false) ? 'html' : 'text'];
|
||||||
@@ -42,8 +56,19 @@ class TranslationApiController extends Controller
|
|||||||
try {
|
try {
|
||||||
$response = Http::connectTimeout(2)->timeout(config('services.translation.timeout'))->post($this->url().'/translate', $payload);
|
$response = Http::connectTimeout(2)->timeout(config('services.translation.timeout'))->post($this->url().'/translate', $payload);
|
||||||
$translated = $response->json('translatedText');
|
$translated = $response->json('translatedText');
|
||||||
if ($response->successful() && is_string($translated) && $translated !== '') {
|
$validTranslation = $isBatch
|
||||||
return response()->json(['original_text' => $data['text'], 'translated_text' => $translated, 'source_lang' => $data['source_lang'], 'target_lang' => $data['target_lang']])->header('Cache-Control', 'no-store');
|
? is_array($translated)
|
||||||
|
&& count($translated) === count($data['text'])
|
||||||
|
&& ! array_filter($translated, fn ($text) => ! is_string($text) || $text === '')
|
||||||
|
: is_string($translated) && $translated !== '';
|
||||||
|
|
||||||
|
if ($response->successful() && $validTranslation) {
|
||||||
|
return response()->json([
|
||||||
|
'original_text' => $data['text'],
|
||||||
|
'translated_text' => $translated,
|
||||||
|
'source_lang' => $data['source_lang'],
|
||||||
|
'target_lang' => $data['target_lang'],
|
||||||
|
])->header('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Presentation choice for Tempo's 21/10/2026 request: one shared navigation. */
|
/* Presentation choice for Tempo's 21/10/2026 request: one shared navigation. */
|
||||||
.tempo-header {
|
.tempo-header {
|
||||||
position: relative;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
background: #090909;
|
background: #090909;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ function PortalNavigationContent({ children }: { children: ReactNode }) {
|
|||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
{
|
{
|
||||||
<div className={printable ? 'print:hidden' : undefined}>
|
<div className={printable ? 'hidden' : 'contents'}>
|
||||||
<PortalMenu primary />
|
<PortalMenu primary />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTempoUi } from '@/components/tempo-brand';
|
import { useTempoUi } from '@/components/tempo-brand';
|
||||||
import { translateText } from '@/translation/service';
|
import { TranslationRequestError, translateTexts } from '@/translation/service';
|
||||||
import { protectTranslationTokens, restoreTranslationTokens } from '@/translation/utils';
|
import { protectTranslationTokens, restoreTranslationTokens } from '@/translation/utils';
|
||||||
|
|
||||||
// Never change the structure of React's DOM or the value of a form control.
|
// Never change the structure of React's DOM or the value of a form control.
|
||||||
const excluded = 'script,style,noscript,code,pre,textarea,[contenteditable]:not([contenteditable="false"]),[translate="no"],[data-no-translate],.no-translate';
|
const excluded = 'script,style,noscript,code,pre,textarea,[contenteditable]:not([contenteditable="false"]),[translate="no"],[data-no-translate],.no-translate';
|
||||||
const attributes = ['placeholder', 'title', 'aria-label', 'alt'] as const;
|
const attributes = ['placeholder', 'title', 'aria-label', 'alt'] as const;
|
||||||
type Target = { node: Text | Element; attribute?: string; original: string; translated?: string };
|
type Target = { node: Text | Element; attribute?: string; original: string; translated?: string };
|
||||||
|
type QueuedText = { value: string; resolve: (value: string) => void; reject: (error: unknown) => void };
|
||||||
|
const batchMaxItems = 8;
|
||||||
|
const batchMaxChars = 2200;
|
||||||
|
const requestMaxChars = 9000;
|
||||||
|
|
||||||
function read(target: Target): string | null {
|
function read(target: Target): string | null {
|
||||||
return target.attribute ? (target.node as Element).getAttribute(target.attribute) : target.node.nodeValue;
|
return target.attribute ? (target.node as Element).getAttribute(target.attribute) : target.node.nodeValue;
|
||||||
@@ -21,48 +25,111 @@ export function PageTranslator() {
|
|||||||
const { language } = useTempoUi();
|
const { language } = useTempoUi();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = language;
|
||||||
if (language === 'pt') return;
|
if (language === 'pt') return;
|
||||||
let active = true;
|
let active = true;
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
const targets = new Map<Node, Map<string, Target>>();
|
const targets = new Map<Node, Map<string, Target>>();
|
||||||
const cache = new Map<string, Promise<string>>();
|
const cache = new Map<string, Promise<string>>();
|
||||||
const queue: Array<() => void> = [];
|
const queue: QueuedText[] = [];
|
||||||
let running = 0;
|
let running = 0;
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let unavailable = false;
|
let unavailable = false;
|
||||||
|
let failureReported = false;
|
||||||
|
|
||||||
const run = () => {
|
const schedule = (delay = 0) => {
|
||||||
if (!active || unavailable || timer || running >= 2 || !queue.length) return;
|
if (!active || unavailable || timer || running >= 2 || !queue.length) return;
|
||||||
running++;
|
timer = setTimeout(() => {
|
||||||
queue.shift()?.();
|
timer = undefined;
|
||||||
// Stay below the API's per-minute limit on large pages.
|
void flush();
|
||||||
timer = setTimeout(() => { timer = undefined; run(); }, 650);
|
}, delay);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rejectQueued = (error: unknown) => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
queue.splice(0).forEach((item) => item.reject(error));
|
||||||
|
};
|
||||||
|
|
||||||
|
const enqueue = (value: string): Promise<string> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
queue.push({ value, resolve, reject });
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
|
||||||
|
const flush = async () => {
|
||||||
|
if (!active || unavailable || running >= 2 || !queue.length) return;
|
||||||
|
|
||||||
|
const batch: QueuedText[] = [];
|
||||||
|
let batchLength = 0;
|
||||||
|
while (queue.length && batch.length < batchMaxItems) {
|
||||||
|
const next = queue[0];
|
||||||
|
if (batch.length > 0 && batchLength + next.value.length > batchMaxChars) break;
|
||||||
|
batch.push(queue.shift()!);
|
||||||
|
batchLength += next.value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
running++;
|
||||||
|
schedule(650);
|
||||||
|
try {
|
||||||
|
const translated = await translateTexts(
|
||||||
|
batch.map((item) => item.value),
|
||||||
|
'auto',
|
||||||
|
language,
|
||||||
|
abort.signal,
|
||||||
|
);
|
||||||
|
batch.forEach((item, index) => item.resolve(translated[index]));
|
||||||
|
} catch (error) {
|
||||||
|
const isUnavailable = !abort.signal.aborted
|
||||||
|
&& error instanceof TranslationRequestError
|
||||||
|
&& (error.status === 503 || error.status === null);
|
||||||
|
if (isUnavailable) {
|
||||||
|
unavailable = true;
|
||||||
|
rejectQueued(error);
|
||||||
|
}
|
||||||
|
batch.forEach((item) => item.reject(error));
|
||||||
|
} finally {
|
||||||
|
running--;
|
||||||
|
schedule(650);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitForRequests = (value: string): string[] => {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
while (start < value.length) {
|
||||||
|
let end = Math.min(start + requestMaxChars, value.length);
|
||||||
|
if (end < value.length) {
|
||||||
|
const boundary = value.lastIndexOf(' ', end);
|
||||||
|
if (boundary > start) end = boundary + 1;
|
||||||
|
}
|
||||||
|
chunks.push(value.slice(start, end));
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
};
|
||||||
|
|
||||||
const translate = (value: string): Promise<string> => {
|
const translate = (value: string): Promise<string> => {
|
||||||
const previous = cache.get(value);
|
const previous = cache.get(value);
|
||||||
if (previous) return previous;
|
if (previous) return previous;
|
||||||
const promise = new Promise<string>((resolve, reject) => {
|
|
||||||
queue.push(() => {
|
const protectedText = protectTranslationTokens(value);
|
||||||
const protectedText = protectTranslationTokens(value);
|
const promise = Promise.all(
|
||||||
translateText({ text: protectedText.text, sourceLanguage: 'auto', targetLanguage: language }, abort.signal)
|
splitForRequests(protectedText.text).map(enqueue),
|
||||||
.then(({ translatedText }) => {
|
).then((translatedChunks) => {
|
||||||
for (const token of protectedText.replacements.keys()) {
|
const translatedText = translatedChunks.join('');
|
||||||
if (!translatedText.includes(token)) throw new Error('Translation lost a protected token');
|
for (const token of protectedText.replacements.keys()) {
|
||||||
}
|
if (!translatedText.includes(token)) {
|
||||||
resolve(restoreTranslationTokens(translatedText, protectedText.replacements));
|
throw new Error('Translation lost a protected token');
|
||||||
})
|
}
|
||||||
.catch((error) => {
|
}
|
||||||
if (!abort.signal.aborted && (error?.status === 503 || error?.status === null)) unavailable = true;
|
return restoreTranslationTokens(translatedText, protectedText.replacements);
|
||||||
reject(error);
|
|
||||||
})
|
|
||||||
.finally(() => { running--; run(); });
|
|
||||||
});
|
|
||||||
run();
|
|
||||||
});
|
});
|
||||||
cache.set(value, promise);
|
cache.set(value, promise);
|
||||||
promise.catch(() => cache.delete(value));
|
promise.catch(() => cache.delete(value));
|
||||||
return promise;
|
return promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
const consider = (node: Text | Element, attribute?: string) => {
|
const consider = (node: Text | Element, attribute?: string) => {
|
||||||
const element = node instanceof Element ? node : node.parentElement;
|
const element = node instanceof Element ? node : node.parentElement;
|
||||||
if (!element || element.closest(excluded)) return;
|
if (!element || element.closest(excluded)) return;
|
||||||
@@ -70,7 +137,7 @@ export function PageTranslator() {
|
|||||||
if (element.closest('option:not([value])')) return;
|
if (element.closest('option:not([value])')) return;
|
||||||
const key = attribute ?? '';
|
const key = attribute ?? '';
|
||||||
const value = attribute ? (node as Element).getAttribute(attribute) : node.nodeValue;
|
const value = attribute ? (node as Element).getAttribute(attribute) : node.nodeValue;
|
||||||
if (!value || value.length > 10000 || !/\p{L}/u.test(value)) return;
|
if (!value || !/\p{L}/u.test(value)) return;
|
||||||
let entries = targets.get(node);
|
let entries = targets.get(node);
|
||||||
if (!entries) { entries = new Map(); targets.set(node, entries); }
|
if (!entries) { entries = new Map(); targets.set(node, entries); }
|
||||||
const old = entries.get(key);
|
const old = entries.get(key);
|
||||||
@@ -81,8 +148,14 @@ export function PageTranslator() {
|
|||||||
if (!active || !node.isConnected || entries?.get(key) !== target || read(target) !== value) return;
|
if (!active || !node.isConnected || entries?.get(key) !== target || read(target) !== value) return;
|
||||||
target.translated = translated;
|
target.translated = translated;
|
||||||
write(target, translated);
|
write(target, translated);
|
||||||
}).catch(() => { /* Keep the original visible when translation fails. */ });
|
}).catch((error) => {
|
||||||
|
if (!abort.signal.aborted && !failureReported) {
|
||||||
|
failureReported = true;
|
||||||
|
console.error('Page translation failed; original text was kept.', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const scan = (root: Node) => {
|
const scan = (root: Node) => {
|
||||||
if (root instanceof Text) { consider(root); return; }
|
if (root instanceof Text) { consider(root); return; }
|
||||||
if (!(root instanceof Element)) return;
|
if (!(root instanceof Element)) return;
|
||||||
@@ -95,6 +168,7 @@ export function PageTranslator() {
|
|||||||
else if (node instanceof Element) for (const name of attributes) if (node.hasAttribute(name)) consider(node, name);
|
else if (node instanceof Element) for (const name of attributes) if (node.hasAttribute(name)) consider(node, name);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
const observer = new MutationObserver((mutations) => {
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
if (mutation.type === 'characterData') scan(mutation.target);
|
if (mutation.type === 'characterData') scan(mutation.target);
|
||||||
@@ -102,8 +176,8 @@ export function PageTranslator() {
|
|||||||
else mutation.addedNodes.forEach(scan);
|
else mutation.addedNodes.forEach(scan);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] });
|
observer.observe(document.documentElement, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] });
|
||||||
scan(document.body);
|
scan(document.documentElement);
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
abort.abort();
|
abort.abort();
|
||||||
|
|||||||
@@ -160,3 +160,45 @@ export async function translateText(
|
|||||||
targetLanguage: payload.target_lang ?? request.targetLanguage,
|
targetLanguage: payload.target_lang ?? request.targetLanguage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function translateTexts(
|
||||||
|
texts: string[],
|
||||||
|
sourceLanguage: string,
|
||||||
|
targetLanguage: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (texts.length === 0) return [];
|
||||||
|
|
||||||
|
const payload = await fetchJson<{ translated_text?: unknown }>(
|
||||||
|
`${translationApiBaseUrl}/translate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
signal,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: texts,
|
||||||
|
source_lang: sourceLanguage,
|
||||||
|
target_lang: targetLanguage,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Array.isArray(payload.translated_text) ||
|
||||||
|
payload.translated_text.length !== texts.length ||
|
||||||
|
payload.translated_text.some(
|
||||||
|
(text) => typeof text !== "string" || text.trim() === "",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new TranslationRequestError(
|
||||||
|
"Translation response does not contain all translated texts",
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.translated_text;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Http\Client\Request as ClientRequest;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class TranslationApiTest extends TestCase
|
class TranslationApiTest extends TestCase
|
||||||
@@ -24,4 +25,29 @@ class TranslationApiTest extends TestCase
|
|||||||
$this->postJson('/api/translate', $input)->assertOk()->assertJsonPath('translated_text', 'Hello');
|
$this->postJson('/api/translate', $input)->assertOk()->assertJsonPath('translated_text', 'Hello');
|
||||||
$this->postJson('/api/translate', $input)->assertStatus(503);
|
$this->postJson('/api/translate', $input)->assertStatus(503);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_translates_text_batches_for_full_page_translation(): void
|
||||||
|
{
|
||||||
|
config(['services.translation.url' => 'http://translator.test']);
|
||||||
|
Http::fake(['translator.test/translate' => Http::response(['translatedText' => ['Home', 'About us']])]);
|
||||||
|
|
||||||
|
$this->postJson('/api/translate', [
|
||||||
|
'text' => ['Início', 'Sobre nós'],
|
||||||
|
'source_lang' => 'auto',
|
||||||
|
'target_lang' => 'en',
|
||||||
|
])->assertOk()
|
||||||
|
->assertJsonPath('translated_text.0', 'Home')
|
||||||
|
->assertJsonPath('translated_text.1', 'About us');
|
||||||
|
|
||||||
|
Http::assertSent(fn (ClientRequest $request) => $request['q'] === ['Início', 'Sobre nós']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_translation_batches_that_exceed_the_request_limit(): void
|
||||||
|
{
|
||||||
|
$this->postJson('/api/translate', [
|
||||||
|
'text' => array_fill(0, 9, 'Texto'),
|
||||||
|
'source_lang' => 'auto',
|
||||||
|
'target_lang' => 'en',
|
||||||
|
])->assertUnprocessable();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user