From fb24a6d9128e9c19367eae1240e6d3c76f03f238 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 12:42:51 -0500 Subject: [PATCH 1/3] Update worklog --- WORKLOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/WORKLOG.md b/WORKLOG.md index 429b4b1b2..801d99798 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -1,5 +1,13 @@ # Worklog +## 2026-08-14 + +### trados connector — stop re-saving already-completed languages + +Same bug as Smartling's `getStatusAll` (fixed on the separate `smartling-connector-enhancements` branch): `langs.forEach` unconditionally overwrote `lang.translation.status` from Trados's task list, with no guard against an already-`'complete'` status. Trados keeps reporting completed file-delivery tasks indefinitely, so every subsequent "Get status" click reverted a `'complete'` lang back to `'translated'`, re-triggering a redundant download/save. Fixed by skipping langs already at `'complete'` or `'cancelled'` (Trados has no `cancelTranslation` yet, but added the guard for consistency/future-proofing). + +`test/loc/trados/getStatusAll.test.js` previously only tested the pure helpers (`getSourceFileStatus`/`getLangStatus`) — no coverage of `getStatusAll` itself. Added 3 tests against mocked `fetch` (Trados login + the `corsFetch`-proxied tasks call): guard holds for `'complete'`, guard holds for `'cancelled'`, non-terminal langs still update normally. + ## 2026-08-07 ### nx2/utils/api.js — remove stage-content.da.live rewrite workaround From 65a320389cce35399f5ae2e0351c79f9bb35a484 Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Fri, 14 Aug 2026 12:43:21 -0500 Subject: [PATCH 2/3] fix: stop re-saving Trados languages already completed to DA getStatusAll unconditionally overwrote lang.translation.status from Trados's task list, with no guard against an already-'complete' status. Trados keeps reporting completed file-delivery tasks indefinitely, so every subsequent "Get status" click reverted a 'complete' lang back to 'translated', causing checkAndSaveLangs to re-download and re-save it every time. Fixed by treating 'complete' and 'cancelled' as terminal, mirroring the equivalent Smartling fix. Co-Authored-By: Claude Sonnet 5 --- nx/blocks/loc/connectors/trados/index.js | 23 +++++++ test/loc/trados/getStatusAll.test.js | 81 +++++++++++++++++++++++- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/nx/blocks/loc/connectors/trados/index.js b/nx/blocks/loc/connectors/trados/index.js index 6738fb342..f27c193a4 100644 --- a/nx/blocks/loc/connectors/trados/index.js +++ b/nx/blocks/loc/connectors/trados/index.js @@ -305,6 +305,21 @@ export function getLangStatus(tasks, langCode, fileCount) { return { status: 'in progress', translated }; } +/** + * 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} + */ export async function getStatusAll({ service, langs, urls, actions }) { const { sendMessage, saveState } = actions; const { apiEndpoint } = service; @@ -329,6 +344,14 @@ export async function getStatusAll({ service, langs, urls, actions }) { 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 { diff --git a/test/loc/trados/getStatusAll.test.js b/test/loc/trados/getStatusAll.test.js index 5d1861fe1..933b49a40 100644 --- a/test/loc/trados/getStatusAll.test.js +++ b/test/loc/trados/getStatusAll.test.js @@ -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` }); @@ -115,3 +115,82 @@ describe('getLangStatus', () => { expect(result.translated).to.equal(1); }); }); + +// --- 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=, 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); + }); +}); From 02473377e9fd50e4c87c7d170baf439ecb7a69fe Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Fri, 4 Sep 2026 13:37:50 -0500 Subject: [PATCH 3/3] fix(translate): detect Trados completion without a specific task type - getLangStatus now considers a language done once none of its tasks are left in a non-terminal status, instead of requiring a 'file-delivery' task specifically - some project templates (e.g. TM-leverage-only workflows) never create that task type, leaving languages stuck at "in progress" in DA even though Trados has finished them - Add pagination to the tasks fetch (fetchAllTasks) - it only returned the first 100 tasks per project, silently undercounting completed work on larger projects - Verified against a real affected project (via a throwaway smoke test, not committed): all 5 languages now resolve to "translated" Co-Authored-By: Claude --- nx/blocks/loc/connectors/trados/index.js | 73 ++++++++++++++++++------ test/loc/trados/getStatusAll.test.js | 73 ++++++++++++++++++++---- test/loc/trados/mocks/lang-partial.json | 30 +++++++++- 3 files changed, 148 insertions(+), 28 deletions(-) diff --git a/nx/blocks/loc/connectors/trados/index.js b/nx/blocks/loc/connectors/trados/index.js index 02242af98..8c257cd29 100644 --- a/nx/blocks/loc/connectors/trados/index.js +++ b/nx/blocks/loc/connectors/trados/index.js @@ -291,20 +291,67 @@ 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} 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; } /** @@ -324,7 +371,6 @@ export function getLangStatus(tasks, langCode, fileCount) { */ export async function getStatusAll({ service, langs, urls, actions }) { const { sendMessage, saveState } = actions; - const { apiEndpoint } = service; const projectId = langs[0]?.translation?.projectId; if (!projectId) return; @@ -332,15 +378,8 @@ export async function getStatusAll({ service, langs, urls, actions }) { 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); diff --git a/test/loc/trados/getStatusAll.test.js b/test/loc/trados/getStatusAll.test.js index 933b49a40..cc3f69666 100644 --- a/test/loc/trados/getStatusAll.test.js +++ b/test/loc/trados/getStatusAll.test.js @@ -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); @@ -103,16 +104,19 @@ 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); }); }); @@ -193,4 +197,53 @@ describe('getStatusAll', () => { 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); + }); }); diff --git a/test/loc/trados/mocks/lang-partial.json b/test/loc/trados/mocks/lang-partial.json index 038766227..c5f096adf 100644 --- a/test/loc/trados/mocks/lang-partial.json +++ b/test/loc/trados/mocks/lang-partial.json @@ -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 }