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
96 changes: 79 additions & 17 deletions nx/blocks/loc/connectors/trados/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,46 +291,108 @@ export function getSourceFileStatus(tasks) {
return null;
}

// Trados project templates vary in which workflow steps they include (e.g.
// TM-leverage-only projects have no 'file-delivery' task at all), so there's
// no single task type reliable across templates to key completion off of.
// Instead, a language is done once none of its tasks are still pending -
// these are the terminal statuses a task can settle into.
const TERMINAL_TASK_STATUSES = ['completed', 'failed', 'skipped', 'canceled'];

export function getLangStatus(tasks, langCode, fileCount) {
const langTasks = tasks.filter((task) => (
task.input?.targetFile?.languageDirection?.targetLanguage?.languageCode === langCode
));

// Translated file count for this lang
const translated = langTasks.filter((t) => (
t.taskType?.key === 'file-delivery' && t.status === 'completed'
)).length;
if (langTasks.some((t) => t.status === 'failed')) return { status: 'error', translated: 0 };

// Empty langTasks is "no data yet", not "done" - every() is vacuously
// true on an empty array, so this must be checked explicitly.
const allTerminal = langTasks.length > 0
&& langTasks.every((t) => TERMINAL_TASK_STATUSES.includes(t.status));
if (allTerminal) return { status: 'translated', translated: fileCount };

return { status: 'in progress', translated: 0 };
}

const TASKS_PAGE_LIMIT = 100;

if (langTasks.some((t) => t.status === 'failed')) return { status: 'error', translated };
if (translated === fileCount) return { status: 'translated', translated };
/**
* Fetches every task for a project, paging through Trados's tasks list
* rather than taking the first page as the complete set - a project with
* more tasks than one page (e.g. many files x languages x workflow steps)
* would otherwise silently undercount completed work.
* @param {Object} service - The service configuration.
* @param {string} projectId - The Trados project id.
* @returns {Promise<Object[]|null>} All tasks, or null if any page fails.
*/
async function fetchAllTasks(service, projectId) {
const { apiEndpoint } = service;
const tasks = [];
let offset = 0;
let itemCount = Infinity;

while (offset < itemCount) {
// eslint-disable-next-line no-await-in-loop
const opts = await getOpts(service);
const url = `${apiEndpoint}/projects/${projectId}/tasks`
+ `?fields=taskType,status,input.targetFile&offset=${offset}&limit=${TASKS_PAGE_LIMIT}`;
// eslint-disable-next-line no-await-in-loop
const resp = await corsFetch(url, opts);
if (!resp.ok) return null;

// eslint-disable-next-line no-await-in-loop
const json = await resp.json();
const items = json.items || [];
tasks.push(...items);
itemCount = json.itemCount ?? tasks.length;

// Guard against an infinite loop if itemCount is ever wrong.
if (!items.length) break;
offset += items.length;
}

return { status: 'in progress', translated };
return tasks;
}

/**
* Refreshes translation status for every target language of a project by
* polling Trados's task list.
* @param {Object} params
* @param {Object} params.service - The service configuration.
* @param {Object[]} params.langs - Target languages; mutated in place with
* `translation.status`/`translation.translated`. A lang already at
* `'complete'` (saved to DA) or `'cancelled'` is left untouched - both
* are terminal, and Trados keeps reporting completed file-delivery
* tasks indefinitely, which would otherwise look "newly finished" (or
* un-cancel a cancelled lang) on every subsequent check.
* @param {Object[]} params.urls - The urls in the project.
* @param {Object} params.actions - `{ sendMessage, saveState }` callbacks.
* @returns {Promise<void>}
*/
export async function getStatusAll({ service, langs, urls, actions }) {
const { sendMessage, saveState } = actions;
const { apiEndpoint } = service;

const projectId = langs[0]?.translation?.projectId;
if (!projectId) return;

const localesStr = langs.map((lang) => lang.code).join(', ');
sendMessage({ text: `Getting status for ${localesStr}` });

const opts = await getOpts(service);
const resp = await corsFetch(
`${apiEndpoint}/projects/${projectId}/tasks?fields=taskType,status,input.targetFile`,
opts,
);
if (!resp.ok) return;

const json = await resp.json();
const tasks = json.items || [];
const tasks = await fetchAllTasks(service, projectId);
if (!tasks) return;

const sourceError = getSourceFileStatus(tasks);

langs.forEach((lang) => {
lang.translation ??= {};

// 'complete'/'cancelled' are terminal - Trados keeps reporting
// completed file-delivery tasks indefinitely, so without this guard
// every subsequent status check would revert 'complete' back to
// 'translated' (triggering a re-save) or 'cancelled' back to
// 'translated' (undoing a cancel).
if (['complete', 'cancelled'].includes(lang.translation.status)) return;

if (sourceError) {
lang.translation.status = sourceError;
} else {
Expand Down
154 changes: 143 additions & 11 deletions test/loc/trados/getStatusAll.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect } from '@esm-bundle/chai';
import { readFile } from '@web/test-runner-commands';
import { getSourceFileStatus, getLangStatus } from '../../../nx/blocks/loc/connectors/trados/index.js';
import { getSourceFileStatus, getLangStatus, getStatusAll } from '../../../nx/blocks/loc/connectors/trados/index.js';

async function loadMock(name) {
const text = await readFile({ path: `./mocks/${name}.json` });
Expand Down Expand Up @@ -57,26 +57,27 @@ describe('getSourceFileStatus', () => {
// --- getLangStatus ---

describe('getLangStatus', () => {
it('should return translated when all file-delivery tasks completed (de-DE)', () => {
it('should return translated when every task for the lang is terminal (de-DE)', () => {
const result = getLangStatus(allCompleted.items, 'de-DE', 1);
expect(result.status).to.equal('translated');
expect(result.translated).to.equal(1);
});

it('should return translated when all file-delivery tasks completed (fr-FR)', () => {
it('should return translated when every task for the lang is terminal (fr-FR)', () => {
const result = getLangStatus(allCompleted.items, 'fr-FR', 1);
expect(result.status).to.equal('translated');
expect(result.translated).to.equal(1);
});

it('should return in progress when delivery not complete for lang', () => {
// lang-partial has de-DE delivered but fr-FR only through machine-translation
it('should return in progress when a lang task is still non-terminal', () => {
// lang-partial has de-DE fully terminal but fr-FR still has an
// in-progress generate-target task
const result = getLangStatus(langPartial.items, 'fr-FR', 1);
expect(result.status).to.equal('in progress');
expect(result.translated).to.equal(0);
});

it('should return translated for lang that is fully delivered', () => {
it('should return translated for a lang whose tasks are all terminal', () => {
const result = getLangStatus(langPartial.items, 'de-DE', 1);
expect(result.status).to.equal('translated');
expect(result.translated).to.equal(1);
Expand All @@ -103,15 +104,146 @@ describe('getLangStatus', () => {
});

it('should return translated count even on error', () => {
// lang-failed has de-DE file-delivery completed but fr-FR failed
// lang-failed has de-DE completed but fr-FR failed
const result = getLangStatus(langFailed.items, 'fr-FR', 1);
expect(result.translated).to.equal(0);
});

it('should return in progress when fileCount exceeds delivered', () => {
// all-completed has 1 file-delivery per lang, but we say there are 5 files
it('does not cross-check fileCount against actual task data - status only reflects whether known tasks are terminal', () => {
// Deliberate behavior: unlike the old file-delivery-counting approach,
// this can't detect "fewer files processed than expected" - only
// whether every task Trados has reported so far is terminal. A
// fileCount that doesn't match reality no longer forces 'in progress'.
const result = getLangStatus(allCompleted.items, 'de-DE', 5);
expect(result.status).to.equal('in progress');
expect(result.translated).to.equal(1);
expect(result.status).to.equal('translated');
expect(result.translated).to.equal(5);
});
});

// --- getStatusAll ---

describe('getStatusAll', () => {
let origFetch;
let counter = 0;

const uniq = (label) => {
counter += 1;
return `${label}-${counter}-${Math.floor(Math.random() * 1e6)}`;
};

function installFetch(tasks) {
origFetch = window.fetch;
window.fetch = async (url) => {
// corsFetch proxies through ?url=<encodeURIComponent(target)>, so
// decode before substring-matching the real target path.
const u = decodeURIComponent(url.toString());
if (u.includes('/integrations/trados/login')) {
return new Response(JSON.stringify({ access_token: 'test-token', expires_in: 3600 }), { status: 200 });
}
if (u.includes('/tasks')) {
return new Response(JSON.stringify({ items: tasks }), { status: 200 });
}
return new Response('{}', { status: 200 });
};
}

afterEach(() => {
if (origFetch) window.fetch = origFetch;
origFetch = null;
});

it('does not revert a lang already saved to DA back to "translated"', async () => {
installFetch(allCompleted.items);

const service = {
org: uniq('org'), site: uniq('site'), env: 'prod', tenantId: 'tenant-1', apiEndpoint: 'https://api.sdl.com',
};
const langs = [{ code: 'de-DE', translation: { projectId: 'proj-1', status: 'complete', translated: 1 } }];
const urls = [{}];
const actions = { sendMessage: () => {}, saveState: async () => {} };

await getStatusAll({ service, langs, urls, actions });

expect(langs[0].translation.status).to.equal('complete');
});

it('does not revert a cancelled lang back to "translated"', async () => {
installFetch(allCompleted.items);

const service = {
org: uniq('org'), site: uniq('site'), env: 'prod', tenantId: 'tenant-1', apiEndpoint: 'https://api.sdl.com',
};
const langs = [{ code: 'de-DE', translation: { projectId: 'proj-1', status: 'cancelled', translated: 0 } }];
const urls = [{}];
const actions = { sendMessage: () => {}, saveState: async () => {} };

await getStatusAll({ service, langs, urls, actions });

expect(langs[0].translation.status).to.equal('cancelled');
});

it('still updates a lang that is not yet complete', async () => {
installFetch(allCompleted.items);

const service = {
org: uniq('org'), site: uniq('site'), env: 'prod', tenantId: 'tenant-1', apiEndpoint: 'https://api.sdl.com',
};
const langs = [{ code: 'de-DE', translation: { projectId: 'proj-1', status: 'in progress', translated: 0 } }];
const urls = [{}];
const actions = { sendMessage: () => {}, saveState: async () => {} };

await getStatusAll({ service, langs, urls, actions });

expect(langs[0].translation.status).to.equal('translated');
expect(langs[0].translation.translated).to.equal(1);
});

it('pages through the tasks list instead of only reading the first page', async () => {
// de-DE's only task deliberately lands past the first page's limit, so
// this only passes if getStatusAll actually fetches subsequent pages.
const fillerTasks = Array.from({ length: 100 }, (_, i) => ({
id: `filler-${i}`,
status: 'completed',
taskType: { key: 'scan' },
input: { type: 'sourceFile' },
}));
const deDeTask = {
id: 'de-de-completed',
status: 'completed',
taskType: { key: 'file-delivery' },
input: {
type: 'targetFile',
targetFile: { languageDirection: { targetLanguage: { languageCode: 'de-DE' } } },
},
};
const allTasks = [...fillerTasks, deDeTask];

origFetch = window.fetch;
window.fetch = async (url) => {
const u = decodeURIComponent(url.toString());
if (u.includes('/integrations/trados/login')) {
return new Response(JSON.stringify({ access_token: 'test-token', expires_in: 3600 }), { status: 200 });
}
if (u.includes('/tasks')) {
const offset = Number(u.match(/offset=(\d+)/)?.[1] ?? 0);
const limit = Number(u.match(/limit=(\d+)/)?.[1] ?? 100);
const page = allTasks.slice(offset, offset + limit);
const body = { items: page, itemCount: allTasks.length };
return new Response(JSON.stringify(body), { status: 200 });
}
return new Response('{}', { status: 200 });
};

const service = {
org: uniq('org'), site: uniq('site'), env: 'prod', tenantId: 'tenant-1', apiEndpoint: 'https://api.sdl.com',
};
const langs = [{ code: 'de-DE', translation: { projectId: 'proj-1', status: 'in progress', translated: 0 } }];
const urls = [{}];
const actions = { sendMessage: () => {}, saveState: async () => {} };

await getStatusAll({ service, langs, urls, actions });

expect(langs[0].translation.status).to.equal('translated');
expect(langs[0].translation.translated).to.equal(1);
});
});
30 changes: 29 additions & 1 deletion test/loc/trados/mocks/lang-partial.json
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,35 @@
}
}
}
},
{
"id": "698f5c169166be4bda17c05f",
"status": "in-progress",
"taskType": {
"id": "696e31eeea8e8f457904fa4d",
"key": "generate-target",
"name": "Target File Generation",
"apiInternalId": "generate-target"
},
"input": {
"type": "targetFile",
"targetFile": {
"id": "698f5c0c641b3318a8caf5d7",
"name": "agilent-trados-demo.html",
"languageDirection": {
"sourceLanguage": {
"languageCode": "en-US",
"englishName": "English (United States)"
},
"targetLanguage": {
"languageCode": "fr-FR",
"englishName": "French (France)"
},
"id": "698f5c0693dfbc0f0e61c024"
}
}
}
}
],
"itemCount": 7
"itemCount": 8
}
Loading