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
|
||||
{
|
||||
$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',
|
||||
'target_lang' => 'required|string|max:20',
|
||||
'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']) {
|
||||
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'];
|
||||
@@ -42,8 +56,19 @@ class TranslationApiController extends Controller
|
||||
try {
|
||||
$response = Http::connectTimeout(2)->timeout(config('services.translation.timeout'))->post($this->url().'/translate', $payload);
|
||||
$translated = $response->json('translatedText');
|
||||
if ($response->successful() && is_string($translated) && $translated !== '') {
|
||||
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');
|
||||
$validTranslation = $isBatch
|
||||
? 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) {
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Presentation choice for Tempo's 21/10/2026 request: one shared navigation. */
|
||||
.tempo-header {
|
||||
position: relative;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
background: #090909;
|
||||
color: #fff;
|
||||
|
||||
@@ -127,7 +127,7 @@ function PortalNavigationContent({ children }: { children: ReactNode }) {
|
||||
</aside>
|
||||
)}
|
||||
{
|
||||
<div className={printable ? 'print:hidden' : undefined}>
|
||||
<div className={printable ? 'hidden' : 'contents'}>
|
||||
<PortalMenu primary />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTempoUi } from '@/components/tempo-brand';
|
||||
import { translateText } from '@/translation/service';
|
||||
import { TranslationRequestError, translateTexts } from '@/translation/service';
|
||||
import { protectTranslationTokens, restoreTranslationTokens } from '@/translation/utils';
|
||||
|
||||
// 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 attributes = ['placeholder', 'title', 'aria-label', 'alt'] as const;
|
||||
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 {
|
||||
return target.attribute ? (target.node as Element).getAttribute(target.attribute) : target.node.nodeValue;
|
||||
@@ -21,48 +25,111 @@ export function PageTranslator() {
|
||||
const { language } = useTempoUi();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language;
|
||||
if (language === 'pt') return;
|
||||
let active = true;
|
||||
const abort = new AbortController();
|
||||
const targets = new Map<Node, Map<string, Target>>();
|
||||
const cache = new Map<string, Promise<string>>();
|
||||
const queue: Array<() => void> = [];
|
||||
const queue: QueuedText[] = [];
|
||||
let running = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let unavailable = false;
|
||||
let failureReported = false;
|
||||
|
||||
const run = () => {
|
||||
const schedule = (delay = 0) => {
|
||||
if (!active || unavailable || timer || running >= 2 || !queue.length) return;
|
||||
running++;
|
||||
queue.shift()?.();
|
||||
// Stay below the API's per-minute limit on large pages.
|
||||
timer = setTimeout(() => { timer = undefined; run(); }, 650);
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
void flush();
|
||||
}, 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 previous = cache.get(value);
|
||||
if (previous) return previous;
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
queue.push(() => {
|
||||
const protectedText = protectTranslationTokens(value);
|
||||
translateText({ text: protectedText.text, sourceLanguage: 'auto', targetLanguage: language }, abort.signal)
|
||||
.then(({ translatedText }) => {
|
||||
for (const token of protectedText.replacements.keys()) {
|
||||
if (!translatedText.includes(token)) throw new Error('Translation lost a protected token');
|
||||
}
|
||||
resolve(restoreTranslationTokens(translatedText, protectedText.replacements));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!abort.signal.aborted && (error?.status === 503 || error?.status === null)) unavailable = true;
|
||||
reject(error);
|
||||
})
|
||||
.finally(() => { running--; run(); });
|
||||
});
|
||||
run();
|
||||
|
||||
const protectedText = protectTranslationTokens(value);
|
||||
const promise = Promise.all(
|
||||
splitForRequests(protectedText.text).map(enqueue),
|
||||
).then((translatedChunks) => {
|
||||
const translatedText = translatedChunks.join('');
|
||||
for (const token of protectedText.replacements.keys()) {
|
||||
if (!translatedText.includes(token)) {
|
||||
throw new Error('Translation lost a protected token');
|
||||
}
|
||||
}
|
||||
return restoreTranslationTokens(translatedText, protectedText.replacements);
|
||||
});
|
||||
cache.set(value, promise);
|
||||
promise.catch(() => cache.delete(value));
|
||||
return promise;
|
||||
};
|
||||
|
||||
const consider = (node: Text | Element, attribute?: string) => {
|
||||
const element = node instanceof Element ? node : node.parentElement;
|
||||
if (!element || element.closest(excluded)) return;
|
||||
@@ -70,7 +137,7 @@ export function PageTranslator() {
|
||||
if (element.closest('option:not([value])')) return;
|
||||
const key = attribute ?? '';
|
||||
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);
|
||||
if (!entries) { entries = new Map(); targets.set(node, entries); }
|
||||
const old = entries.get(key);
|
||||
@@ -81,8 +148,14 @@ export function PageTranslator() {
|
||||
if (!active || !node.isConnected || entries?.get(key) !== target || read(target) !== value) return;
|
||||
target.translated = 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) => {
|
||||
if (root instanceof Text) { consider(root); 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);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'characterData') scan(mutation.target);
|
||||
@@ -102,8 +176,8 @@ export function PageTranslator() {
|
||||
else mutation.addedNodes.forEach(scan);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] });
|
||||
scan(document.body);
|
||||
observer.observe(document.documentElement, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] });
|
||||
scan(document.documentElement);
|
||||
return () => {
|
||||
active = false;
|
||||
abort.abort();
|
||||
|
||||
@@ -160,3 +160,45 @@ export async function translateText(
|
||||
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;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Http\Client\Request as ClientRequest;
|
||||
use Tests\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)->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