diff --git a/nx/blocks/loc/connectors/globallink/index.js b/nx/blocks/loc/connectors/globallink/index.js new file mode 100644 index 000000000..3157e3afe --- /dev/null +++ b/nx/blocks/loc/connectors/globallink/index.js @@ -0,0 +1,935 @@ +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, hasImsSession, imsAccessToken, imsAuthHeader, +} 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; +const DOWNLOAD_POLL_MS = 5000; +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 + * 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 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. + */ +async function authHeaders(service) { + const token = await getCachedAccessToken(INTEGRATION_NAME, service); + return { + ...(await imsAuthHeader()), + ...credentialHeader(token), + ...originHeader(service), + ...JSON_HEADERS, + }; +} + +/** + * 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. 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`. + */ +function onUnauthorized(service, opts) { + return async () => { + const token = await getCachedAccessToken(INTEGRATION_NAME, service, { force: true }); + if (!token) return null; + return { ...opts, headers: { ...opts.headers, ...credentialHeader(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 + * 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"). + */ +function toFileName(daBasePath) { + const trimmed = (daBasePath || '/document').replace(/^\//, ''); + const escaped = trimmed.split(/[\\/]/).map((segment) => segment.replace(/_/g, '__')).join('_'); + const safe = escaped || '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); +} + +/** + * 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, documentIdsByPath) { + const targetDocId = documentIdOf(target); + if (!targetDocId) return undefined; + return urls.find((url) => documentIdsByPath[url.daBasePath] === targetDocId); +} + +/** + * 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 {}; + } +} + +/** + * 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, 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) }; + // 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(); + 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; + }, []); +} + +/** + * 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} 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, +}) { + 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: batchName, + }], + claimScope: 'LANGUAGE', + }); + + 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; +} + +/** + * 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[]} 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[], + * 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) => { + const fileName = toFileName(url.daBasePath); + files[fileName] = strToU8(url.content); + pathByFileName.set(fileName, url.daBasePath); + }); + const zipped = zipSync(files); + + 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('extractArchive', 'true'); + + const token = await getCachedAccessToken(INTEGRATION_NAME, service); + const reqUrl = `${resolveOrigin(service)}/rest/v0/submissions/${submissionId}/upload/source`; + 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: {} }; + } + + // processId is returned asynchronously; submission-level status is polled after all uploads. + 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)), + )]; + + 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 }; +} + +/** + * 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 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); + const started = Array.isArray(json?.startedSubmissionIds) + && json.startedSubmissionIds.some((id) => String(id) === String(submissionId)); + return { started, messages: json?.messages ?? null }; +} + +const TARGETS_PAGE_SIZE = 200; +const TARGETS_PAGE_MAX = 50; + +/** + * 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 {number} pageNumber - The 0-based page number to fetch. + * @returns {Promise} The page's targets, or `null` on failure. + */ +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)); + + 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; + if (Array.isArray(json?.targets)) return json.targets; + if (Array.isArray(json?.items)) return json.items; + return []; +} + +/** + * 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. + * @returns {Promise} All of the submission's targets, or an empty array on failure. + */ +async function listTargets(service, submissionId) { + const targets = []; + for (let pageNumber = 0; pageNumber < TARGETS_PAGE_MAX; pageNumber += 1) { + // eslint-disable-next-line no-await-in-loop + const page = await listTargetsPage(service, submissionId, pageNumber); + if (!page) return pageNumber === 0 ? [] : 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. + * @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 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; +} + +/** + * 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 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 }; +} + +/** + * 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 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; +} + +/** + * 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. 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. + * @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 + 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 + 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. + * @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 (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 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 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 isConnected(service); +} + +/** + * 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` 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 + * `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; + + 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); + const batchName = generateBatchName(title); + + sendMessage({ text: `Creating GlobalLink submission for: ${title}.` }); + 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) => { + lang.translation ??= {}; + lang.translation.status = 'error'; + }); + return; + } + + // Persist for status / download + options.service.submissionId = { value: String(submissionId) }; + + sendMessage({ text: `Uploading ${urls.length} items to GlobalLink.` }); + 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) { + 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) { + 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. 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`. + * @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; + } + + // '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' }); + return; + } + + sendMessage({ text: `Checking GlobalLink status for submission ${submissionId}.` }); + + const targets = await listTargets(service, submissionId); + const documentIdsByPath = getDocumentIdsByPath(service); + activeLangs.forEach((lang) => { + lang.translation ??= {}; + lang.translation.translated = 0; + }); + + const targetCountByLang = {}; + const cancelledCountByLang = {}; + const processedByLang = {}; + targets.forEach((target) => { + const matched = matchUrl(urls, target, documentIdsByPath); + 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; + } + }); + + activeLangs.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. 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. + * @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. + * @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, sendMessage, +}) { + const submissionId = service.submissionId?.value; + if (!submissionId) return urls; + + 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 allTargets = await listTargets(service, submissionId); + const targets = allTargets.filter( + (entry) => isProcessed(entry) && targetLanguageOf(entry) === lang.code, + ); + const documentIdsByPath = getDocumentIdsByPath(service); + + const deliveredTargetIds = []; + + const downloadCallback = async (url) => { + const target = targets.find((entry) => matchUrl([url], entry, documentIdsByPath)); + + const targetId = target?.targetId || target?.id; + if (!targetId) { + url.status = 'error'; + return; + } + + try { + // 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 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); + + const text = await resp.text(); + 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'; + } + }; + + const queue = new Queue(downloadCallback, 5); + + return new Promise((resolve) => { + 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); + }); +} + +/** + * 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 }; + } + + const connected = await isConnected(service); + if (!connected) { + sendMessage({ text: 'Not connected to GlobalLink.', type: 'error' }); + return { ok: false }; + } + + 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); + + 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 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); + 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/nx/blocks/loc/utils/auth.js b/nx/blocks/loc/utils/auth.js index 3d20b945a..64a0a7ca6 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,41 @@ export default async function authReady(name, service) { const accessToken = await getAccessToken(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 + * 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/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", @@ -5125,6 +5126,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 90303d537..c1861f81e 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", diff --git a/test/loc/connectors/globallink/index.test.js b/test/loc/connectors/globallink/index.test.js new file mode 100644 index 000000000..239cc0805 --- /dev/null +++ b/test/loc/connectors/globallink/index.test.js @@ -0,0 +1,891 @@ +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'; + +// 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}`; +// 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(() => { + resetMockIms(); + 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); + }); + + 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', () => { + 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['x-globallink-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); + }); + + 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', () => { + 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 langs = [{ code: 'fr-FR', translation: { status: 'created' } }]; + 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('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')) { + 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('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) => { + 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'); + }); + }); +}); diff --git a/test/loc/utils/auth.test.js b/test/loc/utils/auth.test.js index b7f5f5357..666eec3a6 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, hasImsSession, 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,38 @@ 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({}); + }); + }); + + 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); + }); + }); });