From 986e46ec6f105b448f92784f6cc558c1354daf38 Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Tue, 25 Aug 2026 16:49:26 -0500 Subject: [PATCH 01/13] Add GlobalLink loc connector, routed through the DA_TRANSLATE proxy Implements isConnected/connect/sendAllLanguages/getStatusAll/saveItems/ cancelTranslation against GlobalLink's REST API (submission create, source upload, save/autostart, status polling, deliverable download, and per-language cancel via targetIds), matching the destructured-object contract the loc UI actually calls (verified against Smartling/Trados) rather than the positional-arg signatures the connector originally shipped with. Requests are proxied through DA_TRANSLATE (new /translate/globallink// route in da-translate) instead of hitting GlobalLink directly from the browser, with the real per-site endpoint passed via an x-globallink-origin header that the proxy validates against an allowlist. Fixes found by exercising the connector against a live GlobalLink pilot account: missing customAttributes support (some projects require mandatory submission attributes), pageSize=500 exceeding the API's 200 max, waitForSubmissionReady not recognizing the real "PROCESSED" status, saveAndAutostart trusting HTTP 200 when the body says the submission didn't actually start, and missing `lang.translation ??= {}` guards that crashed sendAllLanguages/getStatusAll on languages with no prior translation state. DA_TRANSLATE is now resolved via getEnv (matching DA_ETC/DA_FEEDBACK) so it can point at a local wrangler dev server instead of only production. --- nx/blocks/loc/connectors/globallink/index.js | 874 +++++++++++++++++++ nx2/utils/utils.js | 8 +- 2 files changed, 881 insertions(+), 1 deletion(-) create mode 100644 nx/blocks/loc/connectors/globallink/index.js diff --git a/nx/blocks/loc/connectors/globallink/index.js b/nx/blocks/loc/connectors/globallink/index.js new file mode 100644 index 000000000..0f7f15a40 --- /dev/null +++ b/nx/blocks/loc/connectors/globallink/index.js @@ -0,0 +1,874 @@ +import { Queue } from '../../../../../nx2/public/utils/tree.js'; +import { addDnt, removeDnt } from '../../dnt/dnt.js'; +import { DA_TRANSLATE } from '../../../../../nx2/utils/utils.js'; + +export const dnt = { addDnt }; + +const BATCH_NAME = 'Batch1'; +const DEFAULT_DUE_DATE_DAYS = 7; +const DEFAULT_TOKEN_TTL_MS = 3600000; +const REFRESH_BUFFER_MS = 60000; +const PROCESS_POLL_MS = 2000; +const PROCESS_POLL_MAX = 60; + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; +const ORIGIN_HEADER = 'x-globallink-origin'; + +let token; +let tokenPolling; + +/** + * Builds the DA_TRANSLATE proxy origin GlobalLink requests are routed through, so the + * browser never calls GlobalLink's API directly (avoids CORS and keeps a single, + * DA-controlled network path for the connector). + * @param {object} service - The flattened per-environment service config. + * @param {string} service.org - The DA org. + * @param {string} service.site - The DA site. + * @returns {string|null} The proxy origin, or `null` if org/site are missing. + */ +function resolveOrigin(service) { + const { org, site } = service; + if (!org || !site) return null; + return `${DA_TRANSLATE}/translate/globallink/${org}/${site}`; +} + +/** + * Builds the header that tells the DA_TRANSLATE proxy which real GlobalLink deployment + * to forward the request to. The proxy validates this against its own allowlist before + * forwarding, so the real endpoint stays driven by org/site config rather than hardcoded + * in the proxy itself. + * @param {object} service - The flattened per-environment service config. + * @param {string} service.endpoint - The real GlobalLink API base endpoint, as configured + * in the site's `.da/translate.json`. + * @returns {{[ORIGIN_HEADER]: string}} The header to merge into every proxied request. + */ +function originHeader(service) { + return { [ORIGIN_HEADER]: service.endpoint }; +} + +/** + * Builds the localStorage key used to persist a service's OAuth token details. + * @param {string} name - The connector/service display name (e.g. "GlobalLink"). + * @param {string} env - The selected environment (e.g. "prod"). + * @returns {string} The localStorage key. + */ +function tokenKey(name, env) { + return `${name.toLowerCase()}.${env}.token`; +} + +/** + * Caches the current access token in memory and persists the full token + * details (with expiry) to localStorage for reuse across page loads. + * @param {string} name - The connector/service display name. + * @param {string} env - The selected environment. + * @param {string} accessToken - The OAuth access token. + * @param {string} refreshToken - The OAuth refresh token. + * @param {number|string} expiresIn - Token lifetime in seconds, as returned by the OAuth server. + * @returns {number} The token's time-to-live in milliseconds. + */ +function setTokenDetails(name, env, accessToken, refreshToken, expiresIn) { + token = accessToken; + const ttlMs = (Number(expiresIn) * 1000) || DEFAULT_TOKEN_TTL_MS; + const expires = Date.now() + ttlMs; + localStorage.setItem(tokenKey(name, env), JSON.stringify({ + accessToken, + refreshToken, + expires, + })); + return ttlMs; +} + +/** + * Reads the persisted OAuth token details for a service/environment from localStorage. + * @param {string} name - The connector/service display name. + * @param {string} env - The selected environment. + * @returns {{accessToken?: string, refreshToken?: string, expires?: number}} The stored token + * details, or an empty object if none are stored or the stored value is invalid JSON. + */ +function getTokenDetails(name, env) { + const lsTokenDetails = localStorage.getItem(tokenKey(name, env)); + if (!lsTokenDetails) return {}; + try { + return JSON.parse(lsTokenDetails); + } catch { + return {}; + } +} + +/** + * Clears the in-memory access token, stops the refresh-polling interval, and + * removes the persisted token details for a service/environment. + * @param {string} name - The connector/service display name. + * @param {string} env - The selected environment. + * @returns {void} + */ +function clearToken(name, env) { + token = undefined; + if (tokenPolling) { + clearInterval(tokenPolling); + tokenPolling = undefined; + } + localStorage.removeItem(tokenKey(name, env)); +} + +/** + * Builds the bearer-auth + JSON + proxy-origin headers used for authenticated + * GlobalLink API calls routed through the DA_TRANSLATE proxy. + * @param {object} service - The flattened per-environment service config. + * @param {string} service.endpoint - The real GlobalLink API base endpoint. + * @returns {object} The request headers. + */ +function authHeaders(service) { + return { + Authorization: `Bearer ${token}`, + ...originHeader(service), + ...JSON_HEADERS, + }; +} + +/** + * Builds a Basic auth header value from an OAuth client id/secret pair. + * @param {string} client - The OAuth client id. + * @param {string} secret - The OAuth client secret. + * @returns {string} The `Basic ` header value. + */ +function basicAuthHeader(client, secret) { + return `Basic ${btoa(`${client}:${secret}`)}`; +} + +/** + * Requests a new OAuth token (password or refresh grant) from GlobalLink, via the + * DA_TRANSLATE proxy. + * @param {object} service - The flattened per-environment service config. + * @param {string} service.org - The DA org, used to resolve the DA_TRANSLATE proxy origin. + * @param {string} service.site - The DA site, used to resolve the DA_TRANSLATE proxy origin. + * @param {string} service.endpoint - The real GlobalLink API base endpoint. + * @param {string} oauthClient - The OAuth client id. + * @param {string} oauthSecret - The OAuth client secret. + * @param {URLSearchParams} body - The grant-specific form-encoded request body. + * @returns {Promise} The parsed token response, or `null` on failure. + */ +async function requestToken(service, oauthClient, oauthSecret, body) { + const opts = { + method: 'POST', + headers: { + Authorization: basicAuthHeader(oauthClient, oauthSecret), + 'Content-Type': 'application/x-www-form-urlencoded', + ...originHeader(service), + }, + body, + }; + const resp = await fetch(`${resolveOrigin(service)}/oauth/token`, opts); + if (!resp.ok) return null; + return resp.json(); +} + +/** + * Attempts to refresh the current OAuth access token using the stored refresh token. + * Clears the token if there is no refresh token or the refresh request fails. + * @param {object} service - The flattened per-environment service config. + * @param {string} service.name - The connector/service display name. + * @param {string} service.env - The selected environment. + * @param {string} service.oauthClient - The OAuth client id. + * @param {string} service.oauthSecret - The OAuth client secret. + * @returns {Promise} Whether the token was refreshed successfully. + */ +async function refreshAccessToken(service) { + const { name, env, oauthClient, oauthSecret } = service; + const { refreshToken: currRefreshToken } = getTokenDetails(name, env); + if (!currRefreshToken) { + clearToken(name, env); + return false; + } + + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: currRefreshToken, + }); + + const json = await requestToken(service, oauthClient, oauthSecret, body); + if (!json?.access_token) { + clearToken(name, env); + return false; + } + + setTokenDetails( + name, + env, + json.access_token, + json.refresh_token || currRefreshToken, + json.expires_in, + ); + return true; +} + +/** + * (Re)starts the interval that proactively refreshes the OAuth access token + * shortly before it expires. + * @param {object} service - The flattened per-environment service config, forwarded to + * {@link refreshAccessToken} on each tick. + * @param {number} [ttlMs] - The current token's time-to-live in milliseconds. + * @returns {void} + */ +function refreshTheToken(service, ttlMs) { + if (tokenPolling) clearInterval(tokenPolling); + const interval = Math.max((ttlMs || DEFAULT_TOKEN_TTL_MS) - REFRESH_BUFFER_MS, REFRESH_BUFFER_MS); + tokenPolling = setInterval(() => { + refreshAccessToken(service); + }, interval); +} + +/** + * Derives a GlobalLink-safe upload file name from a DA base path, flattening + * any nested folders and ensuring an extension is present. + * @param {string} daBasePath - The DA-formatted base path (e.g. "/blog/post-1"). + * @returns {string} The flattened file name (e.g. "blog__post-1.html"). + */ +function toFileName(daBasePath) { + const trimmed = (daBasePath || '/document').replace(/^\//, ''); + const safe = trimmed.replace(/[\\/]/g, '__') || 'document'; + return /\.[a-z0-9]+$/i.test(safe) ? safe : `${safe}.html`; +} + +/** + * Computes a submission due date, N days from now, in epoch milliseconds. + * @param {number} days - The number of days until the submission is due. + * @returns {number} The due date as epoch milliseconds. + */ +function dueDateMs(days) { + return Date.now() + (days * 24 * 60 * 60 * 1000); +} + +/** + * Finds the DA url entry that corresponds to a GlobalLink target, matching + * first by the uploaded `clientIdentifier`, then falling back to file name matching. + * @param {object[]} urls - The DA url entries to search. + * @param {object} target - A GlobalLink target/document record. + * @returns {object|undefined} The matching url entry, if any. + */ +function matchUrl(urls, target) { + const clientId = target.clientIdentifier || target.client_identifier; + if (clientId) { + const byClient = urls.find((url) => url.daBasePath === clientId); + if (byClient) return byClient; + } + + const docName = target.documentName || target.name || target.documentNameWithPath || ''; + return urls.find((url) => { + const fileName = toFileName(url.daBasePath); + return docName === fileName + || docName.endsWith(`/${fileName}`) + || docName.endsWith(`\\${fileName}`) + || docName.includes(fileName); + }); +} + +/** + * Polls a submission's status until GlobalLink finishes processing the uploaded + * source files (or a maximum number of attempts is reached). + * @param {object} service - The flattened per-environment service config. + * @param {string|number} submissionId - The submission to poll. + * @returns {Promise} `false` if the submission reported an error/failure status; + * `true` otherwise (including the ambiguous/timeout case, since GlobalLink often finishes + * processing during save). + */ +async function waitForSubmissionReady(service, submissionId) { + for (let i = 0; i < PROCESS_POLL_MAX; i += 1) { + // eslint-disable-next-line no-await-in-loop + const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/status`, { + headers: authHeaders(service), + }); + if (resp.ok) { + // eslint-disable-next-line no-await-in-loop + const json = await resp.json(); + const status = (json.status || json.submissionStatus || json.processStatus || '').toString().toUpperCase(); + if (status.includes('ERROR') || status.includes('FAIL')) return false; + if (status.includes('READY') + || status.includes('CREATED') + || status.includes('IDLE') + || status.includes('COMPLETE') + || status.includes('PROCESSED') + || status === 'OK') { + return true; + } + } + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { setTimeout(resolve, PROCESS_POLL_MS); }); + } + // Proceed to save even if status stays ambiguous — PD often finishes during save. + return true; +} + +/** + * Extracts custom attribute values from the project options, mirroring the + * `translation.service.custom..` fields Trados/Lionbridge use for their + * own custom fields. GlobalLink projects can require mandatory custom attributes + * (e.g. `Custom_Mandatory`) that must be present at submission-create time, or + * `/save`/`/start` will fail even though the create call itself succeeds. + * @param {object} options - The full localization project options. + * @returns {{name: string, value: string}[]} The custom attributes to send with the submission. + */ +function extractCustomAttributes(options) { + const prefix = 'translation.service.custom.'; + return Object.entries(options || {}).reduce((acc, [key, value]) => { + if (!key.startsWith(prefix) || value === undefined || value === null || value === '') return acc; + // e.g. 'translation.service.custom.textarea.Custom_Mandatory' -> 'Custom_Mandatory' + const name = key.split('.').slice(4).join('.'); + if (name) acc.push({ name, value }); + return acc; + }, []); +} + +/** + * Creates a new GlobalLink submission (with one batch targeting all requested languages). + * @param {object} service - The flattened per-environment service config. + * @param {string|number} service.projectId - The GlobalLink project id. + * @param {string} title - The localization project title, used to build the submission name. + * @param {object[]} langs - The target languages, each with a `code` (BCP-47 locale). + * @param {string} sourceLanguage - The source language code. + * @param {number} dueDateDays - The number of days until the submission is due. + * @param {{name: string, value: string}[]} customAttributes - Any project-required custom + * attributes (e.g. a mandatory field), from {@link extractCustomAttributes}. + * @returns {Promise} The created submission id, or `null` on failure. + */ +async function createSubmission( + service, + title, + langs, + sourceLanguage, + dueDateDays, + customAttributes, +) { + const body = JSON.stringify({ + name: `${title}-${Date.now()}`, + dueDate: dueDateMs(dueDateDays), + projectId: Number(service.projectId) || service.projectId, + sourceLanguage, + instructions: `DA localization project: ${title}`, + ...(customAttributes.length ? { customAttributes } : {}), + batchInfos: [{ + targetLanguageInfos: langs.map((lang) => ({ targetLanguage: lang.code })), + targetFormat: 'TXLF', + name: BATCH_NAME, + }], + claimScope: 'LANGUAGE', + }); + + const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/create`, { + method: 'POST', + headers: authHeaders(service), + body, + }); + if (!resp.ok) return null; + const json = await resp.json(); + return json.submissionId ?? json.id ?? null; +} + +/** + * Uploads a single source document to a GlobalLink submission's default batch. + * @param {object} service - The flattened per-environment service config. + * @param {string} service.fileFormatName - The GlobalLink file format to upload as. + * @param {string|number} submissionId - The target submission id. + * @param {object} url - The DA url entry to upload. + * @param {string} url.daBasePath - The DA-formatted base path, used for the file name and + * as the GlobalLink `clientIdentifier` for later matching. + * @param {string} url.content - The document's HTML content (with DNT applied). + * @returns {Promise} Whether the upload succeeded. + */ +async function uploadSourceFile(service, submissionId, url) { + const body = new FormData(); + const fileName = toFileName(url.daBasePath); + const file = new Blob([url.content], { type: 'text/html' }); + + body.append('file', file, fileName); + body.append('batchName', BATCH_NAME); + body.append('fileFormatName', service.fileFormatName); + body.append('clientIdentifier', url.daBasePath); + + const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/upload/source`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, ...originHeader(service) }, + body, + }); + if (!resp.ok) return false; + + // processId is returned asynchronously; submission-level status is polled after all uploads. + return true; +} + +/** + * Saves a submission and requests that GlobalLink auto-start processing it. GlobalLink + * responds 200 even when the submission didn't actually start (e.g. a missing mandatory + * custom attribute), so success is read from `startedSubmissionIds` in the body, not + * just the HTTP status. + * @param {object} service - The flattened per-environment service config. + * @param {string|number} submissionId - The submission to save/start. + * @returns {Promise<{started: boolean, messages: string[]|null}>} Whether the submission + * actually started, plus any messages GlobalLink returned (e.g. explaining why it didn't). + */ +async function saveAndAutostart(service, submissionId) { + const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/save`, { + method: 'POST', + headers: authHeaders(service), + body: JSON.stringify({ autoStart: true }), + }); + if (!resp.ok) return { started: false, messages: null }; + + const json = await resp.json().catch(() => null); + const started = Array.isArray(json?.startedSubmissionIds) + && json.startedSubmissionIds.some((id) => String(id) === String(submissionId)); + return { started, messages: json?.messages ?? null }; +} + +/** + * Lists a submission's targets (per-document, per-language translation records), + * optionally filtered by status and/or target language. + * @param {object} service - The flattened per-environment service config. + * @param {string|number} submissionId - The submission whose targets to list. + * @param {object} [filters] - Optional query filters. + * @param {string} [filters.targetStatus] - Only return targets with this status. + * @param {string} [filters.targetLanguage] - Only return targets for this language. + * @returns {Promise} The matching targets, or an empty array on failure. + */ +async function listTargets(service, submissionId, { targetStatus, targetLanguage } = {}) { + const reqUrl = new URL(`${resolveOrigin(service)}/rest/v0/targets`); + reqUrl.searchParams.set('submissionIds', submissionId); + // 200 is the API's maximum page size — a larger value is rejected outright. + reqUrl.searchParams.set('pageSize', '200'); + if (targetStatus) reqUrl.searchParams.set('targetStatus', targetStatus); + if (targetLanguage) reqUrl.searchParams.set('targetLanguage', targetLanguage); + + const resp = await fetch(reqUrl, { headers: authHeaders(service) }); + if (!resp.ok) return []; + const json = await resp.json(); + if (Array.isArray(json)) return json; + if (Array.isArray(json?.targets)) return json.targets; + if (Array.isArray(json?.items)) return json.items; + return []; +} + +/** + * Extracts the target language code from a GlobalLink target record, tolerating + * the different field names seen across GlobalLink API versions. + * @param {object} target - A GlobalLink target/document record. + * @returns {string|undefined} The target language code, if present. + */ +function targetLanguageOf(target) { + return target.targetLanguage || target.language || target.locale || target.targetLocale; +} + +/** + * Determines whether a GlobalLink target has finished translation and is ready to download. + * @param {object} target - A GlobalLink target/document record. + * @returns {boolean} Whether the target's status indicates it is processed/complete. + */ +function isProcessed(target) { + const status = (target.targetStatus || target.status || '').toString().toUpperCase(); + return status === 'PROCESSED' || status === 'COMPLETED' || status === 'DELIVERED'; +} + +/** + * Determines whether a GlobalLink target was cancelled. + * @param {object} target - A GlobalLink target/document record. + * @returns {boolean} Whether the target's status indicates it was cancelled. + */ +function isCancelled(target) { + const status = (target.targetStatus || target.status || '').toString().toUpperCase(); + return status.includes('CANCEL'); +} + +/** + * Checks whether there is a currently valid GlobalLink session, refreshing the + * access token from a stored refresh token if needed. + * @param {object} service - The flattened per-environment service config. + * @param {string} service.name - The connector/service display name. + * @param {string} service.env - The selected environment. + * @param {string} service.org - The DA org, used to resolve the DA_TRANSLATE proxy origin. + * @param {string} service.site - The DA site, used to resolve the DA_TRANSLATE proxy origin. + * @param {string} service.endpoint - The real GlobalLink API base endpoint. + * @returns {Promise} Whether the connector is authenticated and ready to use. + */ +export async function isConnected(service) { + const { name, env } = service; + if (!resolveOrigin(service) || !service.endpoint) return false; + + const { expires, refreshToken, accessToken } = getTokenDetails(name, env); + const notExpired = expires > Date.now() + REFRESH_BUFFER_MS; + + if (accessToken && notExpired) { + token = accessToken; + if (!tokenPolling) { + refreshTheToken(service, expires - Date.now()); + } + return true; + } + + if (refreshToken) { + const ok = await refreshAccessToken(service); + if (ok) { + const details = getTokenDetails(name, env); + refreshTheToken(service, details.expires - Date.now()); + return true; + } + } + + return false; +} + +/** + * Authenticates with GlobalLink using the resource-owner password grant and + * starts the background token-refresh loop on success. + * @param {object} service - The flattened per-environment service config. + * @param {string} service.name - The connector/service display name. + * @param {string} service.env - The selected environment. + * @param {string} service.org - The DA org, used to resolve the DA_TRANSLATE proxy origin. + * @param {string} service.site - The DA site, used to resolve the DA_TRANSLATE proxy origin. + * @param {string} service.endpoint - The real GlobalLink API base endpoint. + * @param {string} service.oauthClient - The OAuth client id. + * @param {string} service.oauthSecret - The OAuth client secret. + * @param {string} service.username - The GlobalLink username. + * @param {string} service.password - The GlobalLink password. + * @returns {Promise} Whether authentication succeeded. + */ +export async function connect(service) { + const { + name, env, oauthClient, oauthSecret, username, password, + } = service; + const endpoint = resolveOrigin(service); + const hasCreds = oauthClient && oauthSecret && username && password; + + if (!endpoint || !service.endpoint || !hasCreds) return false; + + const body = new URLSearchParams({ + grant_type: 'password', + username, + password, + }); + + const json = await requestToken(service, oauthClient, oauthSecret, body); + if (!json?.access_token) return false; + + const ttlMs = setTokenDetails(name, env, json.access_token, json.refresh_token, json.expires_in); + refreshTheToken(service, ttlMs); + return true; +} + +/** + * Creates a GlobalLink submission for a set of languages, uploads the source + * documents, and starts the submission for translation. + * @param {object} conf - The translation-send configuration. + * @param {string} conf.title - The localization project title. + * @param {object} conf.service - The flattened per-environment service config (mutated + * in place with the created `submissionId`). + * @param {object} conf.options - The full localization project options, including any + * `translation.service.custom.*` fields required as GlobalLink submission custom attributes. + * @param {object[]} conf.langs - The target languages to send (mutated in place with + * `translation.sent`/`translation.status`). + * @param {object[]} conf.urls - The DA url entries (with content) to upload. + * @param {object} conf.actions - UI callback actions. + * @param {Function} conf.actions.sendMessage - Reports progress/status text to the UI. + * @param {Function} conf.actions.saveState - Persists the project state. + * @returns {Promise} + */ +export async function sendAllLanguages({ + title, service, options, langs, urls, actions, +}) { + const { sendMessage, saveState } = actions; + + if (!token) { + const connected = await isConnected(service); + if (!connected) { + sendMessage({ text: 'Not connected to GlobalLink.', type: 'error' }); + langs.forEach((lang) => { + lang.translation ??= {}; + lang.translation.status = 'error'; + }); + return; + } + } + + if (!service.projectId || !service.fileFormatName) { + sendMessage({ text: 'GlobalLink projectId and fileFormatName are required.', type: 'error' }); + langs.forEach((lang) => { + lang.translation ??= {}; + lang.translation.status = 'error'; + }); + return; + } + + const sourceLanguage = options?.['source.language']?.code || service.sourceLanguage || 'en-US'; + const dueDateDays = Number(service.dueDateDays) || DEFAULT_DUE_DATE_DAYS; + const customAttributes = extractCustomAttributes(options); + + sendMessage({ text: `Creating GlobalLink submission for: ${title}.` }); + const submissionId = await createSubmission( + service, + title, + langs, + sourceLanguage, + dueDateDays, + customAttributes, + ); + if (!submissionId) { + sendMessage({ text: 'Failed to create GlobalLink submission.', type: 'error' }); + langs.forEach((lang) => { + lang.translation ??= {}; + lang.translation.status = 'error'; + }); + return; + } + + // Persist for status / download + service.submissionId = { value: String(submissionId) }; + + sendMessage({ text: `Uploading ${urls.length} items to GlobalLink.` }); + let accepted = 0; + for (const url of urls) { + sendMessage({ text: `Uploading ${url.daBasePath}` }); + // eslint-disable-next-line no-await-in-loop + const ok = await uploadSourceFile(service, submissionId, url); + if (ok) accepted += 1; + } + + if (accepted !== urls.length) { + sendMessage({ text: `Uploaded ${accepted}/${urls.length} items — aborting save.`, type: 'error' }); + langs.forEach((lang) => { + lang.translation ??= {}; + lang.translation.sent = accepted; + lang.translation.status = 'error'; + }); + await saveState({ options }); + return; + } + + sendMessage({ text: 'Waiting for GlobalLink to finish processing uploads.' }); + await waitForSubmissionReady(service, submissionId); + + sendMessage({ text: 'Starting GlobalLink submission.' }); + const { started, messages } = await saveAndAutostart(service, submissionId); + if (!started) { + const detail = messages?.length ? ` ${messages.join(' ')}` : ''; + sendMessage({ text: `Failed to save/start GlobalLink submission.${detail}`, type: 'error' }); + langs.forEach((lang) => { + lang.translation ??= {}; + lang.translation.sent = accepted; + lang.translation.status = 'error'; + }); + await saveState({ options }); + return; + } + + langs.forEach((lang) => { + lang.translation ??= {}; + lang.translation.sent = accepted; + lang.translation.status = 'created'; + }); + + sendMessage(); + await saveState({ options }); +} + +/** + * Refreshes translation progress for a submission, marking languages as + * `translated` once every document has a processed target. + * @param {object} conf - The status-check configuration. + * @param {object} conf.service - The flattened per-environment service config, including + * the previously persisted `submissionId`. + * @param {object[]} conf.langs - The target languages to check (mutated in place with + * `translation.translated`/`translation.status`). + * @param {object[]} conf.urls - The DA url entries being translated, used to match targets. + * @param {object} conf.actions - UI callback actions. + * @param {Function} conf.actions.sendMessage - Reports progress/status text to the UI. + * @param {Function} conf.actions.saveState - Persists the project state. + * @returns {Promise} + */ +export async function getStatusAll({ service, langs, urls, actions }) { + const { sendMessage, saveState } = actions; + const submissionId = service.submissionId?.value; + + if (!submissionId) { + sendMessage({ text: 'No GlobalLink submissionId found for this project.', type: 'error' }); + return; + } + + if (!token) { + const connected = await isConnected(service); + if (!connected) { + sendMessage({ text: 'Not connected to GlobalLink.', type: 'error' }); + return; + } + } + + sendMessage({ text: `Checking GlobalLink status for submission ${submissionId}.` }); + + const targets = await listTargets(service, submissionId); + langs.forEach((lang) => { + lang.translation ??= {}; + lang.translation.translated = 0; + }); + + const targetCountByLang = {}; + const cancelledCountByLang = {}; + const processedByLang = {}; + targets.forEach((target) => { + const matched = matchUrl(urls, target); + if (!matched) return; + const langCode = targetLanguageOf(target); + if (!langCode) return; + + targetCountByLang[langCode] = (targetCountByLang[langCode] || 0) + 1; + if (isCancelled(target)) { + cancelledCountByLang[langCode] = (cancelledCountByLang[langCode] || 0) + 1; + } else if (isProcessed(target)) { + processedByLang[langCode] = (processedByLang[langCode] || 0) + 1; + } + }); + + langs.forEach((lang) => { + const targetCount = targetCountByLang[lang.code] || 0; + const cancelledCount = cancelledCountByLang[lang.code] || 0; + if (targetCount > 0 && cancelledCount === targetCount) { + lang.translation.status = 'cancelled'; + return; + } + + lang.translation.translated = processedByLang[lang.code] || 0; + if (lang.translation.translated === urls.length) { + lang.translation.status = 'translated'; + } + }); + + sendMessage(); + await saveState(); +} + +/** + * Downloads the processed translation deliverables for a language and hands each + * one to `saveFn` for writing back to DA, removing DNT markers first. + * @param {object} conf - The save configuration. + * @param {string} conf.org - The DA org. + * @param {string} conf.site - The DA site. + * @param {object} conf.service - The flattened per-environment service config, including + * the previously persisted `submissionId`. + * @param {object} conf.lang - The language being saved, with a `code` (BCP-47 locale). + * @param {object[]} conf.urls - The DA url entries to download and save. + * @param {Function} conf.saveFn - Callback invoked with each downloaded url entry + * (with `sourceContent` populated) to persist it to DA. + * @returns {Promise} The url entries, each annotated with a `status` (e.g. + * `'success'`/`'error'`) once processing completes. + */ +export async function saveItems({ + org, site, service, lang, urls, saveFn, +}) { + const submissionId = service.submissionId?.value; + if (!submissionId) return urls; + + if (!token) { + const connected = await isConnected(service); + if (!connected) return urls; + } + + const targets = await listTargets(service, submissionId, { + targetStatus: 'PROCESSED', + targetLanguage: lang.code, + }); + + const downloadCallback = async (url) => { + const target = targets.find((entry) => { + if (!isProcessed(entry)) return false; + const langCode = targetLanguageOf(entry); + if (langCode && langCode !== lang.code) return false; + return matchUrl([url], entry); + }); + + const targetId = target?.targetId || target?.id; + if (!targetId) { + url.status = 'error'; + return; + } + + try { + const resp = await fetch( + `${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/targets/${targetId}/download/deliverable`, + { headers: { Authorization: `Bearer ${token}`, ...originHeader(service) } }, + ); + if (!resp.ok) throw new Error(resp.status); + + const text = await resp.text(); + url.sourceContent = await removeDnt({ org, site, html: text, ext: url.ext }); + + await saveFn(url); + } catch { + url.status = 'error'; + } + }; + + const queue = new Queue(downloadCallback, 5); + + return new Promise((resolve) => { + const throttle = setInterval(() => { + const nextUrl = urls.find((url) => !url.inProgress); + if (nextUrl) { + nextUrl.inProgress = true; + queue.push(nextUrl); + } else if (urls.every((url) => url.status)) { + clearInterval(throttle); + resolve(urls); + } + }, 250); + }); +} + +/** + * Cancels GlobalLink translation for a single language, scoped to just that language's + * targets via `targetIds` (the submission itself, and every other language in it, is left + * untouched). Only works while those targets haven't started processing yet. + * @param {object} conf - The cancel configuration. + * @param {object} conf.service - The flattened per-environment service config, including + * the previously persisted `submissionId`. + * @param {object} conf.lang - The language to cancel, with a `code` (BCP-47 locale). + * @param {Function} conf.sendMessage - Reports progress/status text to the UI. + * @returns {Promise<{ok: boolean, skipped?: boolean}>} Whether the cancel succeeded. + */ +export async function cancelTranslation({ service, lang, sendMessage }) { + const submissionId = service.submissionId?.value; + if (!submissionId) { + sendMessage({ text: `Skipping ${lang.name}. No GlobalLink submission to cancel.` }); + return { ok: true, skipped: true }; + } + + if (!token) { + const connected = await isConnected(service); + if (!connected) { + sendMessage({ text: 'Not connected to GlobalLink.', type: 'error' }); + return { ok: false }; + } + } + + const targets = await listTargets(service, submissionId, { targetLanguage: lang.code }); + const targetIds = targets + .map((target) => target.targetId ?? target.id) + .filter((id) => id != null); + + if (!targetIds.length) { + sendMessage({ text: `Skipping ${lang.name}. No GlobalLink targets found to cancel.` }); + return { ok: true, skipped: true }; + } + + sendMessage({ text: `Cancelling GlobalLink translation for ${lang.name}.` }); + + const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/cancel/${submissionId}`, { + method: 'POST', + headers: authHeaders(service), + body: JSON.stringify({ targetIds }), + }); + + if (!resp.ok) { + const json = await resp.json().catch(() => null); + const detail = json?.messages?.length ? ` ${json.messages.join(' ')}` : ''; + sendMessage({ text: `Failed to cancel GlobalLink translation for ${lang.name}.${detail}`, type: 'error' }); + return { ok: false }; + } + + return { ok: true }; +} diff --git a/nx2/utils/utils.js b/nx2/utils/utils.js index 389016db7..bf4112f54 100644 --- a/nx2/utils/utils.js +++ b/nx2/utils/utils.js @@ -51,6 +51,12 @@ const DA_FEEDBACK_ENVS = { prod: 'https://feedback.da.live/feedback', }; +const DA_TRANSLATE_ENVS = { + local: 'http://localhost:8787', + stage: 'https://translate.da.live', + prod: 'https://translate.da.live', +}; + function getEnv(key, envs) { const params = new URLSearchParams(window.location.search); const query = params.get(key); @@ -69,10 +75,10 @@ export const DA_CONTENT = getEnv('da-content', DA_CONTENT_ENVS); export const DA_PREVIEW = getEnv('da-preview', DA_LIVE_PREVIEW_ENVS); export const DA_ETC = getEnv('da-etc', DA_ETC_ENVS); export const DA_FEEDBACK = getEnv('da-feedback', DA_FEEDBACK_ENVS); +export const DA_TRANSLATE = getEnv('da-translate', DA_TRANSLATE_ENVS); export const HLX_ADMIN = 'https://admin.hlx.page'; export const AEM_API = 'https://api.aem.live'; -export const DA_TRANSLATE = 'https://translate.da.live'; export const ALLOWED_TOKEN = [ DA_ADMIN, From d9d146a4bebbd17435020f15c07f2158e122c57a Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Tue, 25 Aug 2026 17:05:42 -0500 Subject: [PATCH 02/13] Move GlobalLink auth to da-etc, out of the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds connectors/globallink/auth.js, mirroring trados/auth.js: getAccessToken calls da-etc's new /:org/sites/:site/integrations/globallink/login endpoint (carrying only the browser's own DA session auth) and caches the resulting access token in localStorage. isConnected/connect now just delegate to authReady. Removes all local OAuth building from index.js (setTokenDetails/ getTokenDetails/clearToken/basicAuthHeader/requestToken/refreshAccessToken/ refreshTheToken and the module-level token/tokenPolling state) — every call site now gets its token via getAccessToken/authHeaders instead. The GlobalLink OAuth client secret and the user's password no longer transit through the browser at all; only the (non-secret) API endpoint used for the DA_TRANSLATE proxy header stays client-side. Corresponding DA config keys renamed oauthClient/oauthSecret -> clientId/ clientSecret to match da-etc's shared credential-resolution helper. --- nx/blocks/loc/connectors/globallink/auth.js | 92 ++++++ nx/blocks/loc/connectors/globallink/index.js | 301 +++---------------- 2 files changed, 133 insertions(+), 260 deletions(-) create mode 100644 nx/blocks/loc/connectors/globallink/auth.js diff --git a/nx/blocks/loc/connectors/globallink/auth.js b/nx/blocks/loc/connectors/globallink/auth.js new file mode 100644 index 000000000..c57a60225 --- /dev/null +++ b/nx/blocks/loc/connectors/globallink/auth.js @@ -0,0 +1,92 @@ +import { daFetch } from '../../../../../nx2/utils/api.js'; +import { DA_ETC } from '../../../../../nx2/utils/utils.js'; + +const TOKEN_BUFFER_MS = 300000; // 5 min buffer before expiry + +/** + * Builds the localStorage key used to cache a site's GlobalLink access token. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The selected environment. + * @returns {string} The localStorage key. + */ +function tokenKey(org, site, env) { + return `globallink.${org}.${site}.${env}.token`; +} + +/** + * Reads the cached access token for a site/environment from localStorage. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The selected environment. + * @returns {{accessToken?: string, expires?: number}} The cached token details, or an + * empty object if none are stored or the stored value is invalid JSON. + */ +function getTokenDetails(org, site, env) { + const stored = localStorage.getItem(tokenKey(org, site, env)); + if (!stored) return {}; + try { + return JSON.parse(stored); + } catch { + return {}; + } +} + +/** + * Persists an access token for a site/environment to localStorage. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The selected environment. + * @param {string} accessToken - The GlobalLink access token. + * @param {number} expires - The epoch millisecond timestamp the token should be + * treated as expired by (already adjusted by {@link TOKEN_BUFFER_MS}). + * @returns {void} + */ +function setTokenDetails(org, site, env, accessToken, expires) { + localStorage.setItem( + tokenKey(org, site, env), + JSON.stringify({ accessToken, expires }), + ); +} + +/** + * Fetches (and caches) a GlobalLink access token. The OAuth exchange itself happens + * server-side in da-etc — the client secret and the GlobalLink user's password never + * reach the browser; this only ever sends the browser's own DA session auth. + * @param {object} service - The flattened per-environment service config. + * @param {string} service.org - The DA org. + * @param {string} service.site - The DA site. + * @param {string} [service.env] - The selected environment (defaults to `'prod'`). + * @returns {Promise} The access token, or `null` if login failed. + */ +export async function getAccessToken(service) { + const { org, site, env = 'prod' } = service; + + const { accessToken: cached, expires: cachedExpires } = getTokenDetails(org, site, env); + if (cached && cachedExpires > Date.now()) return cached; + + const opts = { method: 'POST' }; + const url = `${DA_ETC}/${org}/sites/${site}/integrations/globallink/login?env=${env}`; + + const resp = await daFetch({ url, opts }); + if (!resp.ok) return null; + + const { access_token: accessToken, expires_in: expiresIn } = await resp.json(); + if (!accessToken) return null; + + const expires = Date.now() + ((Number(expiresIn) || 0) * 1000) - TOKEN_BUFFER_MS; + setTokenDetails(org, site, env, accessToken, expires); + + return accessToken; +} + +/** + * Checks whether a usable GlobalLink access token is available, fetching one via + * da-etc if needed. + * @param {object} service - The flattened per-environment service config. + * @returns {Promise} Whether a valid access token is available. + */ +export default async function authReady(service) { + const accessToken = await getAccessToken(service); + return !!accessToken; +} diff --git a/nx/blocks/loc/connectors/globallink/index.js b/nx/blocks/loc/connectors/globallink/index.js index 0f7f15a40..f1a4a0f74 100644 --- a/nx/blocks/loc/connectors/globallink/index.js +++ b/nx/blocks/loc/connectors/globallink/index.js @@ -1,22 +1,18 @@ import { Queue } from '../../../../../nx2/public/utils/tree.js'; import { addDnt, removeDnt } from '../../dnt/dnt.js'; import { DA_TRANSLATE } from '../../../../../nx2/utils/utils.js'; +import authReady, { getAccessToken } from './auth.js'; export const dnt = { addDnt }; const BATCH_NAME = 'Batch1'; const DEFAULT_DUE_DATE_DAYS = 7; -const DEFAULT_TOKEN_TTL_MS = 3600000; -const REFRESH_BUFFER_MS = 60000; const PROCESS_POLL_MS = 2000; const PROCESS_POLL_MAX = 60; const JSON_HEADERS = { 'Content-Type': 'application/json' }; const ORIGIN_HEADER = 'x-globallink-origin'; -let token; -let tokenPolling; - /** * Builds the DA_TRANSLATE proxy origin GlobalLink requests are routed through, so the * browser never calls GlobalLink's API directly (avoids CORS and keeps a single, @@ -46,79 +42,16 @@ function originHeader(service) { return { [ORIGIN_HEADER]: service.endpoint }; } -/** - * Builds the localStorage key used to persist a service's OAuth token details. - * @param {string} name - The connector/service display name (e.g. "GlobalLink"). - * @param {string} env - The selected environment (e.g. "prod"). - * @returns {string} The localStorage key. - */ -function tokenKey(name, env) { - return `${name.toLowerCase()}.${env}.token`; -} - -/** - * Caches the current access token in memory and persists the full token - * details (with expiry) to localStorage for reuse across page loads. - * @param {string} name - The connector/service display name. - * @param {string} env - The selected environment. - * @param {string} accessToken - The OAuth access token. - * @param {string} refreshToken - The OAuth refresh token. - * @param {number|string} expiresIn - Token lifetime in seconds, as returned by the OAuth server. - * @returns {number} The token's time-to-live in milliseconds. - */ -function setTokenDetails(name, env, accessToken, refreshToken, expiresIn) { - token = accessToken; - const ttlMs = (Number(expiresIn) * 1000) || DEFAULT_TOKEN_TTL_MS; - const expires = Date.now() + ttlMs; - localStorage.setItem(tokenKey(name, env), JSON.stringify({ - accessToken, - refreshToken, - expires, - })); - return ttlMs; -} - -/** - * Reads the persisted OAuth token details for a service/environment from localStorage. - * @param {string} name - The connector/service display name. - * @param {string} env - The selected environment. - * @returns {{accessToken?: string, refreshToken?: string, expires?: number}} The stored token - * details, or an empty object if none are stored or the stored value is invalid JSON. - */ -function getTokenDetails(name, env) { - const lsTokenDetails = localStorage.getItem(tokenKey(name, env)); - if (!lsTokenDetails) return {}; - try { - return JSON.parse(lsTokenDetails); - } catch { - return {}; - } -} - -/** - * Clears the in-memory access token, stops the refresh-polling interval, and - * removes the persisted token details for a service/environment. - * @param {string} name - The connector/service display name. - * @param {string} env - The selected environment. - * @returns {void} - */ -function clearToken(name, env) { - token = undefined; - if (tokenPolling) { - clearInterval(tokenPolling); - tokenPolling = undefined; - } - localStorage.removeItem(tokenKey(name, env)); -} - /** * Builds the bearer-auth + JSON + proxy-origin headers used for authenticated - * GlobalLink API calls routed through the DA_TRANSLATE proxy. + * GlobalLink API calls routed through the DA_TRANSLATE proxy. The access token is + * obtained via {@link getAccessToken} (da-etc), never built from credentials here. * @param {object} service - The flattened per-environment service config. * @param {string} service.endpoint - The real GlobalLink API base endpoint. - * @returns {object} The request headers. + * @returns {Promise} The request headers. */ -function authHeaders(service) { +async function authHeaders(service) { + const token = await getAccessToken(service); return { Authorization: `Bearer ${token}`, ...originHeader(service), @@ -126,98 +59,6 @@ function authHeaders(service) { }; } -/** - * Builds a Basic auth header value from an OAuth client id/secret pair. - * @param {string} client - The OAuth client id. - * @param {string} secret - The OAuth client secret. - * @returns {string} The `Basic ` header value. - */ -function basicAuthHeader(client, secret) { - return `Basic ${btoa(`${client}:${secret}`)}`; -} - -/** - * Requests a new OAuth token (password or refresh grant) from GlobalLink, via the - * DA_TRANSLATE proxy. - * @param {object} service - The flattened per-environment service config. - * @param {string} service.org - The DA org, used to resolve the DA_TRANSLATE proxy origin. - * @param {string} service.site - The DA site, used to resolve the DA_TRANSLATE proxy origin. - * @param {string} service.endpoint - The real GlobalLink API base endpoint. - * @param {string} oauthClient - The OAuth client id. - * @param {string} oauthSecret - The OAuth client secret. - * @param {URLSearchParams} body - The grant-specific form-encoded request body. - * @returns {Promise} The parsed token response, or `null` on failure. - */ -async function requestToken(service, oauthClient, oauthSecret, body) { - const opts = { - method: 'POST', - headers: { - Authorization: basicAuthHeader(oauthClient, oauthSecret), - 'Content-Type': 'application/x-www-form-urlencoded', - ...originHeader(service), - }, - body, - }; - const resp = await fetch(`${resolveOrigin(service)}/oauth/token`, opts); - if (!resp.ok) return null; - return resp.json(); -} - -/** - * Attempts to refresh the current OAuth access token using the stored refresh token. - * Clears the token if there is no refresh token or the refresh request fails. - * @param {object} service - The flattened per-environment service config. - * @param {string} service.name - The connector/service display name. - * @param {string} service.env - The selected environment. - * @param {string} service.oauthClient - The OAuth client id. - * @param {string} service.oauthSecret - The OAuth client secret. - * @returns {Promise} Whether the token was refreshed successfully. - */ -async function refreshAccessToken(service) { - const { name, env, oauthClient, oauthSecret } = service; - const { refreshToken: currRefreshToken } = getTokenDetails(name, env); - if (!currRefreshToken) { - clearToken(name, env); - return false; - } - - const body = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: currRefreshToken, - }); - - const json = await requestToken(service, oauthClient, oauthSecret, body); - if (!json?.access_token) { - clearToken(name, env); - return false; - } - - setTokenDetails( - name, - env, - json.access_token, - json.refresh_token || currRefreshToken, - json.expires_in, - ); - return true; -} - -/** - * (Re)starts the interval that proactively refreshes the OAuth access token - * shortly before it expires. - * @param {object} service - The flattened per-environment service config, forwarded to - * {@link refreshAccessToken} on each tick. - * @param {number} [ttlMs] - The current token's time-to-live in milliseconds. - * @returns {void} - */ -function refreshTheToken(service, ttlMs) { - if (tokenPolling) clearInterval(tokenPolling); - const interval = Math.max((ttlMs || DEFAULT_TOKEN_TTL_MS) - REFRESH_BUFFER_MS, REFRESH_BUFFER_MS); - tokenPolling = setInterval(() => { - refreshAccessToken(service); - }, interval); -} - /** * Derives a GlobalLink-safe upload file name from a DA base path, flattening * any nested folders and ensuring an extension is present. @@ -276,7 +117,7 @@ async function waitForSubmissionReady(service, submissionId) { for (let i = 0; i < PROCESS_POLL_MAX; i += 1) { // eslint-disable-next-line no-await-in-loop const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/status`, { - headers: authHeaders(service), + headers: await authHeaders(service), }); if (resp.ok) { // eslint-disable-next-line no-await-in-loop @@ -356,7 +197,7 @@ async function createSubmission( const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/create`, { method: 'POST', - headers: authHeaders(service), + headers: await authHeaders(service), body, }); if (!resp.ok) return null; @@ -385,6 +226,7 @@ async function uploadSourceFile(service, submissionId, url) { body.append('fileFormatName', service.fileFormatName); body.append('clientIdentifier', url.daBasePath); + const token = await getAccessToken(service); const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/upload/source`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, ...originHeader(service) }, @@ -409,7 +251,7 @@ async function uploadSourceFile(service, submissionId, url) { async function saveAndAutostart(service, submissionId) { const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/save`, { method: 'POST', - headers: authHeaders(service), + headers: await authHeaders(service), body: JSON.stringify({ autoStart: true }), }); if (!resp.ok) return { started: false, messages: null }; @@ -438,7 +280,7 @@ async function listTargets(service, submissionId, { targetStatus, targetLanguage if (targetStatus) reqUrl.searchParams.set('targetStatus', targetStatus); if (targetLanguage) reqUrl.searchParams.set('targetLanguage', targetLanguage); - const resp = await fetch(reqUrl, { headers: authHeaders(service) }); + const resp = await fetch(reqUrl, { headers: await authHeaders(service) }); if (!resp.ok) return []; const json = await resp.json(); if (Array.isArray(json)) return json; @@ -478,79 +320,24 @@ function isCancelled(target) { } /** - * Checks whether there is a currently valid GlobalLink session, refreshing the - * access token from a stored refresh token if needed. + * Checks whether there is a currently valid GlobalLink session, fetching an access + * token via da-etc if needed. The client secret and GlobalLink password never reach + * the browser — see `auth.js`. * @param {object} service - The flattened per-environment service config. - * @param {string} service.name - The connector/service display name. - * @param {string} service.env - The selected environment. - * @param {string} service.org - The DA org, used to resolve the DA_TRANSLATE proxy origin. - * @param {string} service.site - The DA site, used to resolve the DA_TRANSLATE proxy origin. - * @param {string} service.endpoint - The real GlobalLink API base endpoint. * @returns {Promise} Whether the connector is authenticated and ready to use. */ -export async function isConnected(service) { - const { name, env } = service; - if (!resolveOrigin(service) || !service.endpoint) return false; - - const { expires, refreshToken, accessToken } = getTokenDetails(name, env); - const notExpired = expires > Date.now() + REFRESH_BUFFER_MS; - - if (accessToken && notExpired) { - token = accessToken; - if (!tokenPolling) { - refreshTheToken(service, expires - Date.now()); - } - return true; - } - - if (refreshToken) { - const ok = await refreshAccessToken(service); - if (ok) { - const details = getTokenDetails(name, env); - refreshTheToken(service, details.expires - Date.now()); - return true; - } - } - - return false; +export function isConnected(service) { + return authReady(service); } /** - * Authenticates with GlobalLink using the resource-owner password grant and - * starts the background token-refresh loop on success. + * Authenticates with GlobalLink. Identical to {@link isConnected} — both simply ensure + * a usable access token is available, obtained server-side by da-etc. * @param {object} service - The flattened per-environment service config. - * @param {string} service.name - The connector/service display name. - * @param {string} service.env - The selected environment. - * @param {string} service.org - The DA org, used to resolve the DA_TRANSLATE proxy origin. - * @param {string} service.site - The DA site, used to resolve the DA_TRANSLATE proxy origin. - * @param {string} service.endpoint - The real GlobalLink API base endpoint. - * @param {string} service.oauthClient - The OAuth client id. - * @param {string} service.oauthSecret - The OAuth client secret. - * @param {string} service.username - The GlobalLink username. - * @param {string} service.password - The GlobalLink password. * @returns {Promise} Whether authentication succeeded. */ -export async function connect(service) { - const { - name, env, oauthClient, oauthSecret, username, password, - } = service; - const endpoint = resolveOrigin(service); - const hasCreds = oauthClient && oauthSecret && username && password; - - if (!endpoint || !service.endpoint || !hasCreds) return false; - - const body = new URLSearchParams({ - grant_type: 'password', - username, - password, - }); - - const json = await requestToken(service, oauthClient, oauthSecret, body); - if (!json?.access_token) return false; - - const ttlMs = setTokenDetails(name, env, json.access_token, json.refresh_token, json.expires_in); - refreshTheToken(service, ttlMs); - return true; +export function connect(service) { + return authReady(service); } /** @@ -575,16 +362,14 @@ export async function sendAllLanguages({ }) { const { sendMessage, saveState } = actions; - if (!token) { - const connected = await isConnected(service); - if (!connected) { - sendMessage({ text: 'Not connected to GlobalLink.', type: 'error' }); - langs.forEach((lang) => { - lang.translation ??= {}; - lang.translation.status = 'error'; - }); - return; - } + const connected = await isConnected(service); + if (!connected) { + sendMessage({ text: 'Not connected to GlobalLink.', type: 'error' }); + langs.forEach((lang) => { + lang.translation ??= {}; + lang.translation.status = 'error'; + }); + return; } if (!service.projectId || !service.fileFormatName) { @@ -691,12 +476,10 @@ export async function getStatusAll({ service, langs, urls, actions }) { return; } - if (!token) { - const connected = await isConnected(service); - if (!connected) { - sendMessage({ text: 'Not connected to GlobalLink.', type: 'error' }); - return; - } + const connected = await isConnected(service); + if (!connected) { + sendMessage({ text: 'Not connected to GlobalLink.', type: 'error' }); + return; } sendMessage({ text: `Checking GlobalLink status for submission ${submissionId}.` }); @@ -763,16 +546,16 @@ export async function saveItems({ const submissionId = service.submissionId?.value; if (!submissionId) return urls; - if (!token) { - const connected = await isConnected(service); - if (!connected) return urls; - } + const connected = await isConnected(service); + if (!connected) return urls; const targets = await listTargets(service, submissionId, { targetStatus: 'PROCESSED', targetLanguage: lang.code, }); + const token = await getAccessToken(service); + const downloadCallback = async (url) => { const target = targets.find((entry) => { if (!isProcessed(entry)) return false; @@ -837,12 +620,10 @@ export async function cancelTranslation({ service, lang, sendMessage }) { return { ok: true, skipped: true }; } - if (!token) { - const connected = await isConnected(service); - if (!connected) { - sendMessage({ text: 'Not connected to GlobalLink.', type: 'error' }); - return { ok: false }; - } + const connected = await isConnected(service); + if (!connected) { + sendMessage({ text: 'Not connected to GlobalLink.', type: 'error' }); + return { ok: false }; } const targets = await listTargets(service, submissionId, { targetLanguage: lang.code }); @@ -859,7 +640,7 @@ export async function cancelTranslation({ service, lang, sendMessage }) { const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/cancel/${submissionId}`, { method: 'POST', - headers: authHeaders(service), + headers: await authHeaders(service), body: JSON.stringify({ targetIds }), }); From f7ccf46710bd1a19622ef5ec21c1dcf18cb1d280 Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Tue, 25 Aug 2026 20:58:52 -0500 Subject: [PATCH 03/13] Fix DA_TRANSLATE local dev port 8787 collides with DA_ADMIN's local port; da-translate's wrangler dev serves on 8788. --- nx2/utils/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nx2/utils/utils.js b/nx2/utils/utils.js index bf4112f54..cabaf679c 100644 --- a/nx2/utils/utils.js +++ b/nx2/utils/utils.js @@ -52,7 +52,7 @@ const DA_FEEDBACK_ENVS = { }; const DA_TRANSLATE_ENVS = { - local: 'http://localhost:8787', + local: 'http://localhost:8788', stage: 'https://translate.da.live', prod: 'https://translate.da.live', }; From 058169519f3d6a3797d59e149ae3067bc35571d0 Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Wed, 26 Aug 2026 07:18:44 -0500 Subject: [PATCH 04/13] Make GlobalLink batch name configurable - batchName threaded through createSubmission/uploadSourceFile instead of a hardcoded 'Batch1' constant - Defaults to DEFAULT_BATCH_NAME, overridable via service.batchName (translation.service..batchName) --- nx/blocks/loc/connectors/globallink/index.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/nx/blocks/loc/connectors/globallink/index.js b/nx/blocks/loc/connectors/globallink/index.js index f1a4a0f74..4ab3573b6 100644 --- a/nx/blocks/loc/connectors/globallink/index.js +++ b/nx/blocks/loc/connectors/globallink/index.js @@ -5,7 +5,7 @@ import authReady, { getAccessToken } from './auth.js'; export const dnt = { addDnt }; -const BATCH_NAME = 'Batch1'; +const DEFAULT_BATCH_NAME = 'Batch1'; const DEFAULT_DUE_DATE_DAYS = 7; const PROCESS_POLL_MS = 2000; const PROCESS_POLL_MAX = 60; @@ -170,6 +170,7 @@ function extractCustomAttributes(options) { * @param {number} dueDateDays - The number of days until the submission is due. * @param {{name: string, value: string}[]} customAttributes - Any project-required custom * attributes (e.g. a mandatory field), from {@link extractCustomAttributes}. + * @param {string} batchName - The name of the batch to create within the submission. * @returns {Promise} The created submission id, or `null` on failure. */ async function createSubmission( @@ -179,6 +180,7 @@ async function createSubmission( sourceLanguage, dueDateDays, customAttributes, + batchName, ) { const body = JSON.stringify({ name: `${title}-${Date.now()}`, @@ -190,7 +192,7 @@ async function createSubmission( batchInfos: [{ targetLanguageInfos: langs.map((lang) => ({ targetLanguage: lang.code })), targetFormat: 'TXLF', - name: BATCH_NAME, + name: batchName, }], claimScope: 'LANGUAGE', }); @@ -206,7 +208,7 @@ async function createSubmission( } /** - * Uploads a single source document to a GlobalLink submission's default batch. + * Uploads a single source document to a GlobalLink submission's batch. * @param {object} service - The flattened per-environment service config. * @param {string} service.fileFormatName - The GlobalLink file format to upload as. * @param {string|number} submissionId - The target submission id. @@ -214,15 +216,17 @@ async function createSubmission( * @param {string} url.daBasePath - The DA-formatted base path, used for the file name and * as the GlobalLink `clientIdentifier` for later matching. * @param {string} url.content - The document's HTML content (with DNT applied). + * @param {string} batchName - The name of the batch this document belongs to, matching the + * one passed to {@link createSubmission}. * @returns {Promise} Whether the upload succeeded. */ -async function uploadSourceFile(service, submissionId, url) { +async function uploadSourceFile(service, submissionId, url, batchName) { const body = new FormData(); const fileName = toFileName(url.daBasePath); const file = new Blob([url.content], { type: 'text/html' }); body.append('file', file, fileName); - body.append('batchName', BATCH_NAME); + body.append('batchName', batchName); body.append('fileFormatName', service.fileFormatName); body.append('clientIdentifier', url.daBasePath); @@ -347,6 +351,8 @@ export function connect(service) { * @param {string} conf.title - The localization project title. * @param {object} conf.service - The flattened per-environment service config (mutated * in place with the created `submissionId`). + * @param {string} [conf.service.batchName] - The batch name to create/upload under + * (defaults to `DEFAULT_BATCH_NAME`). * @param {object} conf.options - The full localization project options, including any * `translation.service.custom.*` fields required as GlobalLink submission custom attributes. * @param {object[]} conf.langs - The target languages to send (mutated in place with @@ -384,6 +390,7 @@ export async function sendAllLanguages({ const sourceLanguage = options?.['source.language']?.code || service.sourceLanguage || 'en-US'; const dueDateDays = Number(service.dueDateDays) || DEFAULT_DUE_DATE_DAYS; const customAttributes = extractCustomAttributes(options); + const batchName = service.batchName || DEFAULT_BATCH_NAME; sendMessage({ text: `Creating GlobalLink submission for: ${title}.` }); const submissionId = await createSubmission( @@ -393,6 +400,7 @@ export async function sendAllLanguages({ sourceLanguage, dueDateDays, customAttributes, + batchName, ); if (!submissionId) { sendMessage({ text: 'Failed to create GlobalLink submission.', type: 'error' }); @@ -411,7 +419,7 @@ export async function sendAllLanguages({ for (const url of urls) { sendMessage({ text: `Uploading ${url.daBasePath}` }); // eslint-disable-next-line no-await-in-loop - const ok = await uploadSourceFile(service, submissionId, url); + const ok = await uploadSourceFile(service, submissionId, url, batchName); if (ok) accepted += 1; } From f71576202c4123537883b1241bfbb3e9856a28b2 Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Wed, 26 Aug 2026 07:26:11 -0500 Subject: [PATCH 05/13] Generate GlobalLink batch name dynamically instead of from config - generateBatchName(title): title + timestamp, truncated to 64 UTF-8 chars (GlobalLink batch-naming limit) - Removes service.batchName config option entirely --- nx/blocks/loc/connectors/globallink/index.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/nx/blocks/loc/connectors/globallink/index.js b/nx/blocks/loc/connectors/globallink/index.js index 4ab3573b6..0caf00f10 100644 --- a/nx/blocks/loc/connectors/globallink/index.js +++ b/nx/blocks/loc/connectors/globallink/index.js @@ -5,7 +5,6 @@ import authReady, { getAccessToken } from './auth.js'; export const dnt = { addDnt }; -const DEFAULT_BATCH_NAME = 'Batch1'; const DEFAULT_DUE_DATE_DAYS = 7; const PROCESS_POLL_MS = 2000; const PROCESS_POLL_MAX = 60; @@ -160,6 +159,17 @@ function extractCustomAttributes(options) { }, []); } +/** + * Generates a name for a submission's batch, derived from the title and a timestamp. + * GlobalLink batch names must be unique within the submission and no more than 64 + * UTF-8 characters. + * @param {string} title - The localization project title. + * @returns {string} A batch name, truncated to 64 characters. + */ +function generateBatchName(title) { + return `${title}-batch-${Date.now()}`.slice(0, 64); +} + /** * Creates a new GlobalLink submission (with one batch targeting all requested languages). * @param {object} service - The flattened per-environment service config. @@ -170,7 +180,8 @@ function extractCustomAttributes(options) { * @param {number} dueDateDays - The number of days until the submission is due. * @param {{name: string, value: string}[]} customAttributes - Any project-required custom * attributes (e.g. a mandatory field), from {@link extractCustomAttributes}. - * @param {string} batchName - The name of the batch to create within the submission. + * @param {string} batchName - The name of the batch to create within the submission. Must + * be unique within the submission and no more than 64 UTF-8 characters. * @returns {Promise} The created submission id, or `null` on failure. */ async function createSubmission( @@ -351,8 +362,6 @@ export function connect(service) { * @param {string} conf.title - The localization project title. * @param {object} conf.service - The flattened per-environment service config (mutated * in place with the created `submissionId`). - * @param {string} [conf.service.batchName] - The batch name to create/upload under - * (defaults to `DEFAULT_BATCH_NAME`). * @param {object} conf.options - The full localization project options, including any * `translation.service.custom.*` fields required as GlobalLink submission custom attributes. * @param {object[]} conf.langs - The target languages to send (mutated in place with @@ -390,7 +399,7 @@ export async function sendAllLanguages({ const sourceLanguage = options?.['source.language']?.code || service.sourceLanguage || 'en-US'; const dueDateDays = Number(service.dueDateDays) || DEFAULT_DUE_DATE_DAYS; const customAttributes = extractCustomAttributes(options); - const batchName = service.batchName || DEFAULT_BATCH_NAME; + const batchName = generateBatchName(title); sendMessage({ text: `Creating GlobalLink submission for: ${title}.` }); const submissionId = await createSubmission( From 1e6b8ea35eb9ea605c7336c037beaf16b5bb7fce Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Wed, 26 Aug 2026 11:57:01 -0500 Subject: [PATCH 06/13] Address GlobalLink API dos-and-don'ts findings - markTargetsDelivered: mark targets delivered after a successful save, so GlobalLink stops re-surfacing them (POST .../targets/delivered) - uploadSourceFiles: upload all source documents as a single zip (extractArchive=true) instead of one call per file; adds fflate as a dependency (nx2/deps/fflate, mirrors existing lit/mdast bundling) - uploadSourceFiles: detect and warn when GlobalLink splits uploads across additional (untracked) submissions due to the per-submission file limit - saveItems: wait for GlobalLink to report a language's deliverables ready (deliverableLanguages + downloadId polling, 5s interval) before downloading individual targets --- nx/blocks/loc/connectors/globallink/index.js | 166 ++++++++++++++++--- nx2/deps/fflate/dist/index.js | 1 + nx2/deps/fflate/src/index.js | 3 + package-lock.json | 7 + package.json | 4 +- 5 files changed, 156 insertions(+), 25 deletions(-) create mode 100644 nx2/deps/fflate/dist/index.js create mode 100644 nx2/deps/fflate/src/index.js diff --git a/nx/blocks/loc/connectors/globallink/index.js b/nx/blocks/loc/connectors/globallink/index.js index 0caf00f10..54895e4ca 100644 --- a/nx/blocks/loc/connectors/globallink/index.js +++ b/nx/blocks/loc/connectors/globallink/index.js @@ -1,6 +1,7 @@ import { Queue } from '../../../../../nx2/public/utils/tree.js'; import { addDnt, removeDnt } from '../../dnt/dnt.js'; import { DA_TRANSLATE } from '../../../../../nx2/utils/utils.js'; +import { zipSync, strToU8 } from '../../../../../nx2/deps/fflate/dist/index.js'; import authReady, { getAccessToken } from './auth.js'; export const dnt = { addDnt }; @@ -8,6 +9,8 @@ export const dnt = { addDnt }; const DEFAULT_DUE_DATE_DAYS = 7; const PROCESS_POLL_MS = 2000; const PROCESS_POLL_MAX = 60; +const DOWNLOAD_POLL_MS = 5000; +const DOWNLOAD_POLL_MAX = 60; const JSON_HEADERS = { 'Content-Type': 'application/json' }; const ORIGIN_HEADER = 'x-globallink-origin'; @@ -219,27 +222,34 @@ async function createSubmission( } /** - * Uploads a single source document to a GlobalLink submission's batch. + * Uploads every source document for a submission's batch as a single zip archive, with + * `extractArchive=true` so GlobalLink unpacks it into individual documents — per GlobalLink's + * "upload files zipped in a single call" guidance, instead of one call per file. If the + * submission has hit GlobalLink's per-submission file limit, GlobalLink silently places + * overflow documents in a new, separate submission instead — the response's + * `documentIds[].submissionId` reveals this when it doesn't match `submissionId`. * @param {object} service - The flattened per-environment service config. * @param {string} service.fileFormatName - The GlobalLink file format to upload as. * @param {string|number} submissionId - The target submission id. - * @param {object} url - The DA url entry to upload. - * @param {string} url.daBasePath - The DA-formatted base path, used for the file name and - * as the GlobalLink `clientIdentifier` for later matching. - * @param {string} url.content - The document's HTML content (with DNT applied). - * @param {string} batchName - The name of the batch this document belongs to, matching the + * @param {object[]} urls - The DA url entries to upload. + * @param {string} batchName - The name of the batch these documents belong to, matching the * one passed to {@link createSubmission}. - * @returns {Promise} Whether the upload succeeded. + * @returns {Promise<{uploadedFileNames: Set, overflowSubmissionIds: string[]}>} The + * file names GlobalLink confirmed receiving, plus any other submission id(s) it placed some + * of them under. */ -async function uploadSourceFile(service, submissionId, url, batchName) { - const body = new FormData(); - const fileName = toFileName(url.daBasePath); - const file = new Blob([url.content], { type: 'text/html' }); +async function uploadSourceFiles(service, submissionId, urls, batchName) { + const files = {}; + urls.forEach((url) => { + files[toFileName(url.daBasePath)] = strToU8(url.content); + }); + const zipped = zipSync(files); - body.append('file', file, fileName); + const body = new FormData(); + body.append('file', new Blob([zipped], { type: 'application/zip' }), `${batchName}.zip`); body.append('batchName', batchName); body.append('fileFormatName', service.fileFormatName); - body.append('clientIdentifier', url.daBasePath); + body.append('extractArchive', 'true'); const token = await getAccessToken(service); const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/upload/source`, { @@ -247,10 +257,19 @@ async function uploadSourceFile(service, submissionId, url, batchName) { headers: { Authorization: `Bearer ${token}`, ...originHeader(service) }, body, }); - if (!resp.ok) return false; + if (!resp.ok) return { uploadedFileNames: new Set(), overflowSubmissionIds: [] }; // processId is returned asynchronously; submission-level status is polled after all uploads. - return true; + const json = await resp.json().catch(() => null); + const documentIds = json?.documentIds || []; + const uploadedFileNames = new Set(documentIds.map((doc) => doc.name)); + const overflowSubmissionIds = [...new Set( + documentIds + .map((doc) => String(doc.submissionId)) + .filter((id) => id && id !== String(submissionId)), + )]; + + return { uploadedFileNames, overflowSubmissionIds }; } /** @@ -304,6 +323,84 @@ async function listTargets(service, submissionId, { targetStatus, targetLanguage return []; } +/** + * Marks targets as delivered once their deliverables have been downloaded and successfully + * saved back to DA, so GlobalLink stops re-surfacing them as pending on later status checks. + * @param {object} service - The flattened per-environment service config. + * @param {string|number} submissionId - The submission whose targets to mark delivered. + * @param {(string|number)[]} targetIds - The target ids to mark delivered. + * @returns {Promise} Whether the request succeeded. + */ +async function markTargetsDelivered(service, submissionId, targetIds) { + if (!targetIds.length) return true; + const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/targets/delivered`, { + method: 'POST', + headers: await authHeaders(service), + body: JSON.stringify({ targetIds }), + }); + return resp.ok; +} + +/** + * Requests that GlobalLink prepare a downloadable package of a submission's completed + * deliverables for a language. This is only used as a readiness signal — the actual files + * are still fetched individually via the per-target deliverable endpoint. + * @param {object} service - The flattened per-environment service config. + * @param {string|number} submissionId - The submission to request a download for. + * @param {string} langCode - The target language code to scope the request to. + * @returns {Promise<{downloadId: string|null, processingFinished: boolean}>} The download + * job id (`null` on failure), and whether it's already finished. + */ +async function requestDownload(service, submissionId, langCode) { + const reqUrl = new URL(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/download`); + reqUrl.searchParams.set('deliverableLanguages', langCode); + reqUrl.searchParams.set('includeManifest', 'true'); + + const resp = await fetch(reqUrl, { headers: await authHeaders(service) }); + if (!resp.ok) return { downloadId: null, processingFinished: false }; + const json = await resp.json().catch(() => null); + return { downloadId: json?.downloadId ?? null, processingFinished: !!json?.processingFinished }; +} + +/** + * Checks whether a previously requested download package has finished processing. + * @param {object} service - The flattened per-environment service config. + * @param {string|number} submissionId - The submission the download belongs to. + * @param {string} downloadId - The download job id from {@link requestDownload}. + * @returns {Promise} Whether the package is ready. + */ +async function isDownloadReady(service, submissionId, downloadId) { + const reqUrl = new URL(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/download`); + reqUrl.searchParams.set('downloadId', downloadId); + + const resp = await fetch(reqUrl, { headers: await authHeaders(service) }); + if (!resp.ok) return false; + const json = await resp.json().catch(() => null); + return !!json?.processingFinished; +} + +/** + * Waits for GlobalLink to finish preparing a language's completed deliverables, polling + * every 5 seconds per GlobalLink's guidance (up to `DOWNLOAD_POLL_MAX` attempts) before any + * individual targets are downloaded. + * @param {object} service - The flattened per-environment service config. + * @param {string|number} submissionId - The submission to wait on. + * @param {string} langCode - The target language code to scope the wait to. + * @returns {Promise} Whether the deliverables are ready. + */ +async function waitForDeliverablesReady(service, submissionId, langCode) { + const { downloadId, processingFinished } = await requestDownload(service, submissionId, langCode); + if (!downloadId || processingFinished) return processingFinished; + + for (let i = 0; i < DOWNLOAD_POLL_MAX; i += 1) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { setTimeout(resolve, DOWNLOAD_POLL_MS); }); + // eslint-disable-next-line no-await-in-loop + if (await isDownloadReady(service, submissionId, downloadId)) return true; + } + return false; +} + /** * Extracts the target language code from a GlobalLink target record, tolerating * the different field names seen across GlobalLink API versions. @@ -424,12 +521,19 @@ export async function sendAllLanguages({ service.submissionId = { value: String(submissionId) }; sendMessage({ text: `Uploading ${urls.length} items to GlobalLink.` }); - let accepted = 0; - for (const url of urls) { - sendMessage({ text: `Uploading ${url.daBasePath}` }); - // eslint-disable-next-line no-await-in-loop - const ok = await uploadSourceFile(service, submissionId, url, batchName); - if (ok) accepted += 1; + const { uploadedFileNames, overflowSubmissionIds } = await uploadSourceFiles( + service, + submissionId, + urls, + batchName, + ); + const accepted = urls.filter((url) => uploadedFileNames.has(toFileName(url.daBasePath))).length; + + if (overflowSubmissionIds.length) { + sendMessage({ + text: `GlobalLink split this submission across additional submission(s) (${overflowSubmissionIds.join(', ')}) because it exceeded the per-submission file limit — only ${submissionId} is tracked, so status/downloads for files in the others will be incomplete.`, + type: 'error', + }); } if (accepted !== urls.length) { @@ -544,7 +648,10 @@ export async function getStatusAll({ service, langs, urls, actions }) { /** * Downloads the processed translation deliverables for a language and hands each - * one to `saveFn` for writing back to DA, removing DNT markers first. + * one to `saveFn` for writing back to DA, removing DNT markers first. Targets that save + * successfully are marked delivered on GlobalLink so they aren't re-surfaced later. + * Waits for GlobalLink to report the language's deliverables as fully prepared before + * downloading any individual target (see {@link waitForDeliverablesReady}). * @param {object} conf - The save configuration. * @param {string} conf.org - The DA org. * @param {string} conf.site - The DA site. @@ -554,11 +661,12 @@ export async function getStatusAll({ service, langs, urls, actions }) { * @param {object[]} conf.urls - The DA url entries to download and save. * @param {Function} conf.saveFn - Callback invoked with each downloaded url entry * (with `sourceContent` populated) to persist it to DA. + * @param {Function} conf.sendMessage - Reports progress/status text to the UI. * @returns {Promise} The url entries, each annotated with a `status` (e.g. * `'success'`/`'error'`) once processing completes. */ export async function saveItems({ - org, site, service, lang, urls, saveFn, + org, site, service, lang, urls, saveFn, sendMessage, }) { const submissionId = service.submissionId?.value; if (!submissionId) return urls; @@ -566,12 +674,20 @@ export async function saveItems({ const connected = await isConnected(service); if (!connected) return urls; + sendMessage({ text: `Waiting for GlobalLink to finish preparing ${lang.name} deliverables.` }); + const ready = await waitForDeliverablesReady(service, submissionId, lang.code); + if (!ready) { + sendMessage({ text: `GlobalLink deliverables for ${lang.name} are not ready yet.`, type: 'error' }); + return urls; + } + const targets = await listTargets(service, submissionId, { targetStatus: 'PROCESSED', targetLanguage: lang.code, }); const token = await getAccessToken(service); + const deliveredTargetIds = []; const downloadCallback = async (url) => { const target = targets.find((entry) => { @@ -598,6 +714,7 @@ export async function saveItems({ url.sourceContent = await removeDnt({ org, site, html: text, ext: url.ext }); await saveFn(url); + if (url.status === 'success') deliveredTargetIds.push(targetId); } catch { url.status = 'error'; } @@ -606,13 +723,14 @@ export async function saveItems({ const queue = new Queue(downloadCallback, 5); return new Promise((resolve) => { - const throttle = setInterval(() => { + const throttle = setInterval(async () => { const nextUrl = urls.find((url) => !url.inProgress); if (nextUrl) { nextUrl.inProgress = true; queue.push(nextUrl); } else if (urls.every((url) => url.status)) { clearInterval(throttle); + await markTargetsDelivered(service, submissionId, deliveredTargetIds); resolve(urls); } }, 250); diff --git a/nx2/deps/fflate/dist/index.js b/nx2/deps/fflate/dist/index.js new file mode 100644 index 000000000..b6a8c7c71 --- /dev/null +++ b/nx2/deps/fflate/dist/index.js @@ -0,0 +1 @@ +var M=Uint8Array,V=Uint16Array,Tr=Int32Array,vr=new M([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),cr=new M([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),xr=new M([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Gr=function(r,n){for(var t=new V(31),e=0;e<31;++e)t[e]=n+=1<>1|(y&21845)<<1,k=(k&52428)>>2|(k&13107)<<2,k=(k&61680)>>4|(k&3855)<<4,Ar[y]=((k&65280)>>8|(k&255)<<8)>>1;var k,y,Q=(function(r,n,t){for(var e=r.length,i=0,a=new V(n);i>v]=u}else for(h=new V(e),i=0;i>15-r[i]);return h}),_=new M(288);for(y=0;y<144;++y)_[y]=8;var y;for(y=144;y<256;++y)_[y]=9;var y;for(y=256;y<280;++y)_[y]=7;var y;for(y=280;y<288;++y)_[y]=8;var y,fr=new M(32);for(y=0;y<32;++y)fr[y]=5;var y,Wr=Q(_,9,0),Yr=Q(_,9,1),jr=Q(fr,5,0),Jr=Q(fr,5,1),gr=function(r){for(var n=r[0],t=1;tn&&(n=r[t]);return n},j=function(r,n,t){var e=n/8|0;return(r[e]|r[e+1]<<8)>>(n&7)&t},yr=function(r,n){var t=n/8|0;return(r[t]|r[t+1]<<8|r[t+2]<<16)>>(n&7)},Dr=function(r){return(r+7)/8|0},hr=function(r,n,t){return(n==null||n<0)&&(n=0),(t==null||t>r.length)&&(t=r.length),new M(r.subarray(n,t))};var Kr=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],L=function(r,n,t){var e=new Error(n||Kr[r]);if(e.code=r,Error.captureStackTrace&&Error.captureStackTrace(e,L),!t)throw e;return e},Qr=function(r,n,t,e){var i=r.length,a=e?e.length:0;if(!i||n.f&&!n.l)return t||new M(0);var f=!t,h=f||n.i!=2,v=n.i;f&&(t=new M(i*3));var u=function(ir){var ar=t.length;if(ir>ar){var tr=new M(Math.max(ar*2,ir));tr.set(t),t=tr}},s=n.f||0,o=n.p||0,l=n.b||0,p=n.l,m=n.d,w=n.m,x=n.n,T=i*8;do{if(!p){s=j(r,o,1);var O=j(r,o+1,3);if(o+=3,O)if(O==1)p=Yr,m=Jr,w=9,x=5;else if(O==2){var I=j(r,o,31)+257,F=j(r,o+10,15)+4,g=I+j(r,o+5,31)+1;o+=14;for(var c=new M(g),B=new M(19),D=0;D>4;if(S<16)c[D++]=S;else{var Z=0,z=0;for(S==16?(z=3+j(r,o,3),o+=2,Z=c[D-1]):S==17?(z=3+j(r,o,7),o+=3):S==18&&(z=11+j(r,o,127),o+=7);z--;)c[D++]=Z}}var $=c.subarray(0,I),E=c.subarray(I);w=gr($),x=gr(E),p=Q($,w,1),m=Q(E,x,1)}else L(1);else{var S=Dr(o)+4,U=r[S-4]|r[S-3]<<8,C=S+U;if(C>i){v&&L(0);break}h&&u(l+U),t.set(r.subarray(S,C),l),n.b=l+=U,n.p=o=C*8,n.f=s;continue}if(o>T){v&&L(0);break}}h&&u(l+131072);for(var er=(1<>4;if(o+=Z&15,o>T){v&&L(0);break}if(Z||L(2),N<256)t[l++]=N;else if(N==256){X=o,p=null;break}else{var R=N-254;if(N>264){var D=N-257,A=vr[D];R=j(r,o,(1<>4;J||L(3),o+=J&15;var E=Vr[rr];if(rr>3){var A=cr[rr];E+=yr(r,o)&(1<T){v&&L(0);break}h&&u(l+131072);var nr=l+R;if(l>8},or=function(r,n,t){t<<=n&7;var e=n/8|0;r[e]|=t,r[e+1]|=t>>8,r[e+2]|=t>>16},wr=function(r,n){for(var t=[],e=0;el&&(l=a[e].s);var p=new V(l+1),m=Mr(t[s-1],p,0);if(m>n){var e=0,w=0,x=m-n,T=1<n)w+=T-(1<>=x;w>0;){var S=a[e].s;p[S]=0&&w;--e){var U=a[e].s;p[U]==n&&(--p[U],++w)}m=n}return{t:new M(p),l:m}},Mr=function(r,n,t){return r.s==-1?Math.max(Mr(r.l,n,t+1),Mr(r.r,n,t+1)):n[r.s]=t},Ir=function(r){for(var n=r.length;n&&!r[--n];);for(var t=new V(++n),e=0,i=r[0],a=1,f=function(v){t[e++]=v},h=1;h<=n;++h)if(r[h]==i&&h!=n)++a;else{if(!i&&a>2){for(;a>138;a-=138)f(32754);a>2&&(f(a>10?a-11<<5|28690:a-3<<5|12305),a=0)}else if(a>3){for(f(i),--a;a>6;a-=6)f(8304);a>2&&(f(a-3<<5|8208),a=0)}for(;a--;)f(i);a=1,i=r[h]}return{c:t.subarray(0,e),n}},sr=function(r,n){for(var t=0,e=0;e>8,r[i+2]=r[i]^255,r[i+3]=r[i+1]^255;for(var a=0;a4&&!B[xr[q-1]];--q);var b=u+5<<3,H=sr(i,_)+sr(a,fr)+f,P=sr(i,l)+sr(a,w)+f+14+3*q+sr(F,B)+2*F[16]+3*F[17]+7*F[18];if(v>=0&&b<=H&&b<=P)return Pr(n,s,r.subarray(v,v+u));var Z,z,$,E;if(d(n,s,1+(P15&&(d(n,s,N[g]>>5&127),s+=N[g]>>12)}}else Z=Wr,z=_,$=jr,E=fr;for(var g=0;g255){var R=A>>18&31;or(n,s,Z[R+257]),s+=z[R+257],R>7&&(d(n,s,A>>23&31),s+=vr[R]);var J=A&31;or(n,s,$[J]),s+=E[J],J>3&&(or(n,s,A>>5&8191),s+=cr[J])}else or(n,s,Z[A]),s+=z[A]}return or(n,s,Z[256]),s+z[256]},Xr=new Tr([65540,131080,131088,131104,262176,1048704,1048832,2114560,2117632]),$r=new M(0),kr=function(r,n,t,e,i,a){var f=a.z||r.length,h=new M(e+f+5*(1+Math.ceil(f/7e3))+i),v=h.subarray(e,h.length-i),u=a.l,s=(a.r||0)&7;if(n){s&&(v[0]=a.r>>3);for(var o=Xr[n-1],l=o>>13,p=o&8191,m=(1<7e3||B>24576)&&(Z>423||!u)){s=Br(r,v,0,U,C,I,g,B,q,c-q,s),B=F=g=0,q=c;for(var z=0;z<286;++z)C[z]=0;for(var z=0;z<30;++z)I[z]=0}var $=2,E=0,er=p,W=H-P&32767;if(Z>2&&b==S(c-W))for(var X=Math.min(l,Z)-1,N=Math.min(32767,c),R=Math.min(258,Z);W<=N&&--er&&H!=P;){if(r[c+$]==r[c+$-W]){for(var A=0;A$){if($=A,E=W,A>X)break;for(var J=Math.min(W,A-2),rr=0,z=0;zrr&&(rr=lr,P=nr)}}}H=P,P=w[H],W+=H-P&32767}if(E){U[B++]=268435456|zr[$]<<18|Cr[E];var ir=zr[$]&31,ar=Cr[E]&31;g+=vr[ir]+cr[ar],++C[257+ir],++I[ar],D=c+$,++F}else U[B++]=r[c],++C[r[c]]}}for(c=Math.max(c,D);c=f&&(v[s/8|0]=u,tr=f),s=Pr(v,s+1,r.subarray(c,tr))}a.i=f}return hr(h,0,e+Dr(s)+i)},dr=(function(){for(var r=new Int32Array(256),n=0;n<256;++n){for(var t=n,e=9;--e;)t=(t&1&&-306674912)^t>>>1;r[n]=t}return r})(),br=function(){var r=-1;return{p:function(n){for(var t=r,e=0;e>>8;r=t},d:function(){return~r}}};var _r=function(r,n,t,e,i){if(!i&&(i={l:1},n.dictionary)){var a=n.dictionary.subarray(-32768),f=new M(a.length+r.length);f.set(a),f.set(r,a.length),r=f,i.w=a.length}return kr(r,n.level==null?6:n.level,n.mem==null?i.l?Math.ceil(Math.max(8,Math.min(13,Math.log(r.length)))*1.5):20:12+n.mem,t,e,i)},Hr=function(r,n){var t={};for(var e in r)t[e]=r[e];for(var e in n)t[e]=n[e];return t};var K=function(r,n){return r[n]|r[n+1]<<8},Y=function(r,n){return(r[n]|r[n+1]<<8|r[n+2]<<16|r[n+3]<<24)>>>0},mr=function(r,n){return Y(r,n)+Y(r,n+4)*4294967296},G=function(r,n,t){for(;t;++n)r[n]=t,t>>>=8};function rn(r,n){return _r(r,n||{},0,0)}function nn(r,n){return Qr(r,{i:2},n&&n.out,n&&n.dictionary)}var Nr=function(r,n,t,e){for(var i in r){var a=r[i],f=n+i,h=e;Array.isArray(a)&&(h=Hr(e,a[1]),a=a[0]),ArrayBuffer.isView(a)?t[f]=[a,h]:(t[f+="/"]=[new M(0),h],Nr(a,f,t,e))}},Zr=typeof TextEncoder<"u"&&new TextEncoder,Sr=typeof TextDecoder<"u"&&new TextDecoder,tn=0;try{Sr.decode($r,{stream:!0}),tn=1}catch{}var en=function(r){for(var n="",t=0;;){var e=r[t++],i=(e>127)+(e>223)+(e>239);if(t+i>r.length)return{s:n,r:hr(r,t-1)};i?i==3?(e=((e&15)<<18|(r[t++]&63)<<12|(r[t++]&63)<<6|r[t++]&63)-65536,n+=String.fromCharCode(55296|e>>10,56320|e&1023)):i&1?n+=String.fromCharCode((e&31)<<6|r[t++]&63):n+=String.fromCharCode((e&15)<<12|(r[t++]&63)<<6|r[t++]&63):n+=String.fromCharCode(e)}};function Ur(r,n){if(n){for(var t=new M(r.length),e=0;e>1)),f=0,h=function(s){a[f++]=s},e=0;ea.length){var v=new M(f+8+(i-e<<1));v.set(a),a=v}var u=r.charCodeAt(e);u<128||n?h(u):u<2048?(h(192|u>>6),h(128|u&63)):u>55295&&u<57344?(u=65536+(u&1047552)|r.charCodeAt(++e)&1023,h(240|u>>18),h(128|u>>12&63),h(128|u>>6&63),h(128|u&63)):(h(224|u>>12),h(128|u>>6&63),h(128|u&63))}return hr(a,0,f)}function Rr(r,n){if(n){for(var t="",e=0;e65535&&L(9),n+=e+4}return n},Er=function(r,n,t,e,i,a,f,h){var v=e.length,u=t.extra,s=h&&h.length,o=Fr(u);G(r,n,f!=null?33639248:67324752),n+=4,f!=null&&(r[n++]=20,r[n++]=t.os),r[n]=20,n+=2,r[n++]=t.flag<<1|(a<0&&8),r[n++]=i&&8,r[n++]=t.compression&255,r[n++]=t.compression>>8;var l=new Date(t.mtime==null?Date.now():t.mtime),p=l.getFullYear()-1980;if((p<0||p>119)&&L(10),G(r,n,p<<25|l.getMonth()+1<<21|l.getDate()<<16|l.getHours()<<11|l.getMinutes()<<5|l.getSeconds()>>1),n+=4,a!=-1&&(G(r,n,t.crc),G(r,n+4,a<0?-a-2:a),G(r,n+8,t.size)),G(r,n+12,v),G(r,n+14,o),n+=16,f!=null&&(G(r,n,s),G(r,n+6,t.attrs),G(r,n+10,f),n+=14),r.set(e,n),n+=v,o)for(var m in u){var w=u[m],x=w.length;G(r,n,+m),G(r,n+2,x),r.set(w,n+4),n+=4+x}return s&&(r.set(h,n),n+=s),n},fn=function(r,n,t,e,i){G(r,n,101010256),G(r,n+8,t),G(r,n+10,t),G(r,n+12,e),G(r,n+16,i)};function hn(r,n){n||(n={});var t={},e=[];Nr(r,"",t,n);var i=0,a=0;for(var f in t){var h=t[f],v=h[0],u=h[1],s=u.level==0?0:8,o=Ur(f),l=o.length,p=u.comment,m=p&&Ur(p),w=m&&m.length,x=Fr(u.extra);l>65535&&L(11);var T=s?rn(v,u):v,O=T.length,S=br();S.p(v),e.push(Hr(u,{size:v.length,crc:S.d(),c:T,f:o,m,u:l!=f.length||m&&p.length!=w,o:i,compression:s})),i+=30+l+x+O,a+=76+2*(l+x)+(w||0)+O}for(var U=new M(a+22),C=i,I=a-i,F=0;F65558)&&L(13);var i=K(r,e+8);if(!i)return{};var a=Y(r,e+16),f=Y(r,e-20)==117853008;if(f){var h=Y(r,e-12);f=Y(r,h)==101075792,f&&(i=Y(r,h+32),a=Y(r,h+48))}for(var v=n&&n.filter,u=0;u=0.25.0", + "fflate": "^0.8.3", "hast-util-raw": "^9.0.4", "hast-util-to-dom": "^4.0.1", "hast-util-to-html": "^9.0.3", @@ -5138,6 +5139,12 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", diff --git a/package.json b/package.json index 10ffcc9a1..c1d5b24da 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "nx2:test:file:watch": "wtr --config ./nx2/test/wtr.config.mjs --node-resolve --port=2000 --coverage --watch", "nx2:build:da-lit": "esbuild --format=esm --minify ./nx2/deps/lit/src/index.js --bundle --outfile=./nx2/deps/lit/dist/index.js", "nx2:build:spectrum": "node nx2/deps/spectrum/build.js", - "nx2:build:mdast": "esbuild --format=esm --minify ./nx2/deps/mdast/src/index.js --bundle --outfile=./nx2/deps/mdast/dist/index.js" + "nx2:build:mdast": "esbuild --format=esm --minify ./nx2/deps/mdast/src/index.js --bundle --outfile=./nx2/deps/mdast/dist/index.js", + "nx2:build:fflate": "esbuild --format=esm --minify ./nx2/deps/fflate/src/index.js --bundle --outfile=./nx2/deps/fflate/dist/index.js" }, "repository": { "type": "git", @@ -75,6 +76,7 @@ "@lit-labs/virtualizer": "^2.0.16", "codemirror": "^6.0.2", "esbuild": ">=0.25.0", + "fflate": "^0.8.3", "hast-util-raw": "^9.0.4", "hast-util-to-dom": "^4.0.1", "hast-util-to-html": "^9.0.3", From e305e53c0dccdcf39c869b7fe241adf9203d3a37 Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Fri, 4 Sep 2026 14:40:27 -0500 Subject: [PATCH 07/13] Fix GlobalLink target pagination and file-name collisions - listTargets: page through /rest/v0/targets (pageNumber) instead of silently truncating to the first 200 results - toFileName: escape existing underscores before flattening path separators, so distinct DA paths can't collide on the same GlobalLink upload file name - sendAllLanguages: persist submissionId onto options.service, matching the Smartling connector's convention --- nx/blocks/loc/connectors/globallink/index.js | 57 +++++++++++++++----- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/nx/blocks/loc/connectors/globallink/index.js b/nx/blocks/loc/connectors/globallink/index.js index 54895e4ca..c0ef6d185 100644 --- a/nx/blocks/loc/connectors/globallink/index.js +++ b/nx/blocks/loc/connectors/globallink/index.js @@ -62,14 +62,18 @@ async function authHeaders(service) { } /** - * Derives a GlobalLink-safe upload file name from a DA base path, flattening - * any nested folders and ensuring an extension is present. + * Derives a GlobalLink-safe upload file name from a DA base path, flattening any nested + * folders and ensuring an extension is present. Literal underscores are doubled before + * folder separators are collapsed to a single underscore, so distinct paths can't collide + * on the flattened name (e.g. "/blog/post-1" and "/blog_post-1" no longer both flatten to + * the same file name) — a real collision would silently drop one file from the upload zip. * @param {string} daBasePath - The DA-formatted base path (e.g. "/blog/post-1"). - * @returns {string} The flattened file name (e.g. "blog__post-1.html"). + * @returns {string} The flattened file name (e.g. "blog_post-1.html"). */ function toFileName(daBasePath) { const trimmed = (daBasePath || '/document').replace(/^\//, ''); - const safe = trimmed.replace(/[\\/]/g, '__') || 'document'; + const escaped = trimmed.split(/[\\/]/).map((segment) => segment.replace(/_/g, '__')).join('_'); + const safe = escaped || 'document'; return /\.[a-z0-9]+$/i.test(safe) ? safe : `${safe}.html`; } @@ -296,26 +300,31 @@ async function saveAndAutostart(service, submissionId) { return { started, messages: json?.messages ?? null }; } +const TARGETS_PAGE_SIZE = 200; +const TARGETS_PAGE_MAX = 50; + /** - * Lists a submission's targets (per-document, per-language translation records), - * optionally filtered by status and/or target language. + * Fetches a single page of a submission's targets. * @param {object} service - The flattened per-environment service config. * @param {string|number} submissionId - The submission whose targets to list. - * @param {object} [filters] - Optional query filters. + * @param {object} filters - Query filters. * @param {string} [filters.targetStatus] - Only return targets with this status. * @param {string} [filters.targetLanguage] - Only return targets for this language. - * @returns {Promise} The matching targets, or an empty array on failure. + * @param {number} pageNumber - The 1-based page number to fetch. + * @returns {Promise} The page's targets, or `null` on failure. */ -async function listTargets(service, submissionId, { targetStatus, targetLanguage } = {}) { +async function listTargetsPage(service, submissionId, filters, pageNumber) { + const { targetStatus, targetLanguage } = filters; const reqUrl = new URL(`${resolveOrigin(service)}/rest/v0/targets`); reqUrl.searchParams.set('submissionIds', submissionId); // 200 is the API's maximum page size — a larger value is rejected outright. - reqUrl.searchParams.set('pageSize', '200'); + reqUrl.searchParams.set('pageSize', String(TARGETS_PAGE_SIZE)); + reqUrl.searchParams.set('pageNumber', String(pageNumber)); if (targetStatus) reqUrl.searchParams.set('targetStatus', targetStatus); if (targetLanguage) reqUrl.searchParams.set('targetLanguage', targetLanguage); const resp = await fetch(reqUrl, { headers: await authHeaders(service) }); - if (!resp.ok) return []; + if (!resp.ok) return null; const json = await resp.json(); if (Array.isArray(json)) return json; if (Array.isArray(json?.targets)) return json.targets; @@ -323,6 +332,30 @@ async function listTargets(service, submissionId, { targetStatus, targetLanguage return []; } +/** + * Lists a submission's targets (per-document, per-language translation records), + * optionally filtered by status and/or target language. Pages through the full + * result set, stopping once a page comes back short of `TARGETS_PAGE_SIZE` (or after + * `TARGETS_PAGE_MAX` pages, as a safety net against an unexpected always-full-page response). + * @param {object} service - The flattened per-environment service config. + * @param {string|number} submissionId - The submission whose targets to list. + * @param {object} [filters] - Optional query filters. + * @param {string} [filters.targetStatus] - Only return targets with this status. + * @param {string} [filters.targetLanguage] - Only return targets for this language. + * @returns {Promise} All matching targets, or an empty array on failure. + */ +async function listTargets(service, submissionId, filters = {}) { + const targets = []; + for (let pageNumber = 1; pageNumber <= TARGETS_PAGE_MAX; pageNumber += 1) { + // eslint-disable-next-line no-await-in-loop + const page = await listTargetsPage(service, submissionId, filters, pageNumber); + if (!page) return pageNumber === 1 ? [] : targets; + targets.push(...page); + if (page.length < TARGETS_PAGE_SIZE) break; + } + return targets; +} + /** * Marks targets as delivered once their deliverables have been downloaded and successfully * saved back to DA, so GlobalLink stops re-surfacing them as pending on later status checks. @@ -518,7 +551,7 @@ export async function sendAllLanguages({ } // Persist for status / download - service.submissionId = { value: String(submissionId) }; + options.service.submissionId = { value: String(submissionId) }; sendMessage({ text: `Uploading ${urls.length} items to GlobalLink.` }); const { uploadedFileNames, overflowSubmissionIds } = await uploadSourceFiles( From e4ebffe4765b345d77f3aba388f610c76910e198 Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Fri, 4 Sep 2026 16:24:26 -0500 Subject: [PATCH 08/13] Match GlobalLink targets to DA urls by documentId, not file name - uploadSourceFiles: record a daBasePath -> documentId map from the upload response, persisted via sendAllLanguages onto options.service.documentIds - matchUrl: match targets by documentId only; drop clientIdentifier (a submission-level field, not per-document) and fuzzy file-name matching - listTargets: page with a 0-based pageNumber; drop the targetStatus/targetLanguage query params (unconfirmed for this endpoint) in favor of client-side filtering in each caller - saveItems/cancelTranslation: filter fetched targets by status (PROCESSED or DELIVERED) and target language locally --- nx/blocks/loc/connectors/globallink/index.js | 146 +++++++++++-------- 1 file changed, 88 insertions(+), 58 deletions(-) diff --git a/nx/blocks/loc/connectors/globallink/index.js b/nx/blocks/loc/connectors/globallink/index.js index c0ef6d185..25cc3f561 100644 --- a/nx/blocks/loc/connectors/globallink/index.js +++ b/nx/blocks/loc/connectors/globallink/index.js @@ -87,27 +87,48 @@ function dueDateMs(days) { } /** - * Finds the DA url entry that corresponds to a GlobalLink target, matching - * first by the uploaded `clientIdentifier`, then falling back to file name matching. + * Extracts a GlobalLink target's source document id, tolerating the different field + * names seen across GlobalLink API versions. + * @param {object} target - A GlobalLink target/document record. + * @returns {string|undefined} The document id, if present. + */ +function documentIdOf(target) { + const id = target.documentId ?? target.docId ?? target.document_id; + return id == null ? undefined : String(id); +} + +/** + * Finds the DA url entry that corresponds to a GlobalLink target, by `documentId` against + * the `daBasePath -> documentId` map recorded at upload time (see {@link uploadSourceFiles}). + * `clientIdentifier` isn't usable here — it identifies the submission, not individual + * documents — and file-name matching is fuzzy, since two documents' flattened names can + * overlap, so neither is used as a fallback. * @param {object[]} urls - The DA url entries to search. * @param {object} target - A GlobalLink target/document record. + * @param {object} documentIdsByPath - The `daBasePath -> documentId` map from upload time. * @returns {object|undefined} The matching url entry, if any. */ -function matchUrl(urls, target) { - const clientId = target.clientIdentifier || target.client_identifier; - if (clientId) { - const byClient = urls.find((url) => url.daBasePath === clientId); - if (byClient) return byClient; - } +function matchUrl(urls, target, documentIdsByPath) { + const targetDocId = documentIdOf(target); + if (!targetDocId) return undefined; + return urls.find((url) => documentIdsByPath[url.daBasePath] === targetDocId); +} - const docName = target.documentName || target.name || target.documentNameWithPath || ''; - return urls.find((url) => { - const fileName = toFileName(url.daBasePath); - return docName === fileName - || docName.endsWith(`/${fileName}`) - || docName.endsWith(`\\${fileName}`) - || docName.includes(fileName); - }); +/** + * Reads the `daBasePath -> documentId` map persisted by {@link sendAllLanguages}, used to + * precisely match GlobalLink targets back to DA urls (see {@link matchUrl}) instead of + * relying solely on fuzzy file-name matching. + * @param {object} service - The flattened per-environment service config, including the + * previously persisted `documentIds`. + * @returns {object} The map, or an empty object if absent/unparsable (e.g. a submission + * created before this map existed). + */ +function getDocumentIdsByPath(service) { + try { + return JSON.parse(service.documentIds?.value || '{}'); + } catch { + return {}; + } } /** @@ -238,14 +259,18 @@ async function createSubmission( * @param {object[]} urls - The DA url entries to upload. * @param {string} batchName - The name of the batch these documents belong to, matching the * one passed to {@link createSubmission}. - * @returns {Promise<{uploadedFileNames: Set, overflowSubmissionIds: string[]}>} The - * file names GlobalLink confirmed receiving, plus any other submission id(s) it placed some - * of them under. + * @returns {Promise<{uploadedFileNames: Set, overflowSubmissionIds: string[], + * documentIdsByPath: object}>} The file names GlobalLink confirmed receiving, any other + * submission id(s) it placed some of them under, and a `daBasePath -> documentId` map for + * precise status/download matching later (see {@link matchUrl}). */ async function uploadSourceFiles(service, submissionId, urls, batchName) { const files = {}; + const pathByFileName = new Map(); urls.forEach((url) => { - files[toFileName(url.daBasePath)] = strToU8(url.content); + const fileName = toFileName(url.daBasePath); + files[fileName] = strToU8(url.content); + pathByFileName.set(fileName, url.daBasePath); }); const zipped = zipSync(files); @@ -261,7 +286,9 @@ async function uploadSourceFiles(service, submissionId, urls, batchName) { headers: { Authorization: `Bearer ${token}`, ...originHeader(service) }, body, }); - if (!resp.ok) return { uploadedFileNames: new Set(), overflowSubmissionIds: [] }; + if (!resp.ok) { + return { uploadedFileNames: new Set(), overflowSubmissionIds: [], documentIdsByPath: {} }; + } // processId is returned asynchronously; submission-level status is polled after all uploads. const json = await resp.json().catch(() => null); @@ -273,7 +300,14 @@ async function uploadSourceFiles(service, submissionId, urls, batchName) { .filter((id) => id && id !== String(submissionId)), )]; - return { uploadedFileNames, overflowSubmissionIds }; + const documentIdsByPath = documentIds.reduce((acc, doc) => { + const daBasePath = pathByFileName.get(doc.name); + const documentId = doc.documentId ?? doc.id; + if (daBasePath && documentId != null) acc[daBasePath] = String(documentId); + return acc; + }, {}); + + return { uploadedFileNames, overflowSubmissionIds, documentIdsByPath }; } /** @@ -304,24 +338,21 @@ const TARGETS_PAGE_SIZE = 200; const TARGETS_PAGE_MAX = 50; /** - * Fetches a single page of a submission's targets. + * Fetches a single page of a submission's targets. Status/language filtering is done + * client-side (see {@link listTargets}'s callers) rather than via query params — GlobalLink's + * `targetStatus`/`targetLanguage` request params aren't confirmed valid for this endpoint, + * and a status filter would also need to cover both `PROCESSED` and `DELIVERED`. * @param {object} service - The flattened per-environment service config. * @param {string|number} submissionId - The submission whose targets to list. - * @param {object} filters - Query filters. - * @param {string} [filters.targetStatus] - Only return targets with this status. - * @param {string} [filters.targetLanguage] - Only return targets for this language. - * @param {number} pageNumber - The 1-based page number to fetch. + * @param {number} pageNumber - The 0-based page number to fetch. * @returns {Promise} The page's targets, or `null` on failure. */ -async function listTargetsPage(service, submissionId, filters, pageNumber) { - const { targetStatus, targetLanguage } = filters; +async function listTargetsPage(service, submissionId, pageNumber) { const reqUrl = new URL(`${resolveOrigin(service)}/rest/v0/targets`); reqUrl.searchParams.set('submissionIds', submissionId); // 200 is the API's maximum page size — a larger value is rejected outright. reqUrl.searchParams.set('pageSize', String(TARGETS_PAGE_SIZE)); reqUrl.searchParams.set('pageNumber', String(pageNumber)); - if (targetStatus) reqUrl.searchParams.set('targetStatus', targetStatus); - if (targetLanguage) reqUrl.searchParams.set('targetLanguage', targetLanguage); const resp = await fetch(reqUrl, { headers: await authHeaders(service) }); if (!resp.ok) return null; @@ -333,23 +364,21 @@ async function listTargetsPage(service, submissionId, filters, pageNumber) { } /** - * Lists a submission's targets (per-document, per-language translation records), - * optionally filtered by status and/or target language. Pages through the full - * result set, stopping once a page comes back short of `TARGETS_PAGE_SIZE` (or after - * `TARGETS_PAGE_MAX` pages, as a safety net against an unexpected always-full-page response). + * Lists all of a submission's targets (per-document, per-language translation records). + * Pages through the full result set, stopping once a page comes back short of + * `TARGETS_PAGE_SIZE` (or after `TARGETS_PAGE_MAX` pages, as a safety net against an + * unexpected always-full-page response). Callers filter the result themselves (by status, + * language, etc.) — see {@link isProcessed}, {@link isCancelled}, {@link targetLanguageOf}. * @param {object} service - The flattened per-environment service config. * @param {string|number} submissionId - The submission whose targets to list. - * @param {object} [filters] - Optional query filters. - * @param {string} [filters.targetStatus] - Only return targets with this status. - * @param {string} [filters.targetLanguage] - Only return targets for this language. - * @returns {Promise} All matching targets, or an empty array on failure. + * @returns {Promise} All of the submission's targets, or an empty array on failure. */ -async function listTargets(service, submissionId, filters = {}) { +async function listTargets(service, submissionId) { const targets = []; - for (let pageNumber = 1; pageNumber <= TARGETS_PAGE_MAX; pageNumber += 1) { + for (let pageNumber = 0; pageNumber < TARGETS_PAGE_MAX; pageNumber += 1) { // eslint-disable-next-line no-await-in-loop - const page = await listTargetsPage(service, submissionId, filters, pageNumber); - if (!page) return pageNumber === 1 ? [] : targets; + const page = await listTargetsPage(service, submissionId, pageNumber); + if (!page) return pageNumber === 0 ? [] : targets; targets.push(...page); if (page.length < TARGETS_PAGE_SIZE) break; } @@ -491,7 +520,7 @@ export function connect(service) { * @param {object} conf - The translation-send configuration. * @param {string} conf.title - The localization project title. * @param {object} conf.service - The flattened per-environment service config (mutated - * in place with the created `submissionId`). + * in place with the created `submissionId` and the `documentIds` daBasePath map). * @param {object} conf.options - The full localization project options, including any * `translation.service.custom.*` fields required as GlobalLink submission custom attributes. * @param {object[]} conf.langs - The target languages to send (mutated in place with @@ -554,12 +583,15 @@ export async function sendAllLanguages({ options.service.submissionId = { value: String(submissionId) }; sendMessage({ text: `Uploading ${urls.length} items to GlobalLink.` }); - const { uploadedFileNames, overflowSubmissionIds } = await uploadSourceFiles( + const { uploadedFileNames, overflowSubmissionIds, documentIdsByPath } = await uploadSourceFiles( service, submissionId, urls, batchName, ); + if (Object.keys(documentIdsByPath).length) { + options.service.documentIds = { value: JSON.stringify(documentIdsByPath) }; + } const accepted = urls.filter((url) => uploadedFileNames.has(toFileName(url.daBasePath))).length; if (overflowSubmissionIds.length) { @@ -639,6 +671,7 @@ export async function getStatusAll({ service, langs, urls, actions }) { sendMessage({ text: `Checking GlobalLink status for submission ${submissionId}.` }); const targets = await listTargets(service, submissionId); + const documentIdsByPath = getDocumentIdsByPath(service); langs.forEach((lang) => { lang.translation ??= {}; lang.translation.translated = 0; @@ -648,7 +681,7 @@ export async function getStatusAll({ service, langs, urls, actions }) { const cancelledCountByLang = {}; const processedByLang = {}; targets.forEach((target) => { - const matched = matchUrl(urls, target); + const matched = matchUrl(urls, target, documentIdsByPath); if (!matched) return; const langCode = targetLanguageOf(target); if (!langCode) return; @@ -714,21 +747,17 @@ export async function saveItems({ return urls; } - const targets = await listTargets(service, submissionId, { - targetStatus: 'PROCESSED', - targetLanguage: lang.code, - }); + const allTargets = await listTargets(service, submissionId); + const targets = allTargets.filter( + (entry) => isProcessed(entry) && targetLanguageOf(entry) === lang.code, + ); + const documentIdsByPath = getDocumentIdsByPath(service); const token = await getAccessToken(service); const deliveredTargetIds = []; const downloadCallback = async (url) => { - const target = targets.find((entry) => { - if (!isProcessed(entry)) return false; - const langCode = targetLanguageOf(entry); - if (langCode && langCode !== lang.code) return false; - return matchUrl([url], entry); - }); + const target = targets.find((entry) => matchUrl([url], entry, documentIdsByPath)); const targetId = target?.targetId || target?.id; if (!targetId) { @@ -794,8 +823,9 @@ export async function cancelTranslation({ service, lang, sendMessage }) { return { ok: false }; } - const targets = await listTargets(service, submissionId, { targetLanguage: lang.code }); - const targetIds = targets + const allTargets = await listTargets(service, submissionId); + const targetIds = allTargets + .filter((target) => targetLanguageOf(target) === lang.code) .map((target) => target.targetId ?? target.id) .filter((id) => id != null); From 6e996cbacefb9e83522067e6b9e237eb6d56f50f Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Fri, 4 Sep 2026 16:55:16 -0500 Subject: [PATCH 09/13] Add GlobalLink connector tests; consolidate auth onto the shared util - Add test/loc/connectors/globallink/index.test.js covering isConnected/connect/sendAllLanguages/getStatusAll/saveItems/ cancelTranslation, at parity with the Smartling connector tests, including target pagination and documentId-matching regressions - Delete the connector's own auth.js and switch to loc/utils/auth.js (as Trados/Lionbridge already do), fixing a bug where the login URL broke when DA_ETC resolves to undefined - Route every GlobalLink fetch through fetchWithRetry with a 401 onUnauthorized handler, so a stale/revoked token gets one forced refresh-and-retry instead of failing the request outright --- nx/blocks/loc/connectors/globallink/auth.js | 92 --- nx/blocks/loc/connectors/globallink/index.js | 108 ++- test/loc/connectors/globallink/index.test.js | 745 +++++++++++++++++++ 3 files changed, 819 insertions(+), 126 deletions(-) delete mode 100644 nx/blocks/loc/connectors/globallink/auth.js create mode 100644 test/loc/connectors/globallink/index.test.js diff --git a/nx/blocks/loc/connectors/globallink/auth.js b/nx/blocks/loc/connectors/globallink/auth.js deleted file mode 100644 index c57a60225..000000000 --- a/nx/blocks/loc/connectors/globallink/auth.js +++ /dev/null @@ -1,92 +0,0 @@ -import { daFetch } from '../../../../../nx2/utils/api.js'; -import { DA_ETC } from '../../../../../nx2/utils/utils.js'; - -const TOKEN_BUFFER_MS = 300000; // 5 min buffer before expiry - -/** - * Builds the localStorage key used to cache a site's GlobalLink access token. - * @param {string} org - The DA org. - * @param {string} site - The DA site. - * @param {string} env - The selected environment. - * @returns {string} The localStorage key. - */ -function tokenKey(org, site, env) { - return `globallink.${org}.${site}.${env}.token`; -} - -/** - * Reads the cached access token for a site/environment from localStorage. - * @param {string} org - The DA org. - * @param {string} site - The DA site. - * @param {string} env - The selected environment. - * @returns {{accessToken?: string, expires?: number}} The cached token details, or an - * empty object if none are stored or the stored value is invalid JSON. - */ -function getTokenDetails(org, site, env) { - const stored = localStorage.getItem(tokenKey(org, site, env)); - if (!stored) return {}; - try { - return JSON.parse(stored); - } catch { - return {}; - } -} - -/** - * Persists an access token for a site/environment to localStorage. - * @param {string} org - The DA org. - * @param {string} site - The DA site. - * @param {string} env - The selected environment. - * @param {string} accessToken - The GlobalLink access token. - * @param {number} expires - The epoch millisecond timestamp the token should be - * treated as expired by (already adjusted by {@link TOKEN_BUFFER_MS}). - * @returns {void} - */ -function setTokenDetails(org, site, env, accessToken, expires) { - localStorage.setItem( - tokenKey(org, site, env), - JSON.stringify({ accessToken, expires }), - ); -} - -/** - * Fetches (and caches) a GlobalLink access token. The OAuth exchange itself happens - * server-side in da-etc — the client secret and the GlobalLink user's password never - * reach the browser; this only ever sends the browser's own DA session auth. - * @param {object} service - The flattened per-environment service config. - * @param {string} service.org - The DA org. - * @param {string} service.site - The DA site. - * @param {string} [service.env] - The selected environment (defaults to `'prod'`). - * @returns {Promise} The access token, or `null` if login failed. - */ -export async function getAccessToken(service) { - const { org, site, env = 'prod' } = service; - - const { accessToken: cached, expires: cachedExpires } = getTokenDetails(org, site, env); - if (cached && cachedExpires > Date.now()) return cached; - - const opts = { method: 'POST' }; - const url = `${DA_ETC}/${org}/sites/${site}/integrations/globallink/login?env=${env}`; - - const resp = await daFetch({ url, opts }); - if (!resp.ok) return null; - - const { access_token: accessToken, expires_in: expiresIn } = await resp.json(); - if (!accessToken) return null; - - const expires = Date.now() + ((Number(expiresIn) || 0) * 1000) - TOKEN_BUFFER_MS; - setTokenDetails(org, site, env, accessToken, expires); - - return accessToken; -} - -/** - * Checks whether a usable GlobalLink access token is available, fetching one via - * da-etc if needed. - * @param {object} service - The flattened per-environment service config. - * @returns {Promise} Whether a valid access token is available. - */ -export default async function authReady(service) { - const accessToken = await getAccessToken(service); - return !!accessToken; -} diff --git a/nx/blocks/loc/connectors/globallink/index.js b/nx/blocks/loc/connectors/globallink/index.js index 25cc3f561..93b33d02a 100644 --- a/nx/blocks/loc/connectors/globallink/index.js +++ b/nx/blocks/loc/connectors/globallink/index.js @@ -2,10 +2,13 @@ import { Queue } from '../../../../../nx2/public/utils/tree.js'; import { addDnt, removeDnt } from '../../dnt/dnt.js'; import { DA_TRANSLATE } from '../../../../../nx2/utils/utils.js'; import { zipSync, strToU8 } from '../../../../../nx2/deps/fflate/dist/index.js'; -import authReady, { getAccessToken } from './auth.js'; +import authReady, { getAccessToken as getCachedAccessToken } from '../../utils/auth.js'; +import fetchWithRetry from '../../utils/fetchWithRetry.js'; export const dnt = { addDnt }; +const INTEGRATION_NAME = 'globallink'; + const DEFAULT_DUE_DATE_DAYS = 7; const PROCESS_POLL_MS = 2000; const PROCESS_POLL_MAX = 60; @@ -47,13 +50,13 @@ function originHeader(service) { /** * Builds the bearer-auth + JSON + proxy-origin headers used for authenticated * GlobalLink API calls routed through the DA_TRANSLATE proxy. The access token is - * obtained via {@link getAccessToken} (da-etc), never built from credentials here. + * obtained via da-etc (see `loc/utils/auth.js`), never built from credentials here. * @param {object} service - The flattened per-environment service config. * @param {string} service.endpoint - The real GlobalLink API base endpoint. * @returns {Promise} The request headers. */ async function authHeaders(service) { - const token = await getAccessToken(service); + const token = await getCachedAccessToken(INTEGRATION_NAME, service); return { Authorization: `Bearer ${token}`, ...originHeader(service), @@ -61,6 +64,35 @@ async function authHeaders(service) { }; } +/** + * Builds a `fetchWithRetry` `onUnauthorized` callback: forces a fresh GlobalLink login + * (bypassing the cached token, which da-etc can reject - e.g. revoked, or clock skew - + * even though the client's own expiry check still considered it valid) and rebuilds + * `opts` with the new bearer token, so a 401 triggers exactly one retry with a valid + * token instead of failing the request outright. + * @param {object} service - The flattened per-environment service config. + * @param {object} opts - The fetch options to rebuild on success. + * @returns {() => Promise} Callback for `fetchWithRetry`'s `onUnauthorized`. + */ +function onUnauthorized(service, opts) { + return async () => { + const token = await getCachedAccessToken(INTEGRATION_NAME, service, { force: true }); + if (!token) return null; + return { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${token}` } }; + }; +} + +/** + * Builds the `fetchWithRetry` config for a GlobalLink request: default rate-limit/ + * transient-failure backoff, plus a per-request `onUnauthorized` callback. + * @param {object} service - The flattened per-environment service config. + * @param {object} opts - The fetch options to rebuild on a 401. + * @returns {object} The `fetchWithRetry` config. + */ +function retryConfig(service, opts) { + return { onUnauthorized: onUnauthorized(service, opts) }; +} + /** * Derives a GlobalLink-safe upload file name from a DA base path, flattening any nested * folders and ensuring an extension is present. Literal underscores are doubled before @@ -142,10 +174,11 @@ function getDocumentIdsByPath(service) { */ async function waitForSubmissionReady(service, submissionId) { for (let i = 0; i < PROCESS_POLL_MAX; i += 1) { + const url = `${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/status`; // eslint-disable-next-line no-await-in-loop - const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/status`, { - headers: await authHeaders(service), - }); + const opts = { headers: await authHeaders(service) }; + // eslint-disable-next-line no-await-in-loop + const resp = await fetchWithRetry(url, opts, retryConfig(service, opts)); if (resp.ok) { // eslint-disable-next-line no-await-in-loop const json = await resp.json(); @@ -236,11 +269,9 @@ async function createSubmission( claimScope: 'LANGUAGE', }); - const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/create`, { - method: 'POST', - headers: await authHeaders(service), - body, - }); + const url = `${resolveOrigin(service)}/rest/v0/submissions/create`; + const opts = { method: 'POST', headers: await authHeaders(service), body }; + const resp = await fetchWithRetry(url, opts, retryConfig(service, opts)); if (!resp.ok) return null; const json = await resp.json(); return json.submissionId ?? json.id ?? null; @@ -280,12 +311,10 @@ async function uploadSourceFiles(service, submissionId, urls, batchName) { body.append('fileFormatName', service.fileFormatName); body.append('extractArchive', 'true'); - const token = await getAccessToken(service); - const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/upload/source`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}`, ...originHeader(service) }, - body, - }); + const token = await getCachedAccessToken(INTEGRATION_NAME, service); + const reqUrl = `${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/upload/source`; + const opts = { method: 'POST', headers: { Authorization: `Bearer ${token}`, ...originHeader(service) }, body }; + const resp = await fetchWithRetry(reqUrl, opts, retryConfig(service, opts)); if (!resp.ok) { return { uploadedFileNames: new Set(), overflowSubmissionIds: [], documentIdsByPath: {} }; } @@ -321,11 +350,13 @@ async function uploadSourceFiles(service, submissionId, urls, batchName) { * actually started, plus any messages GlobalLink returned (e.g. explaining why it didn't). */ async function saveAndAutostart(service, submissionId) { - const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/save`, { + const url = `${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/save`; + const opts = { method: 'POST', headers: await authHeaders(service), body: JSON.stringify({ autoStart: true }), - }); + }; + const resp = await fetchWithRetry(url, opts, retryConfig(service, opts)); if (!resp.ok) return { started: false, messages: null }; const json = await resp.json().catch(() => null); @@ -354,7 +385,8 @@ async function listTargetsPage(service, submissionId, pageNumber) { reqUrl.searchParams.set('pageSize', String(TARGETS_PAGE_SIZE)); reqUrl.searchParams.set('pageNumber', String(pageNumber)); - const resp = await fetch(reqUrl, { headers: await authHeaders(service) }); + const opts = { headers: await authHeaders(service) }; + const resp = await fetchWithRetry(reqUrl, opts, retryConfig(service, opts)); if (!resp.ok) return null; const json = await resp.json(); if (Array.isArray(json)) return json; @@ -395,11 +427,13 @@ async function listTargets(service, submissionId) { */ async function markTargetsDelivered(service, submissionId, targetIds) { if (!targetIds.length) return true; - const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/targets/delivered`, { + const url = `${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/targets/delivered`; + const opts = { method: 'POST', headers: await authHeaders(service), body: JSON.stringify({ targetIds }), - }); + }; + const resp = await fetchWithRetry(url, opts, retryConfig(service, opts)); return resp.ok; } @@ -418,7 +452,8 @@ async function requestDownload(service, submissionId, langCode) { reqUrl.searchParams.set('deliverableLanguages', langCode); reqUrl.searchParams.set('includeManifest', 'true'); - const resp = await fetch(reqUrl, { headers: await authHeaders(service) }); + const opts = { headers: await authHeaders(service) }; + const resp = await fetchWithRetry(reqUrl, opts, retryConfig(service, opts)); if (!resp.ok) return { downloadId: null, processingFinished: false }; const json = await resp.json().catch(() => null); return { downloadId: json?.downloadId ?? null, processingFinished: !!json?.processingFinished }; @@ -435,7 +470,8 @@ async function isDownloadReady(service, submissionId, downloadId) { const reqUrl = new URL(`${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/download`); reqUrl.searchParams.set('downloadId', downloadId); - const resp = await fetch(reqUrl, { headers: await authHeaders(service) }); + const opts = { headers: await authHeaders(service) }; + const resp = await fetchWithRetry(reqUrl, opts, retryConfig(service, opts)); if (!resp.ok) return false; const json = await resp.json().catch(() => null); return !!json?.processingFinished; @@ -496,12 +532,12 @@ function isCancelled(target) { /** * Checks whether there is a currently valid GlobalLink session, fetching an access * token via da-etc if needed. The client secret and GlobalLink password never reach - * the browser — see `auth.js`. + * the browser — see `loc/utils/auth.js`. * @param {object} service - The flattened per-environment service config. * @returns {Promise} Whether the connector is authenticated and ready to use. */ export function isConnected(service) { - return authReady(service); + return authReady(INTEGRATION_NAME, service); } /** @@ -511,7 +547,7 @@ export function isConnected(service) { * @returns {Promise} Whether authentication succeeded. */ export function connect(service) { - return authReady(service); + return authReady(INTEGRATION_NAME, service); } /** @@ -753,7 +789,6 @@ export async function saveItems({ ); const documentIdsByPath = getDocumentIdsByPath(service); - const token = await getAccessToken(service); const deliveredTargetIds = []; const downloadCallback = async (url) => { @@ -766,10 +801,13 @@ export async function saveItems({ } try { - const resp = await fetch( - `${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/targets/${targetId}/download/deliverable`, - { headers: { Authorization: `Bearer ${token}`, ...originHeader(service) } }, - ); + // Built per-download (not hoisted) so a background token refresh mid-batch + // is picked up instead of every download reusing whatever token was + // current when saveItems started. + const token = await getCachedAccessToken(INTEGRATION_NAME, service); + const reqUrl = `${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/targets/${targetId}/download/deliverable`; + const opts = { headers: { Authorization: `Bearer ${token}`, ...originHeader(service) } }; + const resp = await fetchWithRetry(reqUrl, opts, retryConfig(service, opts)); if (!resp.ok) throw new Error(resp.status); const text = await resp.text(); @@ -836,11 +874,13 @@ export async function cancelTranslation({ service, lang, sendMessage }) { sendMessage({ text: `Cancelling GlobalLink translation for ${lang.name}.` }); - const resp = await fetch(`${resolveOrigin(service)}/rest/v0/submissions/cancel/${submissionId}`, { + const url = `${resolveOrigin(service)}/rest/v0/submissions/cancel/${submissionId}`; + const opts = { method: 'POST', headers: await authHeaders(service), body: JSON.stringify({ targetIds }), - }); + }; + const resp = await fetchWithRetry(url, opts, retryConfig(service, opts)); if (!resp.ok) { const json = await resp.json().catch(() => null); diff --git a/test/loc/connectors/globallink/index.test.js b/test/loc/connectors/globallink/index.test.js new file mode 100644 index 000000000..6edcca736 --- /dev/null +++ b/test/loc/connectors/globallink/index.test.js @@ -0,0 +1,745 @@ +import { expect } from '@esm-bundle/chai'; +import { + connect, isConnected, sendAllLanguages, getStatusAll, saveItems, cancelTranslation, +} from '../../../../nx/blocks/loc/connectors/globallink/index.js'; +import { DA_TRANSLATE } from '../../../../nx2/utils/utils.js'; +import { unzipSync } from '../../../../nx2/deps/fflate/dist/index.js'; + +const org = 'acme'; +const site = 'site1'; +const proxyOrigin = `${DA_TRANSLATE}/translate/globallink/${org}/${site}`; +// DA_ETC resolves to undefined in this test env - auth.js falls back to this origin. +const loginUrl = `https://da-etc.adobeaem.workers.dev/${org}/sites/${site}/integrations/globallink/login?env=prod`; + +let calls; +let origFetch; + +function baseService(overrides = {}) { + return { + org, + site, + projectId: 'proj-1', + fileFormatName: 'HTML', + endpoint: 'https://real-globallink.example.com', + ...overrides, + }; +} + +// expires_in omitted so the cached token is always treated as expired (see auth.js's +// TOKEN_BUFFER_MS subtraction) - forces a fresh login call on every test. +function loginResponse(accessToken = 'gl-token') { + return new Response(JSON.stringify({ access_token: accessToken }), { status: 200 }); +} + +function defaultHandler(u) { + if (u.includes('/integrations/globallink/login')) return loginResponse(); + if (u.includes('/rest/v0/submissions/create')) { + return new Response(JSON.stringify({ submissionId: 'sub-1' }), { status: 200 }); + } + if (u.includes('/upload/source')) { + return new Response(JSON.stringify({ + documentIds: [{ name: 'page.html', documentId: 'doc-1', submissionId: 'sub-1' }], + }), { status: 200 }); + } + if (u.endsWith('/status')) { + return new Response(JSON.stringify({ status: 'READY' }), { status: 200 }); + } + if (u.endsWith('/save')) { + return new Response(JSON.stringify({ startedSubmissionIds: ['sub-1'] }), { status: 200 }); + } + if (u.includes('/download/deliverable')) { + return new Response('translated content', { status: 200 }); + } + if (u.includes('/download')) { + return new Response(JSON.stringify({ downloadId: 'dl-1', processingFinished: true }), { status: 200 }); + } + if (u.includes('/rest/v0/targets')) { + return new Response(JSON.stringify({ targets: [] }), { status: 200 }); + } + return new Response('{}', { status: 200 }); +} + +function installFetch(handler = defaultHandler) { + calls = []; + origFetch = window.fetch; + window.fetch = async (url, opts = {}) => { + const u = url.toString(); + calls.push({ url: u, method: opts.method, body: opts.body, headers: opts.headers }); + return handler(u, opts); + }; +} + +function restoreFetch() { + if (origFetch) window.fetch = origFetch; + origFetch = null; +} + +describe('globallink connector', () => { + beforeEach(() => { + localStorage.clear(); + installFetch(); + }); + afterEach(() => { + restoreFetch(); + localStorage.clear(); + }); + + describe('isConnected / connect', () => { + it('resolves true when the da-etc login succeeds', async () => { + const connected = await isConnected(baseService()); + + expect(connected).to.equal(true); + expect(calls[0].url).to.equal(loginUrl); + expect(calls[0].method).to.equal('POST'); + }); + + it('resolves false when the da-etc login fails', async () => { + installFetch(() => new Response('', { status: 401 })); + + expect(await isConnected(baseService())).to.equal(false); + }); + + it('connect behaves identically to isConnected', async () => { + expect(await connect(baseService())).to.equal(true); + }); + }); + + describe('401 recovery', () => { + it('recovers from a stale cached token by forcing a fresh login and retrying once', async () => { + let loginCalls = 0; + installFetch((u, opts) => { + if (u.includes('/integrations/globallink/login')) { + loginCalls += 1; + const accessToken = loginCalls === 1 ? 'stale-token' : 'fresh-token'; + const body = JSON.stringify({ access_token: accessToken, expires_in: 3600 }); + return new Response(body, { status: 200 }); + } + if (u.includes('/rest/v0/submissions/create')) { + if (opts.headers.Authorization !== 'Bearer fresh-token') return new Response('', { status: 401 }); + return new Response(JSON.stringify({ submissionId: 'sub-1' }), { status: 200 }); + } + return defaultHandler(u); + }); + + const service = baseService(); + const options = { service }; + const langs = [{ name: 'French', code: 'fr-FR' }]; + const urls = [{ daBasePath: '/page', content: '

hi

' }]; + const actions = { sendMessage: () => {}, saveState: async () => {} }; + + await sendAllLanguages({ + title: 't', service, options, langs, urls, actions, + }); + + expect(loginCalls).to.equal(2); + expect(langs[0].translation.status).to.equal('created'); + const createCalls = calls.filter((c) => c.url.includes('/rest/v0/submissions/create')); + expect(createCalls).to.have.length(2); + }); + + it('gives up without looping when the retried request also 401s', async () => { + installFetch((u) => { + if (u.includes('/integrations/globallink/login')) { + return new Response(JSON.stringify({ access_token: 'still-bad-token', expires_in: 3600 }), { status: 200 }); + } + if (u.includes('/rest/v0/submissions/create')) return new Response('', { status: 401 }); + return defaultHandler(u); + }); + + const service = baseService(); + const options = { service }; + const langs = [{ name: 'French', code: 'fr-FR' }]; + const urls = [{ daBasePath: '/page', content: '

hi

' }]; + const actions = { sendMessage: () => {}, saveState: async () => {} }; + + await sendAllLanguages({ + title: 't', service, options, langs, urls, actions, + }); + + const createCalls = calls.filter((c) => c.url.includes('/rest/v0/submissions/create')); + expect(createCalls).to.have.length(2); + expect(langs[0].translation.status).to.equal('error'); + }); + }); + + describe('sendAllLanguages', () => { + it('creates a submission, uploads sources, and marks langs created', async () => { + const service = baseService(); + const options = { service }; + const langs = [{ name: 'French', code: 'fr-FR' }]; + const urls = [{ daBasePath: '/page', content: '

hi

' }]; + const actions = { sendMessage: () => {}, saveState: async () => {} }; + + await sendAllLanguages({ + title: 'My Project', service, options, langs, urls, actions, + }); + + expect(calls.some((c) => c.url === `${proxyOrigin}/rest/v0/submissions/create`)).to.equal(true); + expect(calls.some((c) => c.url === `${proxyOrigin}/rest/v0/submissions/sub-1/upload/source`)).to.equal(true); + expect(langs[0].translation.status).to.equal('created'); + expect(langs[0].translation.sent).to.equal(1); + expect(service.submissionId.value).to.equal('sub-1'); + expect(JSON.parse(service.documentIds.value)).to.deep.equal({ '/page': 'doc-1' }); + }); + + it('marks every lang error and makes no submission calls when not connected', async () => { + installFetch(() => new Response('', { status: 401 })); + const service = baseService(); + const options = { service }; + const langs = [{ name: 'French', code: 'fr-FR' }]; + const urls = [{ daBasePath: '/page', content: '

hi

' }]; + const messages = []; + const actions = { sendMessage: (m) => messages.push(m), saveState: async () => {} }; + + await sendAllLanguages({ + title: 't', service, options, langs, urls, actions, + }); + + expect(langs[0].translation.status).to.equal('error'); + const errorMessage = messages.find((m) => m.type === 'error'); + expect(errorMessage.text).to.equal('Not connected to GlobalLink.'); + expect(calls.some((c) => c.url.includes('/rest/v0/submissions/create'))).to.equal(false); + }); + + it('errors when projectId or fileFormatName is missing', async () => { + const service = baseService({ fileFormatName: undefined }); + const options = { service }; + const langs = [{ name: 'French', code: 'fr-FR' }]; + const urls = [{ daBasePath: '/page', content: '

hi

' }]; + const messages = []; + const actions = { sendMessage: (m) => messages.push(m), saveState: async () => {} }; + + await sendAllLanguages({ + title: 't', service, options, langs, urls, actions, + }); + + const errorMessage = messages.find((m) => m.type === 'error'); + expect(errorMessage.text).to.include('projectId and fileFormatName are required'); + expect(langs[0].translation.status).to.equal('error'); + expect(calls.some((c) => c.url.includes('/rest/v0/submissions/create'))).to.equal(false); + }); + + it('errors and stops when submission creation fails', async () => { + installFetch((u) => { + if (u.includes('/rest/v0/submissions/create')) return new Response('{}', { status: 400 }); + return defaultHandler(u); + }); + const service = baseService(); + const options = { service }; + const langs = [{ name: 'French', code: 'fr-FR' }]; + const urls = [{ daBasePath: '/page', content: '

hi

' }]; + const messages = []; + const actions = { sendMessage: (m) => messages.push(m), saveState: async () => {} }; + + await sendAllLanguages({ + title: 't', service, options, langs, urls, actions, + }); + + const errorMessage = messages.find((m) => m.type === 'error'); + expect(errorMessage.text).to.equal('Failed to create GlobalLink submission.'); + expect(calls.some((c) => c.url.includes('/upload/source'))).to.equal(false); + }); + + it('aborts and reports partial upload when not all files are accepted', async () => { + installFetch((u) => { + if (u.includes('/upload/source')) { + return new Response(JSON.stringify({ + documentIds: [{ name: 'page-1.html', documentId: 'doc-1', submissionId: 'sub-1' }], + }), { status: 200 }); + } + return defaultHandler(u); + }); + const service = baseService(); + const options = { service }; + const langs = [{ name: 'French', code: 'fr-FR' }]; + const urls = [ + { daBasePath: '/page-1', content: '

1

' }, + { daBasePath: '/page-2', content: '

2

' }, + ]; + const messages = []; + let saveStateCalled = false; + const actions = { + sendMessage: (m) => messages.push(m), + saveState: async () => { saveStateCalled = true; }, + }; + + await sendAllLanguages({ + title: 't', service, options, langs, urls, actions, + }); + + const errorMessage = messages.find((m) => m.type === 'error' && m.text.includes('aborting save')); + expect(errorMessage.text).to.equal('Uploaded 1/2 items — aborting save.'); + expect(langs[0].translation.status).to.equal('error'); + expect(langs[0].translation.sent).to.equal(1); + expect(saveStateCalled).to.equal(true); + expect(calls.some((c) => c.url.endsWith('/save'))).to.equal(false); + }); + + it('warns when GlobalLink splits the upload into an overflow submission', async () => { + installFetch((u) => { + if (u.includes('/upload/source')) { + return new Response(JSON.stringify({ + documentIds: [ + { name: 'page-1.html', documentId: 'doc-1', submissionId: 'sub-1' }, + { name: 'page-2.html', documentId: 'doc-2', submissionId: 'sub-2' }, + ], + }), { status: 200 }); + } + return defaultHandler(u); + }); + const service = baseService(); + const options = { service }; + const langs = [{ name: 'French', code: 'fr-FR' }]; + const urls = [ + { daBasePath: '/page-1', content: '

1

' }, + { daBasePath: '/page-2', content: '

2

' }, + ]; + const messages = []; + const actions = { sendMessage: (m) => messages.push(m), saveState: async () => {} }; + + await sendAllLanguages({ + title: 't', service, options, langs, urls, actions, + }); + + const errorMessage = messages.find((m) => m.type === 'error' && m.text.includes('split')); + expect(errorMessage.text).to.include('sub-2'); + expect(langs[0].translation.status).to.equal('created'); + }); + + it('errors with GlobalLink\'s detail when save/autostart does not report the submission started', async () => { + installFetch((u) => { + if (u.endsWith('/save')) { + return new Response(JSON.stringify({ + startedSubmissionIds: [], + messages: ['Missing mandatory field Custom_Mandatory'], + }), { status: 200 }); + } + return defaultHandler(u); + }); + const service = baseService(); + const options = { service }; + const langs = [{ name: 'French', code: 'fr-FR' }]; + const urls = [{ daBasePath: '/page', content: '

hi

' }]; + const messages = []; + const actions = { sendMessage: (m) => messages.push(m), saveState: async () => {} }; + + await sendAllLanguages({ + title: 't', service, options, langs, urls, actions, + }); + + const errorMessage = messages.find((m) => m.type === 'error'); + expect(errorMessage.text).to.equal( + 'Failed to save/start GlobalLink submission. Missing mandatory field Custom_Mandatory', + ); + expect(langs[0].translation.status).to.equal('error'); + }); + + it('does not let two DA paths collide into the same uploaded file name', async () => { + let uploadedFiles; + installFetch(async (u, opts) => { + if (u.includes('/upload/source')) { + const zipBlob = opts.body.get('file'); + const buf = new Uint8Array(await zipBlob.arrayBuffer()); + uploadedFiles = unzipSync(buf); + const documentIds = Object.keys(uploadedFiles).map((name, i) => ( + { name, documentId: `doc-${i}`, submissionId: 'sub-1' } + )); + return new Response(JSON.stringify({ documentIds }), { status: 200 }); + } + return defaultHandler(u); + }); + const service = baseService(); + const options = { service }; + const langs = [{ name: 'French', code: 'fr-FR' }]; + const urls = [ + { daBasePath: '/blog/post-1', content: '

a

' }, + { daBasePath: '/blog_post-1', content: '

b

' }, + ]; + const actions = { sendMessage: () => {}, saveState: async () => {} }; + + await sendAllLanguages({ + title: 't', service, options, langs, urls, actions, + }); + + expect(Object.keys(uploadedFiles)).to.have.length(2); + expect(langs[0].translation.sent).to.equal(2); + }); + }); + + describe('getStatusAll', () => { + it('errors when no submissionId has been persisted yet', async () => { + const service = baseService(); + const messages = []; + const actions = { sendMessage: (m) => messages.push(m), saveState: async () => {} }; + + await getStatusAll({ + service, langs: [], urls: [], actions, + }); + + expect(messages[0].text).to.equal('No GlobalLink submissionId found for this project.'); + expect(calls.length).to.equal(0); + }); + + it('errors when not connected', async () => { + installFetch(() => new Response('', { status: 401 })); + const service = baseService({ submissionId: { value: 'sub-1' } }); + const messages = []; + const actions = { sendMessage: (m) => messages.push(m), saveState: async () => {} }; + + await getStatusAll({ + service, langs: [], urls: [], actions, + }); + + const errorMessage = messages.find((m) => m.type === 'error'); + expect(errorMessage.text).to.equal('Not connected to GlobalLink.'); + }); + + it('marks a lang translated once every matched target is processed', async () => { + installFetch((u) => { + if (u.includes('/rest/v0/targets')) { + return new Response(JSON.stringify({ + targets: [{ documentId: 'doc-1', targetLanguage: 'fr-FR', targetStatus: 'PROCESSED' }], + }), { status: 200 }); + } + return defaultHandler(u); + }); + const service = baseService({ + submissionId: { value: 'sub-1' }, + documentIds: { value: JSON.stringify({ '/page': 'doc-1' }) }, + }); + const langs = [{ code: 'fr-FR', translation: { translated: 0 } }]; + const urls = [{ daBasePath: '/page' }]; + 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('marks a lang cancelled when every matched target was cancelled', async () => { + installFetch((u) => { + if (u.includes('/rest/v0/targets')) { + return new Response(JSON.stringify({ + targets: [{ documentId: 'doc-1', targetLanguage: 'fr-FR', targetStatus: 'CANCELLED' }], + }), { status: 200 }); + } + return defaultHandler(u); + }); + const service = baseService({ + submissionId: { value: 'sub-1' }, + documentIds: { value: JSON.stringify({ '/page': 'doc-1' }) }, + }); + const langs = [{ code: 'fr-FR', translation: { translated: 0 } }]; + const urls = [{ daBasePath: '/page' }]; + const actions = { sendMessage: () => {}, saveState: async () => {} }; + + await getStatusAll({ + service, langs, urls, actions, + }); + + expect(langs[0].translation.status).to.equal('cancelled'); + }); + + it('ignores a target whose documentId is not in the persisted map', async () => { + installFetch((u) => { + if (u.includes('/rest/v0/targets')) { + return new Response(JSON.stringify({ + targets: [{ documentId: 'doc-999', targetLanguage: 'fr-FR', targetStatus: 'PROCESSED' }], + }), { status: 200 }); + } + return defaultHandler(u); + }); + const service = baseService({ + submissionId: { value: 'sub-1' }, + documentIds: { value: JSON.stringify({ '/page': 'doc-1' }) }, + }); + const langs = [{ code: 'fr-FR', translation: { translated: 0 } }]; + const urls = [{ daBasePath: '/page' }]; + const actions = { sendMessage: () => {}, saveState: async () => {} }; + + await getStatusAll({ + service, langs, urls, actions, + }); + + expect(langs[0].translation.translated).to.equal(0); + expect(langs[0].translation.status).to.equal(undefined); + }); + + it('pages through more than one page of targets', async () => { + const totalTargets = 201; + const documentIdsByPath = {}; + const urls = []; + for (let i = 0; i < totalTargets; i += 1) { + documentIdsByPath[`/page-${i}`] = `doc-${i}`; + urls.push({ daBasePath: `/page-${i}` }); + } + + const pageRequests = []; + installFetch((u) => { + if (u.includes('/rest/v0/targets')) { + const pageNumber = Number(new URL(u).searchParams.get('pageNumber')); + pageRequests.push(pageNumber); + const start = pageNumber * 200; + const end = Math.min(start + 200, totalTargets); + const targets = []; + for (let i = start; i < end; i += 1) { + targets.push({ documentId: `doc-${i}`, targetLanguage: 'fr-FR', targetStatus: 'PROCESSED' }); + } + return new Response(JSON.stringify({ targets }), { status: 200 }); + } + return defaultHandler(u); + }); + + const service = baseService({ + submissionId: { value: 'sub-1' }, + documentIds: { value: JSON.stringify(documentIdsByPath) }, + }); + const langs = [{ code: 'fr-FR', translation: { translated: 0 } }]; + const actions = { sendMessage: () => {}, saveState: async () => {} }; + + await getStatusAll({ + service, langs, urls, actions, + }); + + expect(pageRequests).to.deep.equal([0, 1]); + expect(langs[0].translation.translated).to.equal(totalTargets); + expect(langs[0].translation.status).to.equal('translated'); + }); + }); + + describe('saveItems', () => { + it('returns urls unchanged when there is no submissionId', async () => { + const service = baseService(); + const urls = [{ daBasePath: '/page', ext: 'html' }]; + + const result = await saveItems({ + org, site, service, lang: { code: 'fr-FR', name: 'French' }, urls, saveFn: async () => {}, sendMessage: () => {}, + }); + + expect(result).to.equal(urls); + expect(calls.length).to.equal(0); + }); + + it('returns urls unchanged when not connected', async () => { + installFetch(() => new Response('', { status: 401 })); + const service = baseService({ submissionId: { value: 'sub-1' } }); + const urls = [{ daBasePath: '/page', ext: 'html' }]; + + const result = await saveItems({ + org, site, service, lang: { code: 'fr-FR', name: 'French' }, urls, saveFn: async () => {}, sendMessage: () => {}, + }); + + expect(result).to.equal(urls); + }); + + it('errors and returns urls when deliverables are not yet ready', async () => { + installFetch((u) => { + if (u.includes('/download') && !u.includes('/download/deliverable')) { + return new Response( + JSON.stringify({ downloadId: null, processingFinished: false }), + { status: 200 }, + ); + } + return defaultHandler(u); + }); + const service = baseService({ submissionId: { value: 'sub-1' } }); + const urls = [{ daBasePath: '/page', ext: 'html' }]; + const messages = []; + + const result = await saveItems({ + org, + site, + service, + lang: { code: 'fr-FR', name: 'French' }, + urls, + saveFn: async () => {}, + sendMessage: (m) => messages.push(m), + }); + + expect(result).to.equal(urls); + const errorMessage = messages.find((m) => m.type === 'error'); + expect(errorMessage.text).to.include('are not ready yet'); + }); + + it('downloads processed deliverables, saves them, and marks targets delivered', async () => { + let deliveredBody; + installFetch((u, opts) => { + if (u.includes('/rest/v0/targets')) { + return new Response(JSON.stringify({ + targets: [{ + targetId: 'target-1', documentId: 'doc-1', targetLanguage: 'fr-FR', targetStatus: 'PROCESSED', + }], + }), { status: 200 }); + } + if (u.includes('/targets/delivered')) { + deliveredBody = JSON.parse(opts.body); + return new Response('{}', { status: 200 }); + } + return defaultHandler(u); + }); + const service = baseService({ + submissionId: { value: 'sub-1' }, + documentIds: { value: JSON.stringify({ '/page': 'doc-1' }) }, + }); + const urls = [{ daBasePath: '/page', ext: 'html' }]; + const saveFn = async (url) => { url.status = 'success'; }; + + const result = await saveItems({ + org, site, service, lang: { code: 'fr-FR', name: 'French' }, urls, saveFn, sendMessage: () => {}, + }); + + expect(result[0].status).to.equal('success'); + expect(result[0].sourceContent).to.be.a('string'); + expect(deliveredBody.targetIds).to.deep.equal(['target-1']); + }); + + it('marks a url errored when it cannot be matched to any target', async () => { + installFetch((u) => { + if (u.includes('/rest/v0/targets')) { + return new Response(JSON.stringify({ + targets: [{ + targetId: 'target-1', documentId: 'doc-1', targetLanguage: 'fr-FR', targetStatus: 'PROCESSED', + }], + }), { status: 200 }); + } + return defaultHandler(u); + }); + // No documentIds map persisted on the service, so matchUrl can't resolve anything. + const service = baseService({ submissionId: { value: 'sub-1' } }); + const urls = [{ daBasePath: '/page', ext: 'html' }]; + const saveFn = async (url) => { url.status = 'success'; }; + + const result = await saveItems({ + org, site, service, lang: { code: 'fr-FR', name: 'French' }, urls, saveFn, sendMessage: () => {}, + }); + + expect(result[0].status).to.equal('error'); + }); + + it('marks a url errored when the deliverable download fails', async () => { + installFetch((u) => { + if (u.includes('/rest/v0/targets')) { + return new Response(JSON.stringify({ + targets: [{ + targetId: 'target-1', documentId: 'doc-1', targetLanguage: 'fr-FR', targetStatus: 'PROCESSED', + }], + }), { status: 200 }); + } + if (u.includes('/download/deliverable')) return new Response('', { status: 404 }); + return defaultHandler(u); + }); + const service = baseService({ + submissionId: { value: 'sub-1' }, + documentIds: { value: JSON.stringify({ '/page': 'doc-1' }) }, + }); + const urls = [{ daBasePath: '/page', ext: 'html' }]; + const saveFn = async (url) => { url.status = 'success'; }; + + const result = await saveItems({ + org, site, service, lang: { code: 'fr-FR', name: 'French' }, urls, saveFn, sendMessage: () => {}, + }); + + expect(result[0].status).to.equal('error'); + }); + }); + + describe('cancelTranslation', () => { + it('skips when there is no submission to cancel', async () => { + const service = baseService(); + const messages = []; + + const result = await cancelTranslation({ + service, lang: { code: 'fr-FR', name: 'French' }, sendMessage: (m) => messages.push(m), + }); + + expect(result).to.deep.equal({ ok: true, skipped: true }); + expect(messages[0].text).to.include('No GlobalLink submission to cancel'); + }); + + it('fails when not connected', async () => { + installFetch(() => new Response('', { status: 401 })); + const service = baseService({ submissionId: { value: 'sub-1' } }); + const messages = []; + + const result = await cancelTranslation({ + service, lang: { code: 'fr-FR', name: 'French' }, sendMessage: (m) => messages.push(m), + }); + + expect(result).to.deep.equal({ ok: false }); + }); + + it('skips when there are no targets to cancel for the language', async () => { + installFetch((u) => { + if (u.includes('/rest/v0/targets')) { + return new Response(JSON.stringify({ + targets: [{ targetId: 'target-1', targetLanguage: 'de-DE' }], + }), { status: 200 }); + } + return defaultHandler(u); + }); + const service = baseService({ submissionId: { value: 'sub-1' } }); + const messages = []; + + const result = await cancelTranslation({ + service, lang: { code: 'fr-FR', name: 'French' }, sendMessage: (m) => messages.push(m), + }); + + expect(result).to.deep.equal({ ok: true, skipped: true }); + expect(messages[0].text).to.include('No GlobalLink targets found to cancel'); + }); + + it('cancels only the targets for the given language', async () => { + let cancelBody; + installFetch((u, opts) => { + if (u.includes('/rest/v0/targets')) { + return new Response(JSON.stringify({ + targets: [ + { targetId: 'target-fr', targetLanguage: 'fr-FR' }, + { targetId: 'target-de', targetLanguage: 'de-DE' }, + ], + }), { status: 200 }); + } + if (u.includes('/submissions/cancel/')) { + cancelBody = JSON.parse(opts.body); + return new Response('{}', { status: 200 }); + } + return defaultHandler(u); + }); + const service = baseService({ submissionId: { value: 'sub-1' } }); + + const result = await cancelTranslation({ + service, lang: { code: 'fr-FR', name: 'French' }, sendMessage: () => {}, + }); + + expect(result).to.deep.equal({ ok: true }); + expect(cancelBody.targetIds).to.deep.equal(['target-fr']); + }); + + it('surfaces an error message when the cancel request fails', async () => { + installFetch((u) => { + if (u.includes('/rest/v0/targets')) { + return new Response(JSON.stringify({ + targets: [{ targetId: 'target-fr', targetLanguage: 'fr-FR' }], + }), { status: 200 }); + } + if (u.includes('/submissions/cancel/')) { + return new Response(JSON.stringify({ messages: ['Targets already in progress'] }), { status: 400 }); + } + return defaultHandler(u); + }); + const service = baseService({ submissionId: { value: 'sub-1' } }); + const messages = []; + + const result = await cancelTranslation({ + service, lang: { code: 'fr-FR', name: 'French' }, sendMessage: (m) => messages.push(m), + }); + + expect(result).to.deep.equal({ ok: false }); + const errorMessage = messages.find((m) => m.type === 'error'); + expect(errorMessage.text).to.include('Targets already in progress'); + }); + }); +}); From f3dc9193904c7878232cabccd2a30e8b418f489d Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Tue, 8 Sep 2026 14:17:58 -0500 Subject: [PATCH 10/13] Send an IMS token to the GlobalLink proxy, matching da-translate's new gate da-translate now requires an IMS bearer token on Authorization for /translate/globallink, mirroring the Google connector's daFetch-based auth. GlobalLink's own credential moves to a new x-globallink-authorization header. isConnected/connect now also require a valid IMS session before making any proxy call. The IMS helpers (imsAccessToken/imsAuthHeader) live in the shared loc/utils/auth.js so other da-etc-backed connectors can reuse them. Co-Authored-By: Claude Sonnet 5 --- nx/blocks/loc/connectors/globallink/index.js | 67 +++++++++++++++----- nx/blocks/loc/utils/auth.js | 29 ++++++++- test/loc/connectors/globallink/index.test.js | 49 +++++++++++++- test/loc/utils/auth.test.js | 36 ++++++++++- 4 files changed, 161 insertions(+), 20 deletions(-) diff --git a/nx/blocks/loc/connectors/globallink/index.js b/nx/blocks/loc/connectors/globallink/index.js index 93b33d02a..9288170ad 100644 --- a/nx/blocks/loc/connectors/globallink/index.js +++ b/nx/blocks/loc/connectors/globallink/index.js @@ -2,7 +2,9 @@ import { Queue } from '../../../../../nx2/public/utils/tree.js'; import { addDnt, removeDnt } from '../../dnt/dnt.js'; import { DA_TRANSLATE } from '../../../../../nx2/utils/utils.js'; import { zipSync, strToU8 } from '../../../../../nx2/deps/fflate/dist/index.js'; -import authReady, { getAccessToken as getCachedAccessToken } from '../../utils/auth.js'; +import authReady, { + getAccessToken as getCachedAccessToken, imsAccessToken, imsAuthHeader, +} from '../../utils/auth.js'; import fetchWithRetry from '../../utils/fetchWithRetry.js'; export const dnt = { addDnt }; @@ -17,6 +19,10 @@ const DOWNLOAD_POLL_MAX = 60; const JSON_HEADERS = { 'Content-Type': 'application/json' }; const ORIGIN_HEADER = 'x-globallink-origin'; +// Carries GlobalLink's own bearer token. The Authorization header itself is reserved for +// the IMS token DA_TRANSLATE requires to gate access to the proxy (see imsAuthHeader) - +// GlobalLink's credential can't travel there too without colliding with it. +const CREDENTIAL_HEADER = 'x-globallink-authorization'; /** * Builds the DA_TRANSLATE proxy origin GlobalLink requests are routed through, so the @@ -48,9 +54,20 @@ function originHeader(service) { } /** - * Builds the bearer-auth + JSON + proxy-origin headers used for authenticated - * GlobalLink API calls routed through the DA_TRANSLATE proxy. The access token is - * obtained via da-etc (see `loc/utils/auth.js`), never built from credentials here. + * Builds the GlobalLink credential header for a request routed through the DA_TRANSLATE + * proxy. The access token is obtained via da-etc (see `loc/utils/auth.js`), never built + * from credentials here. Kept out of Authorization since that header carries the IMS + * token instead (see {@link imsAuthHeader}). + * @param {string} token - The GlobalLink access token. + * @returns {{[CREDENTIAL_HEADER]: string}} The header to merge into the request. + */ +function credentialHeader(token) { + return { [CREDENTIAL_HEADER]: `Bearer ${token}` }; +} + +/** + * Builds the IMS-auth + GlobalLink-credential + JSON + proxy-origin headers used for + * authenticated GlobalLink API calls routed through the DA_TRANSLATE proxy. * @param {object} service - The flattened per-environment service config. * @param {string} service.endpoint - The real GlobalLink API base endpoint. * @returns {Promise} The request headers. @@ -58,7 +75,8 @@ function originHeader(service) { async function authHeaders(service) { const token = await getCachedAccessToken(INTEGRATION_NAME, service); return { - Authorization: `Bearer ${token}`, + ...(await imsAuthHeader()), + ...credentialHeader(token), ...originHeader(service), ...JSON_HEADERS, }; @@ -69,7 +87,9 @@ async function authHeaders(service) { * (bypassing the cached token, which da-etc can reject - e.g. revoked, or clock skew - * even though the client's own expiry check still considered it valid) and rebuilds * `opts` with the new bearer token, so a 401 triggers exactly one retry with a valid - * token instead of failing the request outright. + * token instead of failing the request outright. A 401 caused by a stale IMS token + * instead of a stale GlobalLink one isn't recoverable here - `loadIms()` is expected to + * always hand back a live token, same as it does for `daFetch` elsewhere. * @param {object} service - The flattened per-environment service config. * @param {object} opts - The fetch options to rebuild on success. * @returns {() => Promise} Callback for `fetchWithRetry`'s `onUnauthorized`. @@ -78,7 +98,7 @@ function onUnauthorized(service, opts) { return async () => { const token = await getCachedAccessToken(INTEGRATION_NAME, service, { force: true }); if (!token) return null; - return { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${token}` } }; + return { ...opts, headers: { ...opts.headers, ...credentialHeader(token) } }; }; } @@ -313,7 +333,11 @@ async function uploadSourceFiles(service, submissionId, urls, batchName) { const token = await getCachedAccessToken(INTEGRATION_NAME, service); const reqUrl = `${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/upload/source`; - const opts = { method: 'POST', headers: { Authorization: `Bearer ${token}`, ...originHeader(service) }, body }; + const opts = { + method: 'POST', + headers: { ...(await imsAuthHeader()), ...credentialHeader(token), ...originHeader(service) }, + body, + }; const resp = await fetchWithRetry(reqUrl, opts, retryConfig(service, opts)); if (!resp.ok) { return { uploadedFileNames: new Set(), overflowSubmissionIds: [], documentIdsByPath: {} }; @@ -530,24 +554,30 @@ function isCancelled(target) { } /** - * Checks whether there is a currently valid GlobalLink session, fetching an access - * token via da-etc if needed. The client secret and GlobalLink password never reach - * the browser — see `loc/utils/auth.js`. + * Checks whether there is a currently valid GlobalLink session (fetching an access token + * via da-etc if needed) and a valid IMS session (DA_TRANSLATE requires both - every call + * routes through its proxy). The client secret and GlobalLink password never reach the + * browser — see `loc/utils/auth.js`. * @param {object} service - The flattened per-environment service config. * @returns {Promise} Whether the connector is authenticated and ready to use. */ -export function isConnected(service) { - return authReady(INTEGRATION_NAME, service); +export async function isConnected(service) { + const [glReady, imsToken] = await Promise.all([ + authReady(INTEGRATION_NAME, service), + imsAccessToken(), + ]); + return glReady && !!imsToken; } /** * Authenticates with GlobalLink. Identical to {@link isConnected} — both simply ensure - * a usable access token is available, obtained server-side by da-etc. + * a usable GlobalLink access token (obtained server-side by da-etc) and IMS session are + * available. * @param {object} service - The flattened per-environment service config. * @returns {Promise} Whether authentication succeeded. */ export function connect(service) { - return authReady(INTEGRATION_NAME, service); + return isConnected(service); } /** @@ -806,7 +836,12 @@ export async function saveItems({ // current when saveItems started. const token = await getCachedAccessToken(INTEGRATION_NAME, service); const reqUrl = `${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/targets/${targetId}/download/deliverable`; - const opts = { headers: { Authorization: `Bearer ${token}`, ...originHeader(service) } }; + const headers = { + ...(await imsAuthHeader()), + ...credentialHeader(token), + ...originHeader(service), + }; + const opts = { headers }; const resp = await fetchWithRetry(reqUrl, opts, retryConfig(service, opts)); if (!resp.ok) throw new Error(resp.status); diff --git a/nx/blocks/loc/utils/auth.js b/nx/blocks/loc/utils/auth.js index 3d20b945a..f0706d7cc 100644 --- a/nx/blocks/loc/utils/auth.js +++ b/nx/blocks/loc/utils/auth.js @@ -1,4 +1,4 @@ -import { daFetch } from '../../../../nx2/utils/api.js'; +import { daFetch, loadIms, handleSignIn } from '../../../../nx2/utils/api.js'; import { DA_ETC } from '../../../../nx2/utils/utils.js'; // DA_ETC_ENVS has no 'stage' entry, so DA_ETC resolves to undefined in a @@ -124,3 +124,30 @@ export default async function authReady(name, service) { const accessToken = await getAccessToken(name, service); return !!accessToken; } + +/** + * Resolves the current IMS access token, mirroring how `daFetch` authenticates calls to + * DA_TRANSLATE elsewhere (e.g. the Google connector). Connectors whose DA_TRANSLATE proxy + * requires IMS auth (e.g. GlobalLink) use this instead of building their own IMS session + * handling. Triggers the sign-in flow if no IMS session is available. + * @returns {Promise} The token, or `null` if no IMS session is available. + */ +export async function imsAccessToken() { + const { accessToken } = await loadIms(); + if (!accessToken) { + handleSignIn(); + return null; + } + return accessToken.token; +} + +/** + * Builds the Authorization header a DA_TRANSLATE proxy requires to gate access to a + * connector's endpoint. + * @returns {Promise<{Authorization?: string}>} The header to merge into the request, or + * `{}` if no IMS token could be obtained. + */ +export async function imsAuthHeader() { + const token = await imsAccessToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} diff --git a/test/loc/connectors/globallink/index.test.js b/test/loc/connectors/globallink/index.test.js index 6edcca736..de62718d7 100644 --- a/test/loc/connectors/globallink/index.test.js +++ b/test/loc/connectors/globallink/index.test.js @@ -5,6 +5,11 @@ import { import { DA_TRANSLATE } from '../../../../nx2/utils/utils.js'; import { unzipSync } from '../../../../nx2/deps/fflate/dist/index.js'; +// Dynamic-expression import (not a literal string) so @web/dev-server-import-maps +// does not rewrite this to ...?wds-import-map=0. See test/nx2/utils/api.test.js. +const imsPath = '../../../../nx2/utils/ims.js'; +const { setMockIms, resetMockIms } = await import(imsPath); + const org = 'acme'; const site = 'site1'; const proxyOrigin = `${DA_TRANSLATE}/translate/globallink/${org}/${site}`; @@ -76,6 +81,7 @@ function restoreFetch() { describe('globallink connector', () => { beforeEach(() => { + resetMockIms(); localStorage.clear(); installFetch(); }); @@ -102,6 +108,47 @@ describe('globallink connector', () => { it('connect behaves identically to isConnected', async () => { expect(await connect(baseService())).to.equal(true); }); + + it('resolves false when there is no IMS session, even with a valid GlobalLink login', async () => { + setMockIms({ anonymous: true }); + + expect(await isConnected(baseService())).to.equal(false); + }); + }); + + describe('IMS auth', () => { + it('sends the IMS bearer token as Authorization and the GlobalLink token as x-globallink-authorization', async () => { + const service = baseService(); + const options = { service }; + const langs = [{ name: 'French', code: 'fr-FR' }]; + const urls = [{ daBasePath: '/page', content: '

hi

' }]; + const actions = { sendMessage: () => {}, saveState: async () => {} }; + + await sendAllLanguages({ + title: 't', service, options, langs, urls, actions, + }); + + const createCall = calls.find((c) => c.url.includes('/rest/v0/submissions/create')); + expect(createCall.headers.Authorization).to.equal('Bearer test-token'); + expect(createCall.headers['x-globallink-authorization']).to.equal('Bearer gl-token'); + }); + + it('does not call the submission-create proxy endpoint when there is no IMS session', async () => { + setMockIms({ anonymous: true }); + const service = baseService(); + const options = { service }; + const langs = [{ name: 'French', code: 'fr-FR' }]; + const urls = [{ daBasePath: '/page', content: '

hi

' }]; + const messages = []; + const actions = { sendMessage: (m) => messages.push(m), saveState: async () => {} }; + + await sendAllLanguages({ + title: 't', service, options, langs, urls, actions, + }); + + expect(calls.some((c) => c.url.includes('/rest/v0/submissions/create'))).to.equal(false); + expect(langs[0].translation.status).to.equal('error'); + }); }); describe('401 recovery', () => { @@ -115,7 +162,7 @@ describe('globallink connector', () => { return new Response(body, { status: 200 }); } if (u.includes('/rest/v0/submissions/create')) { - if (opts.headers.Authorization !== 'Bearer fresh-token') return new Response('', { status: 401 }); + if (opts.headers['x-globallink-authorization'] !== 'Bearer fresh-token') return new Response('', { status: 401 }); return new Response(JSON.stringify({ submissionId: 'sub-1' }), { status: 200 }); } return defaultHandler(u); diff --git a/test/loc/utils/auth.test.js b/test/loc/utils/auth.test.js index b7f5f5357..09c51bd98 100644 --- a/test/loc/utils/auth.test.js +++ b/test/loc/utils/auth.test.js @@ -1,5 +1,12 @@ import { expect } from '@esm-bundle/chai'; -import authReady, { getAccessToken } from '../../../nx/blocks/loc/utils/auth.js'; +import authReady, { + getAccessToken, imsAccessToken, imsAuthHeader, +} from '../../../nx/blocks/loc/utils/auth.js'; + +// Dynamic-expression import (not a literal string) so @web/dev-server-import-maps +// does not rewrite this to ...?wds-import-map=0. See test/nx2/utils/api.test.js. +const imsPath = '../../../nx2/utils/ims.js'; +const { setMockIms, resetMockIms } = await import(imsPath); const LOGIN_ORIGIN = 'https://da-etc.adobeaem.workers.dev'; @@ -26,7 +33,10 @@ function tokenResponse(accessToken, expiresIn = 3600) { } describe('auth', () => { - beforeEach(() => localStorage.clear()); + beforeEach(() => { + resetMockIms(); + localStorage.clear(); + }); afterEach(() => { restoreFetch(); @@ -146,4 +156,26 @@ describe('auth', () => { expect(await authReady('example', { org: 'acme', site: 'site9', env: 'prod' })).to.equal(false); }); }); + + describe('imsAccessToken / imsAuthHeader', () => { + it('resolves the token from the current IMS session', async () => { + expect(await imsAccessToken()).to.equal('test-token'); + }); + + it('resolves null and does not throw when there is no IMS session', async () => { + setMockIms({ anonymous: true }); + + expect(await imsAccessToken()).to.equal(null); + }); + + it('builds an Authorization header from the IMS session', async () => { + expect(await imsAuthHeader()).to.deep.equal({ Authorization: 'Bearer test-token' }); + }); + + it('returns an empty header when there is no IMS session', async () => { + setMockIms({ anonymous: true }); + + expect(await imsAuthHeader()).to.deep.equal({}); + }); + }); }); From a77b4909fd87a71bf6ac18db0d6256f8003db15d Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Tue, 8 Sep 2026 18:42:06 -0500 Subject: [PATCH 11/13] Don't re-check status for already-complete/cancelled GlobalLink langs GlobalLink keeps reporting a delivered target as processed forever, so getStatusAll re-checking a lang after it's already saved would flip its status back to 'translated' and trigger a redundant re-save on the next status check (same bug already fixed in Smartling). Skip langs whose translation.status is 'complete' or 'cancelled' before fetching targets. --- nx/blocks/loc/connectors/globallink/index.js | 15 ++++-- test/loc/connectors/globallink/index.test.js | 54 +++++++++++++++++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/nx/blocks/loc/connectors/globallink/index.js b/nx/blocks/loc/connectors/globallink/index.js index 9288170ad..f166c21d4 100644 --- a/nx/blocks/loc/connectors/globallink/index.js +++ b/nx/blocks/loc/connectors/globallink/index.js @@ -707,7 +707,9 @@ export async function sendAllLanguages({ /** * Refreshes translation progress for a submission, marking languages as - * `translated` once every document has a processed target. + * `translated` once every document has a processed target. Languages already `complete` + * or `cancelled` are skipped, since GlobalLink keeps reporting delivered targets as + * processed indefinitely. * @param {object} conf - The status-check configuration. * @param {object} conf.service - The flattened per-environment service config, including * the previously persisted `submissionId`. @@ -728,6 +730,13 @@ export async function getStatusAll({ service, langs, urls, actions }) { return; } + // 'complete'/'cancelled' are terminal - GlobalLink keeps reporting a delivered target as + // processed forever, 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((lang) => !['complete', 'cancelled'].includes(lang.translation?.status)); + if (!activeLangs.length) return; + const connected = await isConnected(service); if (!connected) { sendMessage({ text: 'Not connected to GlobalLink.', type: 'error' }); @@ -738,7 +747,7 @@ export async function getStatusAll({ service, langs, urls, actions }) { const targets = await listTargets(service, submissionId); const documentIdsByPath = getDocumentIdsByPath(service); - langs.forEach((lang) => { + activeLangs.forEach((lang) => { lang.translation ??= {}; lang.translation.translated = 0; }); @@ -760,7 +769,7 @@ export async function getStatusAll({ service, langs, urls, actions }) { } }); - langs.forEach((lang) => { + activeLangs.forEach((lang) => { const targetCount = targetCountByLang[lang.code] || 0; const cancelledCount = cancelledCountByLang[lang.code] || 0; if (targetCount > 0 && cancelledCount === targetCount) { diff --git a/test/loc/connectors/globallink/index.test.js b/test/loc/connectors/globallink/index.test.js index de62718d7..ebf389585 100644 --- a/test/loc/connectors/globallink/index.test.js +++ b/test/loc/connectors/globallink/index.test.js @@ -430,11 +430,12 @@ describe('globallink connector', () => { it('errors when not connected', async () => { installFetch(() => new Response('', { status: 401 })); const service = baseService({ submissionId: { value: 'sub-1' } }); + const langs = [{ code: 'fr-FR', translation: { status: 'created' } }]; const messages = []; const actions = { sendMessage: (m) => messages.push(m), saveState: async () => {} }; await getStatusAll({ - service, langs: [], urls: [], actions, + service, langs, urls: [], actions, }); const errorMessage = messages.find((m) => m.type === 'error'); @@ -490,6 +491,57 @@ describe('globallink connector', () => { expect(langs[0].translation.status).to.equal('cancelled'); }); + it('does not revert a lang already saved to DA back to "translated"', async () => { + installFetch((u) => { + if (u.includes('/rest/v0/targets')) { + // GlobalLink keeps reporting a delivered target as processed indefinitely. + return new Response(JSON.stringify({ + targets: [{ documentId: 'doc-1', targetLanguage: 'fr-FR', targetStatus: 'DELIVERED' }], + }), { status: 200 }); + } + return defaultHandler(u); + }); + const service = baseService({ + submissionId: { value: 'sub-1' }, + documentIds: { value: JSON.stringify({ '/page': 'doc-1' }) }, + }); + const langs = [{ code: 'fr-FR', translation: { translated: 1, status: 'complete', saved: 1 } }]; + const urls = [{ daBasePath: '/page' }]; + const actions = { sendMessage: () => {}, saveState: async () => {} }; + + await getStatusAll({ + service, langs, urls, actions, + }); + + expect(langs[0].translation.status).to.equal('complete'); + expect(calls.length).to.equal(0); + }); + + it('does not revert a cancelled lang back to "translated"', async () => { + installFetch((u) => { + if (u.includes('/rest/v0/targets')) { + return new Response(JSON.stringify({ + targets: [{ documentId: 'doc-1', targetLanguage: 'fr-FR', targetStatus: 'PROCESSED' }], + }), { status: 200 }); + } + return defaultHandler(u); + }); + const service = baseService({ + submissionId: { value: 'sub-1' }, + documentIds: { value: JSON.stringify({ '/page': 'doc-1' }) }, + }); + const langs = [{ code: 'fr-FR', translation: { translated: 0, status: 'cancelled' } }]; + const urls = [{ daBasePath: '/page' }]; + const actions = { sendMessage: () => {}, saveState: async () => {} }; + + await getStatusAll({ + service, langs, urls, actions, + }); + + expect(langs[0].translation.status).to.equal('cancelled'); + expect(calls.length).to.equal(0); + }); + it('ignores a target whose documentId is not in the persisted map', async () => { installFetch((u) => { if (u.includes('/rest/v0/targets')) { From 3596419151bbb581061af5938edac54ddd07a68c Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Tue, 8 Sep 2026 18:55:20 -0500 Subject: [PATCH 12/13] Stop re-triggering IMS sign-in on every GlobalLink poll iteration waitForSubmissionReady/waitForDeliverablesReady called authHeaders on every loop tick, which calls handleSignIn() whenever there's no IMS session - if the session was lost mid-poll, this fired repeatedly (every 2-5s for up to several minutes) instead of once. Add a side-effect-free hasImsSession() check and bail out of both loops as soon as the session is gone. --- nx/blocks/loc/connectors/globallink/index.js | 18 +++++--- nx/blocks/loc/utils/auth.js | 11 +++++ test/loc/connectors/globallink/index.test.js | 47 ++++++++++++++++++++ test/loc/utils/auth.test.js | 14 +++++- 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/nx/blocks/loc/connectors/globallink/index.js b/nx/blocks/loc/connectors/globallink/index.js index f166c21d4..895dedef0 100644 --- a/nx/blocks/loc/connectors/globallink/index.js +++ b/nx/blocks/loc/connectors/globallink/index.js @@ -3,7 +3,7 @@ import { addDnt, removeDnt } from '../../dnt/dnt.js'; import { DA_TRANSLATE } from '../../../../../nx2/utils/utils.js'; import { zipSync, strToU8 } from '../../../../../nx2/deps/fflate/dist/index.js'; import authReady, { - getAccessToken as getCachedAccessToken, imsAccessToken, imsAuthHeader, + getAccessToken as getCachedAccessToken, hasImsSession, imsAccessToken, imsAuthHeader, } from '../../utils/auth.js'; import fetchWithRetry from '../../utils/fetchWithRetry.js'; @@ -188,12 +188,16 @@ function getDocumentIdsByPath(service) { * source files (or a maximum number of attempts is reached). * @param {object} service - The flattened per-environment service config. * @param {string|number} submissionId - The submission to poll. - * @returns {Promise} `false` if the submission reported an error/failure status; - * `true` otherwise (including the ambiguous/timeout case, since GlobalLink often finishes - * processing during save). + * @returns {Promise} `false` if the submission reported an error/failure status, or + * if the IMS session is lost mid-poll (stops polling immediately rather than repeatedly + * re-triggering IMS sign-in every attempt); `true` otherwise (including the ambiguous/ + * timeout case, since GlobalLink often finishes processing during save). */ async function waitForSubmissionReady(service, submissionId) { for (let i = 0; i < PROCESS_POLL_MAX; i += 1) { + // eslint-disable-next-line no-await-in-loop + if (!(await hasImsSession())) return false; + const url = `${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/status`; // eslint-disable-next-line no-await-in-loop const opts = { headers: await authHeaders(service) }; @@ -504,7 +508,8 @@ async function isDownloadReady(service, submissionId, downloadId) { /** * Waits for GlobalLink to finish preparing a language's completed deliverables, polling * every 5 seconds per GlobalLink's guidance (up to `DOWNLOAD_POLL_MAX` attempts) before any - * individual targets are downloaded. + * individual targets are downloaded. Stops polling immediately (rather than repeatedly + * re-triggering IMS sign-in every attempt) if the IMS session is lost mid-poll. * @param {object} service - The flattened per-environment service config. * @param {string|number} submissionId - The submission to wait on. * @param {string} langCode - The target language code to scope the wait to. @@ -515,6 +520,9 @@ async function waitForDeliverablesReady(service, submissionId, langCode) { if (!downloadId || processingFinished) return processingFinished; for (let i = 0; i < DOWNLOAD_POLL_MAX; i += 1) { + // eslint-disable-next-line no-await-in-loop + if (!(await hasImsSession())) return false; + // eslint-disable-next-line no-await-in-loop await new Promise((resolve) => { setTimeout(resolve, DOWNLOAD_POLL_MS); }); // eslint-disable-next-line no-await-in-loop diff --git a/nx/blocks/loc/utils/auth.js b/nx/blocks/loc/utils/auth.js index f0706d7cc..64a0a7ca6 100644 --- a/nx/blocks/loc/utils/auth.js +++ b/nx/blocks/loc/utils/auth.js @@ -125,6 +125,17 @@ export default async function authReady(name, service) { return !!accessToken; } +/** + * Checks whether an IMS session is currently available, without triggering the sign-in + * flow if not - unlike {@link imsAccessToken}, safe to call repeatedly (e.g. from inside a + * polling loop) without repeatedly invoking `handleSignIn()`. + * @returns {Promise} Whether a usable IMS access token is available. + */ +export async function hasImsSession() { + const { accessToken } = await loadIms(); + return !!accessToken; +} + /** * Resolves the current IMS access token, mirroring how `daFetch` authenticates calls to * DA_TRANSLATE elsewhere (e.g. the Google connector). Connectors whose DA_TRANSLATE proxy diff --git a/test/loc/connectors/globallink/index.test.js b/test/loc/connectors/globallink/index.test.js index ebf389585..239cc0805 100644 --- a/test/loc/connectors/globallink/index.test.js +++ b/test/loc/connectors/globallink/index.test.js @@ -411,6 +411,24 @@ describe('globallink connector', () => { expect(Object.keys(uploadedFiles)).to.have.length(2); expect(langs[0].translation.sent).to.equal(2); }); + + it('stops polling for submission-ready status once the IMS session is lost mid-wait', async () => { + installFetch((u) => { + if (u.includes('/upload/source')) setMockIms({ anonymous: true }); + return defaultHandler(u); + }); + const service = baseService(); + const options = { service }; + const langs = [{ name: 'French', code: 'fr-FR' }]; + const urls = [{ daBasePath: '/page', content: '

hi

' }]; + const actions = { sendMessage: () => {}, saveState: async () => {} }; + + await sendAllLanguages({ + title: 't', service, options, langs, urls, actions, + }); + + expect(calls.some((c) => c.url.endsWith('/status'))).to.equal(false); + }); }); describe('getStatusAll', () => { @@ -663,6 +681,35 @@ describe('globallink connector', () => { expect(errorMessage.text).to.include('are not ready yet'); }); + it('stops waiting for deliverables without polling further once the IMS session is lost', async () => { + installFetch((u) => { + if (u.includes('/download') && !u.includes('/download/deliverable')) { + setMockIms({ anonymous: true }); + return new Response( + JSON.stringify({ downloadId: 'dl-1', processingFinished: false }), + { status: 200 }, + ); + } + return defaultHandler(u); + }); + const service = baseService({ submissionId: { value: 'sub-1' } }); + const urls = [{ daBasePath: '/page', ext: 'html' }]; + const messages = []; + + const result = await saveItems({ + org, + site, + service, + lang: { code: 'fr-FR', name: 'French' }, + urls, + saveFn: async () => {}, + sendMessage: (m) => messages.push(m), + }); + + expect(result).to.equal(urls); + expect(calls.some((c) => c.url.includes('downloadId=dl-1'))).to.equal(false); + }); + it('downloads processed deliverables, saves them, and marks targets delivered', async () => { let deliveredBody; installFetch((u, opts) => { diff --git a/test/loc/utils/auth.test.js b/test/loc/utils/auth.test.js index 09c51bd98..666eec3a6 100644 --- a/test/loc/utils/auth.test.js +++ b/test/loc/utils/auth.test.js @@ -1,6 +1,6 @@ import { expect } from '@esm-bundle/chai'; import authReady, { - getAccessToken, imsAccessToken, imsAuthHeader, + getAccessToken, hasImsSession, imsAccessToken, imsAuthHeader, } from '../../../nx/blocks/loc/utils/auth.js'; // Dynamic-expression import (not a literal string) so @web/dev-server-import-maps @@ -178,4 +178,16 @@ describe('auth', () => { expect(await imsAuthHeader()).to.deep.equal({}); }); }); + + describe('hasImsSession', () => { + it('resolves true when there is a current IMS session', async () => { + expect(await hasImsSession()).to.equal(true); + }); + + it('resolves false without throwing or triggering sign-in when there is no IMS session', async () => { + setMockIms({ anonymous: true }); + + expect(await hasImsSession()).to.equal(false); + }); + }); }); From 27fcf7cb62674c91997c1046c845fc1b39156689 Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Tue, 8 Sep 2026 18:59:38 -0500 Subject: [PATCH 13/13] Convert createSubmission to a destructured object parameter 7 positional string/number args (title, langs, sourceLanguage, batchName, ...) made it easy to silently mis-order two adjacent same-typed args. No behavior change - the body already referenced every param by name. --- nx/blocks/loc/connectors/globallink/index.js | 46 ++++++++------------ 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/nx/blocks/loc/connectors/globallink/index.js b/nx/blocks/loc/connectors/globallink/index.js index 895dedef0..3157e3afe 100644 --- a/nx/blocks/loc/connectors/globallink/index.js +++ b/nx/blocks/loc/connectors/globallink/index.js @@ -257,27 +257,23 @@ function generateBatchName(title) { /** * Creates a new GlobalLink submission (with one batch targeting all requested languages). - * @param {object} service - The flattened per-environment service config. - * @param {string|number} service.projectId - The GlobalLink project id. - * @param {string} title - The localization project title, used to build the submission name. - * @param {object[]} langs - The target languages, each with a `code` (BCP-47 locale). - * @param {string} sourceLanguage - The source language code. - * @param {number} dueDateDays - The number of days until the submission is due. - * @param {{name: string, value: string}[]} customAttributes - Any project-required custom - * attributes (e.g. a mandatory field), from {@link extractCustomAttributes}. - * @param {string} batchName - The name of the batch to create within the submission. Must - * be unique within the submission and no more than 64 UTF-8 characters. + * @param {object} conf - The submission-create configuration. + * @param {object} conf.service - The flattened per-environment service config. + * @param {string|number} conf.service.projectId - The GlobalLink project id. + * @param {string} conf.title - The localization project title, used to build the + * submission name. + * @param {object[]} conf.langs - The target languages, each with a `code` (BCP-47 locale). + * @param {string} conf.sourceLanguage - The source language code. + * @param {number} conf.dueDateDays - The number of days until the submission is due. + * @param {{name: string, value: string}[]} conf.customAttributes - Any project-required + * custom attributes (e.g. a mandatory field), from {@link extractCustomAttributes}. + * @param {string} conf.batchName - The name of the batch to create within the submission. + * Must be unique within the submission and no more than 64 UTF-8 characters. * @returns {Promise} The created submission id, or `null` on failure. */ -async function createSubmission( - service, - title, - langs, - sourceLanguage, - dueDateDays, - customAttributes, - batchName, -) { +async function createSubmission({ + service, title, langs, sourceLanguage, dueDateDays, customAttributes, batchName, +}) { const body = JSON.stringify({ name: `${title}-${Date.now()}`, dueDate: dueDateMs(dueDateDays), @@ -635,15 +631,9 @@ export async function sendAllLanguages({ const batchName = generateBatchName(title); sendMessage({ text: `Creating GlobalLink submission for: ${title}.` }); - const submissionId = await createSubmission( - service, - title, - langs, - sourceLanguage, - dueDateDays, - customAttributes, - batchName, - ); + const submissionId = await createSubmission({ + service, title, langs, sourceLanguage, dueDateDays, customAttributes, batchName, + }); if (!submissionId) { sendMessage({ text: 'Failed to create GlobalLink submission.', type: 'error' }); langs.forEach((lang) => {