Skip to content
Draft
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
150 changes: 96 additions & 54 deletions nx/blocks/loc/connectors/smartling/index.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { addDnt, removeDnt } from '../../dnt/dnt.js';
import downloadQueue from '../../utils/downloadQueue.js';
import fetchWithRetry from '../../utils/fetchWithRetry.js';
import { Queue } from '../../../../../nx2/public/utils/tree.js';
import {
BASE_OPTS, resolveOrigin, getToken, onUnauthorized, isConnected, connect as establishConnection,
} from './auth.js';

const MAX_CONCURRENT_STATUS = 5;
const TERMINAL_STATUSES = ['complete', 'cancelled'];

export const dnt = { addDnt };

export { isConnected };
Expand Down Expand Up @@ -294,12 +298,9 @@ export async function sendAllLanguages({
return;
}

// Presist to the state for future reference
// Persist to the state for future reference
options.service.jobUid = { value: jobUid };

// // Persist into the immediate config object - janktown, but ok for now
// config[`${env}.jobUid`] = jobUid;

sendMessage({ text: `Creating a batch in Smartling for: ${title}.` });
const batchUid = await createBatch({
org,
Expand Down Expand Up @@ -347,59 +348,95 @@ export async function sendAllLanguages({
}

/**
* Fetches Smartling's per-locale progress for a job.
* Computes a locale's translation progress for one file per Smartling's
* "Checking File Translation Status" formula. Unauthorized strings stay in
* the denominator, so a file with any unauthorized string can never reach
* 100% (otherwise source-language text bleeds through into the download).
* The result is floored: 99.9999% reports as 99%.
* @param {Object} params
* @param {number} params.totalStringCount - Strings in the file.
* @param {number} [params.completedStringCount] - Published strings.
* @param {number} [params.excludedStringCount] - Strings excluded from
* translation.
* @returns {number} Progress percentage, 0-100; 100 only when nothing is
* left to translate or every non-excluded string is complete.
*/
export function translationProgress({
totalStringCount, completedStringCount = 0, excludedStringCount = 0,
}) {
const translatable = totalStringCount - excludedStringCount;
if (translatable === 0) return 100;
return Math.floor((completedStringCount / translatable) * 100);
}

/**
* Fetches per-locale string counts for one file. Unlike the job progress
* endpoint this is not job-scoped and reports on unauthorized strings via
* the file-level `totalStringCount`.
* @param {Object} params
* @param {string} params.org - The DA org.
* @param {string} params.site - The DA site.
* @param {string} params.env - The environment key (e.g. 'prod').
* @param {string} params.endpoint - The resolved Smartling API origin.
* @param {string} params.projectId - The Smartling project id.
* @param {string} params.jobUid - The job to check progress for.
* @returns {Promise<Object[]|null>} Each locale's `{ targetLocaleId,
* percentComplete }`, or null on failure. `percentComplete` is reported
* as 100 when Smartling has no content at all for that locale in this
* job (its own `progress` field is `null`, not a 0% in-progress state) -
* matching the "No content for translation" status Smartling's dashboard
* shows for it.
* @param {string} params.fileUri - The file's DA base path.
* @returns {Promise<{totalStringCount: number, items: Object[]}|{error: string, status: number}>}
* The file's total string count and per-locale `{ localeId,
* authorizedStringCount, completedStringCount, excludedStringCount }`
* items, or an error object on failure.
*/
async function fetchJobProgress({
org, site, env, endpoint, projectId, jobUid,
async function fetchFileStatus({
org, site, env, endpoint, projectId, fileUri,
}) {
const url = `${endpoint}/jobs-api/v3/projects/${projectId}/jobs/${jobUid}/progress`;
const url = new URL(`${endpoint}/files-api/v2/projects/${projectId}/file/status`);
url.searchParams.set('fileUri', fileUri);
const opts = { headers: { Authorization: `Bearer ${getToken(org, site, env)}` } };

const resp = await fetchWithRetry(url, opts, { onUnauthorized: onUnauthorized(opts) });
if (!resp.ok) return null;
if (!resp.ok) return { error: `Could not get status for ${fileUri}`, status: resp.status };
const { response } = await resp.json();
const { contentProgressReport = [] } = response?.data || {};
return contentProgressReport.map(({ targetLocaleId, progress }) => ({
targetLocaleId,
percentComplete: progress === null ? 100 : (progress?.percentComplete ?? 0),
}));
const { totalStringCount = 0, items = [] } = response?.data || {};
if (totalStringCount <= 0) return { error: `Smartling reported no strings for ${fileUri}`, status: resp.status };
return { totalStringCount, items };
}

/**
* Refreshes translation status for every target language of a job via
* Smartling's getJobProgress endpoint.
* Computes one language's progress (0-100) for every file.
* @param {Object} params
* @param {string} params.code - The language's locale code.
* @param {Object[]} params.fileStatuses - Results of `fetchFileStatus`.
* @returns {number[]} One progress value per file; a locale Smartling
* doesn't list for a file counts as 0, never as complete.
*/
function getLangProgress({ code, fileStatuses }) {
return fileStatuses.map(({ totalStringCount, items }) => {
const item = items.find((entry) => entry.localeId === code);
return item ? translationProgress({ totalStringCount, ...item }) : 0;
});
}

/**
* Refreshes translation status for every target language of a job from
* per-file, per-locale string counts (Smartling's
* getFileTranslationStatusAllLocales), instead of the job-scoped
* `percentComplete`, which ignores unauthorized strings and reports `null`
* for locales it has no content for.
* @param {Object} params
* @param {string} params.org - The DA org.
* @param {string} params.site - The DA site.
* @param {Object} params.service - The service configuration; reads
* `jobUid.value` (set by `sendAllLanguages`).
* `jobUid.value` (set by `sendAllLanguages`) only to confirm something
* was sent.
* @param {Object[]} params.langs - Target languages; mutated in place with
* `translation.status` ('translated' once Smartling reports 100% for
* that locale, otherwise Smartling's real progress percentage, e.g.
* '62% translated') and `translation.translated` (`urls.length` once
* translated, otherwise `0` - this endpoint reports one job-wide
* percentage per locale, not a per-file breakdown). A lang already at
* `'complete'` or `'cancelled'` is left untouched - both are terminal,
* and Smartling keeps reporting 100% indefinitely, which would otherwise
* look "newly finished" on every subsequent check. If every lang is
* already terminal, skips the API call entirely.
* `translation.status` ('translated' only once every file is at 100% for
* that locale, otherwise the lowest per-file progress, e.g. '62%
* translated') and `translation.translated` (number of files at 100%).
* A lang already at `'complete'` or `'cancelled'` is left untouched. If
* every lang is already terminal, skips the API calls entirely.
* @param {Object[]} params.urls - The urls in the project.
* @param {Object} params.actions - `{ saveState, sendMessage }` callbacks;
* `sendMessage` surfaces an error if no job has been created yet, or if
* the progress request itself fails.
* any file status request fails.
* @returns {Promise<void>}
*/
export async function getStatusAll({
Expand All @@ -416,32 +453,37 @@ export async function getStatusAll({
return;
}

// 'complete'/'cancelled' are terminal - Smartling keeps reporting 100%
// translated forever once done, so without this guard every subsequent
// status check would revert 'complete' back to 'translated' (triggering
// a re-save) or 'cancelled' back to 'translated' (undoing the cancel).
const activeLangs = langs.filter((l) => !['complete', 'cancelled'].includes(l.translation.status));
// 'complete'/'cancelled' are terminal - Smartling keeps reporting a file
// as translated forever once done, so without this guard every
// subsequent status check would revert 'complete' back to 'translated'
// (triggering a re-save) or 'cancelled' back to 'translated'.
const activeLangs = langs.filter((l) => !TERMINAL_STATUSES.includes(l.translation.status));
if (!activeLangs.length) return;

const progressByLocale = await fetchJobProgress({
org, site, env, endpoint, projectId, jobUid: jobUid.value,
});
if (!progressByLocale) {
sendMessage({ text: 'Checking status failed: could not reach Smartling.', type: 'error' });
const fileStatuses = [];
const queue = new Queue(async (url) => {
fileStatuses.push(await fetchFileStatus({
org, site, env, endpoint, projectId, fileUri: url.daBasePath,
}));
}, MAX_CONCURRENT_STATUS);
await Promise.all(urls.map((url) => queue.push(url)));

const failed = fileStatuses.find((fileStatus) => fileStatus.error);
if (failed) {
sendMessage({ text: `Checking status failed: ${failed.error}.`, type: 'error' });
return;
}

for (const lang of activeLangs) {
const entry = progressByLocale.find((p) => p.targetLocaleId === lang.code);
const percentComplete = entry?.percentComplete ?? 0;
// A save can finish while the requests are in flight; don't overwrite it.
const stillActive = activeLangs.filter((l) => !TERMINAL_STATUSES.includes(l.translation.status));

if (percentComplete === 100) {
lang.translation.translated = urls.length;
lang.translation.status = 'translated';
} else {
lang.translation.translated = 0;
lang.translation.status = `${percentComplete}% translated`;
}
for (const lang of stillActive) {
const progress = getLangProgress({ code: lang.code, fileStatuses });
const translated = progress.filter((percent) => percent === 100).length;
lang.translation.translated = translated;
lang.translation.status = translated === progress.length
? 'translated'
: `${Math.min(...progress)}% translated`;
}

await saveState();
Expand Down
19 changes: 10 additions & 9 deletions nx/blocks/loc/views/translate/translate.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,18 +208,19 @@ class NxLocTranslate extends LitElement {
}

async handleGetStatus() {
// if (!this.incompleteLangs) {
// this.handleMessage({ text: 'All languages complete or cancelled.' });
// return;
// }

const conf = await this.getBaseTranslationConf(false);
if (this._statusBusy) return;
this._statusBusy = true;
try {
const conf = await this.getBaseTranslationConf(false);

await this._service.connector.getStatusAll(removeWaitingLanguagesFromConf(conf));
await this._service.connector.getStatusAll(removeWaitingLanguagesFromConf(conf));

await this.checkAndSaveLangs(conf);
await this.checkAndSaveLangs(conf);

this.handleSaveLangs();
this.handleSaveLangs();
} finally {
this._statusBusy = false;
}
}

async handleCancelAll() {
Expand Down
Loading
Loading