UNPKG

rosey-cloudcannon-connector

Version:

Middleware that connects Rosey to CloudCannon to enable editing of Rosey translations via CloudCannon's CMS.

566 lines (503 loc) 20.6 kB
import { SmartlingApiClientBuilder, SmartlingJobsApi, SmartlingJobBatchesApi, SmartlingFilesApi, CreateJobParameters, CreateBatchParameters, UploadBatchFileParameters, FileType, DownloadFileParameters, RetrievalType, } from "smartling-api-sdk-nodejs"; import fs from "fs"; import path from "path"; import dotenv from "dotenv"; import YAML from "yaml"; import { readJsonFromFile, getTranslationFilePath, isDirectory, readTranslationFile, handleConfigPaths, } from "../utils/file-helpers.mjs"; import { htmlToMarkdown } from "../utils/html-to-markdown.mjs"; dotenv.config(); export async function callSmartling(configData) { const roseyBaseFilePath = handleConfigPaths( configData.rosey_paths.rosey_base_file_path ); const translationFilesDirPath = handleConfigPaths( configData.rosey_paths.translations_dir_path ); const incomingTranslationsDir = handleConfigPaths( configData.smartling.incoming_translations_dir ); const outgoingTranslationsFilePath = handleConfigPaths( configData.smartling.outgoing_translations_file_path ); const locales = configData.locales; const userSecret = process.env.DEV_USER_SECRET; // Set this in env variables const projectId = configData.smartling.dev_project_id; const userId = configData.smartling.dev_user_identifier; const smartlingTranslateEnabled = configData.smartling.enabled; const authRequestData = { userIdentifier: userId, userSecret: userSecret, }; const outgoingTranslationFileUri = configData.smartling.outgoing_translation_file_uri; const pingInterval = configData.smartling.ping_interval; const pingMaximum = configData.smartling.ping_maximum; const pingsToWaitForAuth = configData.smartling.pings_to_wait_for_auth; // One more unnecessary check to check if smarling is enabled (just in case) if (!smartlingTranslateEnabled || smartlingTranslateEnabled === "false") { console.log("🤖 Smartling translation disabled - skipping API call."); return; } else { console.log("🤖 Smartling translation enabled - continuing with API call."); } // Create outgoing translations file // Before adding a phrase to send away for translation, we check if it // Already has a translation in the translation files, and in one of the received Smartling translation files (which should all be the same) // We only add things to this file that need to be translated, so if it's empty we can skip call await generateOutgoingTranslationFile( roseyBaseFilePath, translationFilesDirPath, locales, outgoingTranslationsFilePath, incomingTranslationsDir ); // Get the outgoing translation file // Check here if the outgoingTranslationFile is empty // If that is the case, outgoingTranslationFile is empty - don't proceed with the call const outgoingTranslationsFileData = await readJsonFromFile( outgoingTranslationsFilePath ); const outgoingTranslationFileDataKeys = Object.keys( outgoingTranslationsFileData ); if (outgoingTranslationFileDataKeys.length) { console.log( `🤖 Detected ${outgoingTranslationFileDataKeys.length} translation keys to send to Smartling - continuing with API call.` ); } else { console.log( "🤖 Nothing detected to send to Smartling - skipping API call." ); return; } // Create factory for building API clients. const apiBuilder = new SmartlingApiClientBuilder() .setBaseSmartlingApiUrl("https://api.smartling.com") .authWithUserIdAndUserSecret(userId, userSecret); // Instantiate the APIs we'll need. const jobsApi = apiBuilder.build(SmartlingJobsApi); const batchesApi = apiBuilder.build(SmartlingJobBatchesApi); const filesApi = apiBuilder.build(SmartlingFilesApi); // Get a token const authData = await getSmartlingAuth( "https://api.smartling.com/auth-api/v2/authenticate", authRequestData ); if (!authData) { console.log( `⚠️ No authentication data received from Smartling, skipping Smartling call. Check you have set your Smartling credentials correctly, and try again.` ); return; } const authToken = authData.response.data.accessToken; // Create job. const createJobParams = new CreateJobParameters().setName( `Test job name ${Date.now()}` ); const job = await jobsApi.createJob(projectId, createJobParams); // Create job batch parameters const createBatchParams = new CreateBatchParameters() .setTranslationJobUid(job.translationJobUid) .setAuthorize(true); // Add file URIs to the batch parameters createBatchParams.addFileUri(outgoingTranslationFileUri); // Create batch const batch = await batchesApi.createBatch(projectId, createBatchParams); const uploadBatchFileParams = new UploadBatchFileParameters() .setFile(outgoingTranslationsFilePath) .setFileUri(outgoingTranslationFileUri) .setFileType(FileType.JSON) .setLocalesToApprove(locales); // Determines which locales Smartling will translate for, needs to be configure on Smartlings end await batchesApi.uploadBatchFile( projectId, batch.batchUid, uploadBatchFileParams ); console.log(`🤖 Uploaded file ${outgoingTranslationsFilePath} to Smartling.`); // Call Job Status API until translation is completed, or we timeout. const jobInfoUrlString = `https://api.smartling.com/jobs-api/v3/projects/${projectId}/jobs/${job.translationJobUid}`; // We'll exit after a configured amount of pings, in case something goes wrong - so we don't hang around forever let pingCount = 0; await new Promise(function (resolve, reject) { // Set up an interval to try the Smartling api every x amount of pings until its finished // Check the job status every ping const checkJobStatus = setInterval(async () => { console.log("🤖 Checking translation job status..."); // Fetch current status of the job const smartlingJobData = await fetchSmartlingData( jobInfoUrlString, authToken ); const jobStatus = smartlingJobData.response.data.jobStatus; // Exit if we reach the maximum amount of pings if (pingCount >= pingMaximum) { console.log( `⏰ Timing out Smartling connection after ${pingCount} API calls.` ); clearInterval(checkJobStatus); } // Increment for each time we check pingCount++; console.log( `🤖 Smartling translation job status API call: ${pingCount}.` ); // Job is completed so download the files and stop checking the status switch (jobStatus) { case "COMPLETED": console.log(`🤖 Translation: ${jobStatus}, downloading files now!`); clearInterval(checkJobStatus); await downloadSmartlingData( filesApi, locales, projectId, outgoingTranslationFileUri, incomingTranslationsDir ); resolve(); break; // Checks authorization before proceeding to in progress - can sometimes hang here // if there are no new translations from Smartlings POV, but for some reason we've still sent away to translate // Good to be able to exit earlier than normal if it hangs around on this, as it will never move to IN_PROGRESS case "AWAITING_AUTHORIZATION": console.log( `🤖 Still ${jobStatus} for translation job, waiting for completion...` ); if (pingCount >= pingsToWaitForAuth) { console.log( `🤖 Skipping after ${pingCount} API calls. If still 'Awaiting Authorization' by now, there is probably nothing to translate in this job, and we should have already exited this function.` ); clearInterval(checkJobStatus); } break; case "IN_PROGRESS": console.log( `🏗️ Translation job still ${jobStatus}, waiting and trying again...` ); break; // Cancelled on Smartlings end for some reason case "CANCELLED": console.log( `🤖 Translation job ${jobStatus} for some reason. Skipping for now. Go to Smartling dashboard for more details.` ); clearInterval(checkJobStatus); break; } }, pingInterval); }); await writeReceivedTranslationsToTranslationFiles( translationFilesDirPath, locales, incomingTranslationsDir ); return; } // Get a token async function getSmartlingAuth(url, data) { try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(data), }); if (response.ok) { const result = await response.json(); return result; } else { console.error("Error:", response.status, response.statusText); } } catch (error) { console.error("Error:", error.message); } } // Get job progress async function fetchSmartlingData(url, authToken) { try { const response = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${authToken}`, }, }); if (response.ok) { const result = await response.json(); return result; } else { console.error("Error:", response.status, response.statusText); } } catch (error) { console.error("Error:", error.message); } } // Generate the file we're going to send to Smartling for translating async function generateOutgoingTranslationFile( baseFilePath, translationFilesDirPath, locales, outgoingFilePath, incomingTranslationsDir ) { // Ensure the incoming smartling files directory exists and read from it await fs.promises.mkdir(incomingTranslationsDir, { recursive: true }); const existingSmartlingTranslationsFiles = await fs.promises.readdir( incomingTranslationsDir ); // We need to check all locales in case a locale gets added after the first one // If a file exists in there it should have existing smartling translation keys which we can add to check // We're not sending away already translated keys // If we send away a key we get all the locales back, so we just need to read the first one const existingSmartlingTranslations = {}; // Checking if there are any files in the smartling-translations dir if (existingSmartlingTranslationsFiles.length) { // Go through each and get the existing data from them stored under each locale await Promise.all( existingSmartlingTranslationsFiles.map(async (translationFile) => { const smartlingTranslationsFilePath = path.join( incomingTranslationsDir, translationFile ); const smartlingTranslations = await readJsonFromFile( smartlingTranslationsFilePath ); const localeByFileName = translationFile.replace(".json", ""); existingSmartlingTranslations[localeByFileName] = Object.keys( smartlingTranslations ); }) ); // Otherwise just keep existingSmartlingTranslations an empty obj so we see there is no existing Smartling translations } // Check if the translation key is a blank string in any of the translation files // Which means there's no translation and only then send away for a translation for the key // This will prevent manually translated keys (or if Smartling has been disabled, and enabled again) // Being sent away for translation // For each locale check all translation files to see if a key is missing a translation const untranslatedKeysInTranslationFiles = []; await Promise.all( // Loop through each locale locales.map(async (locale) => { const translationFilesForLocalePath = path.join( translationFilesDirPath, locale ); const translationFilesForLocale = await fs.promises.readdir( translationFilesForLocalePath, { recursive: true } ); // Loop through the translation files in each locale and look for untranslated keys in their translation files // If we find any locale has an untranslated key we'll add it to the keys allowed to be sent to Smartling // And later we check that they also don't reside in the translations we've already received from Smartling for (const translationFileName of translationFilesForLocale) { const translationFilePath = getTranslationFilePath( locale, translationFilesDirPath, translationFileName ); // Don't try read a dir if (!(await isDirectory(translationFilePath))) { const translationFileContents = await readTranslationFile( translationFilePath ); const keysOnTranslationPage = Object.keys(translationFileContents); // Look for keys on the page without a translation (not incl _inputs or urlTranslation) for (const key of keysOnTranslationPage) { if (key !== "_inputs" && key !== "urlTranslation") { const keyContents = translationFileContents[key]; if (keyContents.trim() === "") { untranslatedKeysInTranslationFiles.push(key); } } } } } }) ); // We also still need to check for if a key already has a value in existing incoming Smartling translations // - A smartling translation could be deleted in the translations files, // - Which would mean if we didn't check existing Smartling translations it would send away again and err (awaitng_auth) // Get all the keys in the base.json and check whether we need to translate them const inputFileData = await readJsonFromFile(baseFilePath); const inputFileKeyData = inputFileData.keys; const inputKeys = Object.keys(inputFileKeyData); // If we don't already have the translation in any of the locales translations files or Smartling files // Add it to the outgoing translations obj to be sent away const translationObject = {}; for (const key of inputKeys) { // If key doesn't have a translation in the translation files, look through the Smartling translations for each locale if (untranslatedKeysInTranslationFiles.includes(key)) { // If there's also no translation been received before for at least one of the locales, // we'll add it to the phrases to send to Smartling for (const locale of locales) { // If the locale doesn't exist in Smartling translations a translation def doesn't exist so we're safe to send away if (!existingSmartlingTranslations[locale]) { const originalPhrase = inputFileData.keys[key].original; translationObject[key] = originalPhrase; // We can break because the translation doesn't exist in at least one locale so it should be sent away break; // If the key doesn't exist in our Smartling translations already we can send away } else if (!existingSmartlingTranslations[locale].includes(key)) { const originalPhrase = inputFileData.keys[key].original; translationObject[key] = originalPhrase; break; } } } } // Write the outgoing keys to go to Smartling await fs.promises.writeFile( outgoingFilePath, JSON.stringify(translationObject) ); console.log( `🤖 Wrote ${ Object.keys(translationObject).length } keys to send to Smartling for translation.` ); } // Once Smartling has finished translating, download the files to put into the right place in our project async function downloadSmartlingData( filesApi, locales, projectId, outgoingTranslationFileUri, incomingTranslationsDir ) { for (const locale of locales) { // Get the downloaded translations for each locale const downloadFileParams = new DownloadFileParameters().setRetrievalType( RetrievalType.PUBLISHED ); const downloadedFileContent = await filesApi.downloadFile( projectId, outgoingTranslationFileUri, locale, downloadFileParams ); const downloadedFileData = JSON.parse(downloadedFileContent); // Check if the directory exists, if not create it await fs.promises.mkdir(incomingTranslationsDir, { recursive: true }); // Once we receive something back update our existing smartling translations const smartlingTranslationsPath = path.join( incomingTranslationsDir, `${locale}.json` ); const smartlingTranslationsData = await readJsonFromFile( smartlingTranslationsPath ); // Loop through our downloaded keys and add to the Smartling translations we've received in the past const downloadedFileDataKeys = Object.keys(downloadedFileData); downloadedFileDataKeys.forEach((key) => { smartlingTranslationsData[key] = downloadedFileData[key]; }); // Order Smartling data keys, so they're always in alphanumeric order const orderedSmartlingTranslationsData = Object.keys( smartlingTranslationsData ) .sort() // Sort the keys alphabetically .reduce((obj, key) => { obj[key] = smartlingTranslationsData[key]; // Rebuild the object with sorted keys return obj; }, {}); console.log( `Received ${ Object.keys(orderedSmartlingTranslationsData).length } translated keys from Smartling.` ); // Write the updated translations - existing and new await fs.promises.writeFile( smartlingTranslationsPath, JSON.stringify(orderedSmartlingTranslationsData) ); console.log(`🤖 Downloaded ${smartlingTranslationsPath}.`); } } async function writeReceivedTranslationsToTranslationFiles( translationFilesDirPath, locales, incomingTranslationsDir ) { // Loop through each locale for (const locale of locales) { const translationPagesUpdatedLogs = {}; const smartlingFilePath = path.join( incomingTranslationsDir, `${locale}.json` ); // Get the downloaded Smartling data for that locale const incomingSmartlingData = await readJsonFromFile(smartlingFilePath); const incomingSmartlingDataKeys = Object.keys(incomingSmartlingData); // Find all the yaml translation files for that locale and read each one const translationFilesForLocaleDirPath = path.join( translationFilesDirPath, locale ); const translationFilePaths = await fs.promises.readdir( translationFilesForLocaleDirPath, { recursive: true } ); // Loop through all the files in the locale for (const filePath of translationFilePaths) { const fullFilePath = path.join( translationFilesForLocaleDirPath, filePath ); let translationsUpdatedOnPage = 0; // Don't try to read a dir if (!(await isDirectory(fullFilePath))) { const translationFileContents = await readTranslationFile(fullFilePath); // For each one look through it for keys that are included in the downloaded Smartling data for (const translationKey of Object.keys(translationFileContents)) { if ( incomingSmartlingDataKeys.includes(translationKey) && // If they're blank (an empty string) in the translation file, overwrite with the Smartling data converted to md translationFileContents[translationKey]?.trim() === "" ) { translationFileContents[translationKey] = await htmlToMarkdown( incomingSmartlingData[translationKey] ); // Update this to true to let us know we do need to write the file back translationsUpdatedOnPage += 1; } } if (translationsUpdatedOnPage > 0) { // Update the logs translationPagesUpdatedLogs[filePath] = translationsUpdatedOnPage; // Write the translation file back to where we found it with the new data await fs.promises.writeFile( fullFilePath, YAML.stringify(translationFileContents) ); } } } // Work out total updates for the logs for each locale const pagesUpdated = Object.keys(translationPagesUpdatedLogs); if (pagesUpdated.length) { let totalKeysUpdated = 0; for (const pageWithUpdates of pagesUpdated) { totalKeysUpdated += translationPagesUpdatedLogs[pageWithUpdates]; } console.log( `Updated ${locale} pages with ${totalKeysUpdated} keys on ${pagesUpdated.length} pages with translations from Smartling.` ); } } }