NOTE: Apart from
(and even then it's questionable, I'm Scottish). These are machine translated in languages I don't read. If they're terrible please contact me.
You can see how this translation was done in this article.
Sunday, 25 August 2024
//Less than a minute
En artículos anteriores, hemos discutido la importancia de la traducción en el contexto de las aplicaciones web. También hemos explorado el uso de la biblioteca EasyNMT para realizar traducciones en una aplicación ASP.NET Core. En este post cubriré cómo agregué un servicio de fondo a la solicitud para permitirle enviar solicitudes de traducción que se procesan en segundo plano.
De nuevo, puedes ver todo el código fuente para esto en mi GitHub página.
Aquí añadimos una pequeña herramienta que envía trabajos de backround al servicio que detallamos en la parte 2. Esta herramienta es un formulario simple que le permite enviar una solicitud de traducción al servicio. A continuación, se almacena en caché y se añade a una cola que le da información sobre el estado de la traducción.
Esto añade funcionalidad donde al seleccionar un documento 'nuevo' puede traducirlo.
En nuestra página del editor de Markdown agregué un poco de código que contiene un poco desplegable (en _LanguageDropDown.cshtml
) que le permite seleccionar el idioma al que desea traducir.
@if (Model.IsNew)
{
var translationHidden = Model.TranslationTasks.Any() ? "" : "hidden";
<p class="text-blue-dark dark:text-blue-light inline-flex items-center justify-center space-x-2">
<partial name="_LanguageDropDown" for="Languages"/>
<button class="btn btn-outline btn-sm mt-1" x-on:click="window.mostlylucid.translations.submitTranslation"><i class='bx bx-send'></i>Translate</button>
</p>
<div id="translations" class="@translationHidden">
<partial name="_GetTranslations" model="Model.TranslationTasks" />
</div>
<div id="translatedcontent" class="hidden">
<textarea class="hidden" id="translatedcontentarea"></textarea>
</div>
}
Nuestro _LanguageDropDown
vista parcial es un menú desplegable simple que le permite seleccionar el idioma al que desea traducir. Esto está poblado de una lista de idiomas en el Languages
propiedad del modelo.
Usted puede ver que utiliza Alpine.js para manejar el menú desplegable y para establecer el idioma y la bandera seleccionados para mostrar en la parte principal de la selección. También establece el código corto de la lengua que se utiliza al enviar la solicitud de traducción.
Usando Alping significa que mantenemos JavaScript mínimo, referenciado localmente en nuestras vistas. Esta es una gran manera de mantener sus vistas limpias y fáciles de leer.
@using Mostlylucid.Helpers
@model List<string>
<div id="LanguageDropDown" x-data="{
open: false,
selectedLanguage: 'Select Language',
selectedFlag: '' ,
selectedShortCode:''
}" class="relative inline-block mt-3">
<!-- Dropdown Button -->
<button x-on:click="open = !open" class="btn btn-sm btn-outline flex items-center space-x-2">
<!-- Dynamically Show the Flag Icon -->
<template x-if="selectedFlag">
<img :src="selectedFlag" class="h-4 w-4 rounded outline outline-1 outline-green-dark dark:outline-white" alt="Selected Language Flag">
</template>
<span x-text="selectedLanguage"></span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Dropdown Menu -->
<div x-show="open" x-on:click.away="open = false"
class="absolute left-0 mt-2 w-64 rounded-md shadow-lg dark:bg-custom-dark-bg bg-white ring-1 ring-black ring-opacity-5 z-50">
<ul class="p-2">
@foreach (var language in Model)
{
<li>
<a href="#"
x-on:click.prevent="selectedLanguage = '@(language.ConvertCodeToLanguage())'; selectedFlag = '/img/flags/@(language).svg'; selectedShortCode='@language'; open = false"
class="flex dark:text-white text-black items-center p-2 hover:bg-gray-100">
<img src="/img/flags/@(language).svg" asp-append-version="true" class="ml-2 h-4 w-4 mr-4 rounded outline outline-1 outline-green-dark dark:outline-white" alt="@language"> @language.ConvertCodeToLanguage()
</a>
</li>
}
</ul>
</div>
</div>
Verás que esto tiene algún código Apline.js que llama a nuestro window.mostlylucid.translations.submitTranslation
función. Esta función se define en nuestra translations.js
archivo que está incluido en nuestro _Layout.cshtml
archivo.
export function submitTranslation() {
const languageDropDown = document.getElementById('LanguageDropDown');
// Access Alpine.js data using Apline.$data (Alpine.js internal structure)
const alpineData = Alpine.$data(languageDropDown);
const shortCode = alpineData.selectedShortCode;
const markdown = simplemde.value();
if (shortCode === '' || markdown === '') return;
// Create the data object that matches your model
const model = {
Language: shortCode,
OriginalMarkdown: markdown
};
// Perform the fetch request to start the translation using POST
fetch('/api/translate/start-translation', {
method: 'POST',
headers: {
'Content-Type': 'application/json' // The content type should be JSON
},
body: JSON.stringify(model) // Send the data object as JSON
})
.then(function(response) {
if (response.ok) {
// Process the returned task ID
return response.json(); // Parse the JSON response (assuming the task ID is returned in JSON)
} else {
console.error('Failed to start the translation');
}
})
.then(function(taskId) {
if (taskId) {
console.log("Task ID:", taskId);
// Trigger an HTMX request to get the translations after saving
htmx.ajax('get', "/editor/get-translations", {
target: '#translations', // Update this element with the response
swap: 'innerHTML', // Replace the content inside the target
}).then(function () {
// Remove the hidden class after the content is updated
document.getElementById('translations').classList.remove('hidden');
});
}
})
.catch(function(error) {
// Handle any errors that occur during the fetch
console.error('An error occurred:', error);
});
Este código se describe en el siguiente diagrama de secuencia:
Aunque esto parece un montón de código, en realidad es bastante simple.
Enviamos una solicitud POST al servidor con el idioma y el contenido de marcado. Esto va a un punto final llamado start-translation
que se define en nuestro TranslationAPI
. Esto inicia la tarea de traducción y añade esta tarea al Cache para este usuario.
El servidor responde con una tarea Id (que registramos pero no usamos)
Luego enviamos una solicitud GET al servidor para obtener las traducciones. Esto se hace usando HTMX que es una biblioteca que le permite actualizar partes de la página sin una actualización de página completa. Esta es una herramienta muy potente y se utiliza en muchos lugares en esta aplicación.
Este es un controlador WebAPI que toma las peticiones que contienen Markdown y un código de idioma. A continuación, envía la solicitud a nuestro servidor de traducción de fondo, oculta la tarea de nuevo el UserId (contenido en una cookie) y devuelve la tarea Id al cliente. (Deshabilité el atributo ValidateAntiForgeryToken por ahora, ya que no lo estoy usando)
[HttpPost("start-translation")]
// [ValidateAntiForgeryToken]
public async Task<Results<Ok<string>, BadRequest<string>>> StartTranslation([FromBody] MarkdownTranslationModel model)
{
if(ModelState.IsValid == false)
{
return TypedResults.BadRequest("Invalid model");
}
if(!backgroundTranslateService.TranslationServiceUp)
{
return TypedResults.BadRequest("Translation service is down");
}
// Create a unique identifier for this translation task
var taskId = Guid.NewGuid().ToString("N");
var userId = Request.GetUserId(Response);
// Trigger translation and store the associated task
var translationTask = await backgroundTranslateService.Translate(model);
var translateTask = new TranslateTask(taskId, DateTime.Now, model.Language, translationTask);
translateCacheService.AddTask(userId, translateTask);
// Return the task ID to the client
return TypedResults.Ok(taskId);
}
Esto se solicita utilizando HTMX y devuelve las traducciones para el usuario actual. Este es un punto final simple que obtiene las traducciones de la caché y las devuelve al cliente.
[HttpGet]
[Route("get-translations")]
public IActionResult GetTranslations()
{
var userId = Request.GetUserId(Response);
var tasks = translateCacheService.GetTasks(userId);
var translations = tasks.Select(x=> new TranslateResultTask(x, false)).ToList();
return PartialView("_GetTranslations", translations);
}
Esta es una vista sencilla que utiliza HTMX para encuestar el servidor cada 5 segundos para obtener las traducciones para el usuario actual. Muestra una tabla de traducciones con un enlace para ver la traducción.
También tiene manejo para cuando todas las traducciones están completas para detener la votación (estableciendo el gatillo a none
). Y mostrar un mensaje alternativo cuando no hay traducciones.
También utilizo la biblioteca Humanizer para mostrar el tiempo necesario para completar la traducción en un formato legible por el ser humano.
Esto se traduce en este punto de vista:
@using Humanizer
@using Mostlylucid.Helpers
@model List<Mostlylucid.MarkdownTranslator.Models.TranslateResultTask>
@{
var allCompleted = Model.All(x => x.Completed);
var noTranslations = Model.Count == 0;
var trigger = allCompleted ? "none" : "every 5s";
if (noTranslations)
{
<div class="alert alert-info" role="alert">
No translations have been requested yet.
</div>
}
else
{
<div class="translationpoller" hx-controller="Editor" hx-action="GetTranslations" hx-get hx-swap="outerHTML" hx-trigger="@trigger">
<table class="table">
<thead>
<th>
@Html.DisplayNameFor(model => model[0].TaskId)
</th>
<th>
@Html.DisplayNameFor(model => model[0].Completed)
</th>
<th >
@Html.DisplayNameFor(model => model[0].Language)
</th>
<th>
@Html.DisplayNameFor(model => model[0].TotalMilliseconds)
</th>
</thead>
@foreach (var item in Model)
{
<tr>
<td> <a href="#" x-on:click.prevent="window.mostlylucid.translations.viewTranslation('@item.TaskId')">View</a></td>
<td>@if (item.Completed)
{
<i class='bx bx-check text-green'></i>
}
else
{
<i class='bx bx-loader-alt animate-spin dark:text-white text-black'></i>
}
</td>
<td>
<p class="flex items-center">
<img src="/img/flags/@(item.Language).svg" asp-append-version="true" class="ml-2 h-4 w-4 mr-4 rounded outline outline-1 outline-green-dark dark:outline-white" alt="@item.Language">
@item.Language.ConvertCodeToLanguage()
</p>
</td>
<td>@(TimeSpan.FromMilliseconds(item.TotalMilliseconds).Humanize())</td>
</tr>
}
</table>
</div>
}
}
Como verá en la vista anterior, llamamos a un pequeño Alping onclick para ver la traducción. Esta es una función simple que obtiene la traducción del servidor y la muestra en un diálogo modal.
<a href="#" x-on:click.prevent="window.mostlylucid.translations.viewTranslation('@item.TaskId')">View</a>
Lo que llama a esto. Todo lo que hace es obtener la transalción poblada del servidor y lo muestra en la página.
export function viewTranslation(taskId) {
// Construct the URL with the query parameters
const url = `/api/translate/get-translation/${taskId}`;
// Fetch call to the API endpoint
fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json' // Indicate that we expect a JSON response
}
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then(data =>
{
let translatedContentArea = document.getElementById("translatedcontent")
translatedContentArea.classList.remove("hidden");
let textArea = document.getElementById('translatedcontentarea');
textArea.classList.remove('hidden');
textArea.value = data.originalMarkdown;
simplemde.value(data.translatedMarkdown);
}) // Log the successful response data
.catch(error => console.error('Error:', error)); // Handle any errors
}
Esto es similar al método anterior para obtener una lista de las traducciones excepto que obtiene una sola traducción con el OriginalMarkdown
y TranslatedMarkdown
población:
[HttpGet]
[Route("get-translation/{taskId}")]
public Results<JsonHttpResult<TranslateResultTask>, BadRequest<string>> GetTranslation(string taskId)
{
var userId = Request.GetUserId(Response);
var tasks = translateCacheService.GetTasks(userId);
var translationTask = tasks.FirstOrDefault(t => t.TaskId == taskId);
if (translationTask == null) return TypedResults.BadRequest("Task not found");
var result = new TranslateResultTask(translationTask, true);
return TypedResults.Json(result);
}
El resultado de todo esto es que ahora puede enviar una solicitud de traducción y ver el estado de la traducción en el editor después de que esté completa. Voy a entrar en más detalles sobre cómo funciona el servicio de traducción en el próximo post.
Tengo más que quiero hacer aquí incluyendo re-ganar el flujo Render Markdown para el contenido traducido, etc. Pero eso; es la diversión de este sitio; no todo está completamente pulido, pero es todo el código real con el que se puede jugar mientras lo construyo. De nuevo, puedes ver todo el código fuente para esto en mi GitHub página. Así que por favor ve a ver eso si quieres ver más.