UNPKG

scratch-l10n

Version:
635 lines (599 loc) 28.6 kB
/** * @file * Helper functions for syncing Freshdesk knowledge base articles with Transifex */ import { promises as fsPromises } from 'fs' import { mkdirp } from 'mkdirp' import { emitAnnotation } from './annotations.mts' import { describeJsonParseError, messageOf } from './errors.mts' import FreshdeskApi, { FreshdeskArticleCreate, FreshdeskArticleStatus, FreshdeskFolder, logAuthenticatedAgent, } from './freshdesk-api.mts' import { loadBaseline, saveBaseline, SyncBaseline } from './sync-baseline.mts' import { TransifexStringKeyValueJson, TransifexStringsKeyValueJson, TransifexStrings } from './transifex-formats.mts' import { TransifexResourceObject } from './transifex-objects.mts' import { txPull, txResourcesObjects, txAvailableLanguages, txResourceLanguageStats, transifexEditorLink, txPullErrorCause, SOURCE_LOCALE, } from './transifex.mts' import { emitWarning } from './warnings.mts' const FD = new FreshdeskApi('https://mitscratch.freshdesk.com', process.env.FRESHDESK_TOKEN ?? '') const TX_PROJECT = 'scratch-help' // Collect per-item failures instead of aborting on the first one, so one bad translation or a single // item the token cannot write still lets every other item sync. Freshdesk rate limiting is handled // transparently by the client (paced requests, `Retry-After`-aware retries), so anything that reaches // here is a genuine problem worth a human's attention. `reportFailures` prints a consolidated summary // and fails the run at the end, so real errors are surfaced rather than lost in the log. const failures: { context: string; message: string; link?: string }[] = [] const recordFailure = (context: string, error: unknown, link?: string): void => { const message = messageOf(error) // A txPull error carries the failing (resource, locale) — and, for a parse failure, the raw text — // on its `cause`, so we can build a Transifex link and describe malformed JSON without the call site // passing anything. An explicit link still wins: some callers know the fix lives on the source (`en`) // rather than the translation locale the pull used. const cause = txPullErrorCause(error) const resolvedLink = link ?? (cause ? transifexEditorLink({ project: cause.project, resource: cause.resource, lang: cause.locale }) : undefined) const detail = cause?.buffer != null ? describeJsonParseError(error, cause.buffer) : undefined const fullMessage = detail ? `${message}${detail}` : message // Genuine failures go to stderr (like the consolidated summary), so they stand apart from normal output. process.stderr.write(`Error: ${context}: ${fullMessage}${resolvedLink ? ` (${resolvedLink})` : ''}\n`) failures.push({ context, message: fullMessage, link: resolvedLink }) // Surface it on the run page too (no-op off CI). The full list still lives in stderr and the summary. emitAnnotation({ level: 'error', title: context, message: resolvedLink ? `${fullMessage}\n${resolvedLink}` : fullMessage, }) } /** * Print a summary of everything that failed during the sync and mark the process as failed if there * was anything. Call once, after all saves have completed. */ export const reportFailures = (): void => { if (failures.length === 0) { return } console.error(`\n${failures.length} item(s) failed to sync to Freshdesk:`) for (const failure of failures) { console.error(` - ${failure.context}: ${failure.message}${failure.link ? ` (${failure.link})` : ''}`) } process.exitCode = 1 } // --- Change detection ------------------------------------------------------- // The sync used to push every translation for every locale on every run (~10k+ Freshdesk writes), // which is what tripped the rate limit. We now pull per-(resource, locale) stats from Transifex once // and skip any pair whose translations have not changed since the last successful sync, so a normal // day writes only what actually moved. `DRY_RUN` does everything except issue the Freshdesk writes, // for validating the gate without mutating the knowledge base. /** When set (to a non-empty value other than "0"/"false"), do everything except issue Freshdesk writes. */ const DRY_RUN = !!process.env.DRY_RUN && process.env.DRY_RUN !== '0' && process.env.DRY_RUN.toLowerCase() !== 'false' /** * Wall-clock budget for a single run, in minutes (override with `HELP_SYNC_MAX_MINUTES`). When it is * exceeded the sync stops starting new writes, persists the baseline for everything that finished, and * exits cleanly. Durability across a cancellation is already handled by the incremental baseline flush * plus the workflow's save-on-cancel step, so this budget is the graceful path, not the safety net: * exiting cleanly under the step's hard `timeout-minutes` lets the run print its failure summary, exit * non-zero on genuine errors, and finish as a success rather than a cancellation -- so real problems * surface (and alert) while the baseline is still converging instead of being masked by a timeout. */ const RUN_TIME_BUDGET_MS = (() => { const minutes = Number(process.env.HELP_SYNC_MAX_MINUTES) return (Number.isFinite(minutes) && minutes > 0 ? minutes : 24) * 60_000 })() /** * When this run began, used to enforce {@link RUN_TIME_BUDGET_MS}. Each sync phase (`pull:help:names`, * `pull:help:articles`) runs as its own job step with its own step-level `timeout-minutes`, so this * per-process clock starts alongside that step's timeout: the soft budget sits just under it, stopping * new writes and exiting cleanly (reporting failures) before the step is hard-killed. */ const runStartedAt = Date.now() /** Count of changed, supported pairs left unattempted because the run hit its time budget. */ let deferredForBudget = 0 /** * Whether the run has used up its wall-clock budget and should stop starting new writes. Only active * once change detection is initialized, so the locale-debug script (which force-pulls) is unaffected. * @returns true if no further writes should be started this run */ const runBudgetExhausted = (): boolean => changeDetectionReady && Date.now() - runStartedAt >= RUN_TIME_BUDGET_MS /** Whether {@link initChangeDetection} has run; until it has, the gate is disabled (sync everything). */ let changeDetectionReady = false /** Transifex last-update timestamp per `<resourceSlug>:<localeCode>`, loaded once per run. */ let currentStats = new Map<string, string | null>() /** The baseline recorded by the previous successful sync, loaded once per run. */ let baseline: SyncBaseline = new Map() /** Pairs synced this run (or, in a dry run, that would sync), folded into the baseline at the end. */ const syncedPairs: SyncBaseline = new Map() /** Count of Freshdesk writes skipped in a dry run, reported at the end. */ let plannedWrites = 0 /** Whether the Freshdesk supported-language filter is active (helpdesk settings loaded successfully). */ let localeFilterActive = false /** Freshdesk supported language codes (lowercased) the knowledge base can hold translations for. */ const supportedFreshdeskLocales = new Set<string>() /** Freshdesk primary language (lowercased); its content lives on the base resource, not a translation. */ let primaryLanguage = '' /** * Load the Transifex per-(resource, locale) stats, the previous baseline, and the Freshdesk helpdesk * settings (which languages the KB can hold translations for). Call once before syncing. * @returns a promise that resolves once change-detection state is loaded */ export const initChangeDetection = async (): Promise<void> => { currentStats = await txResourceLanguageStats(TX_PROJECT) baseline = loadBaseline() try { const settings = await FD.getHelpdeskSettings() primaryLanguage = (settings.primary_language ?? '').toLowerCase() for (const code of settings.supported_languages ?? []) { supportedFreshdeskLocales.add(code.toLowerCase()) } localeFilterActive = supportedFreshdeskLocales.size > 0 } catch (error) { // If the settings can't be read, don't gate on language: attempt every locale and let the // create path warn about any Freshdesk won't accept. console.warn(`Could not read Freshdesk helpdesk settings; not filtering locales: ${messageOf(error)}`) } changeDetectionReady = true } /** * Whether Freshdesk can hold a translation for a Transifex locale: it must map to a configured * supported language and not be the primary language (whose content lives on the base resource). * @param locale - the Transifex locale code * @returns true if the locale should be pushed to Freshdesk */ const localeSupported = (locale: string): boolean => { // Until the filter is active (settings not loaded, or the locale-debug script), don't gate. if (!localeFilterActive) { return true } const fd = freshdeskLocale(locale).toLowerCase() return fd !== primaryLanguage && supportedFreshdeskLocales.has(fd) } /** * Emit a single warning naming the locales Freshdesk is not configured to hold translations for, so * someone reading the logs can decide whether to enable any of them in Freshdesk. * @param languages - the Transifex locales the sync would otherwise push */ export const reportSkippedLocales = (languages: string[]): void => { if (!localeFilterActive) { return } // Report the Freshdesk language codes (deduplicated: several Transifex locales can map to one), // because those are what an operator enables in Freshdesk -- not the raw Transifex locale codes. const skipped = [...new Set(languages.filter(l => l !== 'en' && !localeSupported(l)).map(freshdeskLocale))].sort() if (skipped.length > 0) { emitWarning( `Skipping ${skipped.length} Freshdesk language(s) not enabled for the knowledge base; their ` + `translations cannot sync until they are added under Admin > Helpdesk Settings: ${skipped.join(', ')}.`, ) } } /** * Whether a (resource, locale) pair has translations worth pushing: it must have translated content * and a newer last-update timestamp than the baseline recorded. * @param resource - the Transifex resource slug * @param locale - the Transifex locale code * @returns true if the pair changed since the last successful sync */ const pairChanged = (resource: string, locale: string): boolean => { // When change detection hasn't been initialized (for example the locale-debug script, which // deliberately force-pulls), don't gate — behave as before and sync everything requested. if (!changeDetectionReady) { return true } const current = currentStats.get(`${resource}:${locale}`) // No stats for the pair, or nothing translated yet -> nothing to push. if (!current) { return false } return baseline.get(`${resource}:${locale}`) !== current } /** * Record that a (resource, locale) pair synced successfully, so its baseline entry advances to the * current Transifex timestamp when the run finishes. * @param resource - the Transifex resource slug * @param locale - the Transifex locale code */ const markSynced = (resource: string, locale: string): void => { const current = currentStats.get(`${resource}:${locale}`) if (current) { syncedPairs.set(`${resource}:${locale}`, current) } } /** Minimum interval between incremental baseline flushes, to bound both progress loss and write churn. */ const BASELINE_FLUSH_INTERVAL_MS = 15_000 /** When the baseline was last flushed to disk, used to debounce {@link maybeFlushBaseline}. */ let lastBaselineFlushAt = 0 /** * Merge the pairs synced so far into the baseline and persist it to disk. A no-op in a dry run (which * must never mutate the baseline) and before change detection is initialized: a script that calls * saveItem without initChangeDetection (the locale-debug pull) leaves baseline/currentStats empty, and * persisting then would clobber the real baseline with an empty file, forcing the next sync to re-sync * everything. Safe to call from concurrently-running saveItem tasks: JS is single-threaded and * {@link saveBaseline} writes atomically, so calls cannot interleave a partial file. */ const flushBaseline = (): void => { if (DRY_RUN || !changeDetectionReady) { return } for (const [key, timestamp] of syncedPairs) { baseline.set(key, timestamp) } saveBaseline(baseline) lastBaselineFlushAt = Date.now() } /** * Persist the baseline if enough time has passed since the last flush. Called as the sync makes * progress so a run cancelled mid-sync (for example by its phase step's timeout) still keeps what it * finished on disk, letting the workflow's save-on-cancel step cache it and the next run continue * instead of restarting a full sync. The graceful path still calls {@link flushBaseline} once more at * the end via {@link finalizeSync}. */ const maybeFlushBaseline = (): void => { // Nothing to persist in a dry run or before change detection is initialized, so skip the debounce // entirely rather than calling flushBaseline (a no-op there) on every batch. flushBaseline keeps the // same guards for the finalizeSync path. if (DRY_RUN || !changeDetectionReady) { return } if (Date.now() - lastBaselineFlushAt >= BASELINE_FLUSH_INTERVAL_MS) { flushBaseline() } } /** * Finish the run: persist the advanced baseline (unless this is a dry run) and print the failure * summary. Call once, after all saves have completed. */ export const finalizeSync = (): void => { if (DRY_RUN) { console.log( `[dry-run] ${syncedPairs.size} (resource, locale) pair(s) changed and would sync, for ` + `${plannedWrites} Freshdesk write(s). No writes were issued and the baseline was left unchanged.`, ) } else { flushBaseline() console.log(`Synced ${syncedPairs.size} (resource, locale) pair(s); baseline now holds ${baseline.size} entries.`) } if (deferredForBudget > 0) { // Informational, not a warning: during a first-time full sync this is expected every run until the // backlog clears, and routing it through emitWarning would alert on every one of those runs. console.log( `Reached the ${RUN_TIME_BUDGET_MS / 60_000}-minute run budget with ${deferredForBudget} changed ` + `pair(s) not yet synced; they will sync on subsequent runs as the baseline converges.`, ) } reportFailures() } /** * Log which agent the configured Freshdesk token authenticates as, to aid in diagnosing permission * problems. Never throws. * @returns a promise that resolves once the agent has been logged */ export const logFreshdeskAgent = (): Promise<void> => logAuthenticatedAgent(FD) const freshdeskLocale = (locale: string): string => { // map between Transifex locale and Freshdesk. Two letter codes are usually fine const localeMap: Record<string, string> = { es_419: 'es-LA', ja: 'ja-JP', 'ja-Hira': 'ja-JP', lv: 'lv-LV', nb: 'nb-NO', nn: 'nb-NO', pt: 'pt-PT', pt_BR: 'pt-BR', ru: 'ru-RU', sv: 'sv-SE', zh_CN: 'zh-CN', zh_TW: 'zh-TW', } return localeMap[locale] || locale } /** * Parse a string into an integer. * If converting the integer back to a string does not result in the same string, throw. * @param str - The (allegedly) numeric string to parse * @param radix - Interpret the string as a number in this base. For example, use 10 for decimal values. * @returns The numeric value of the string */ const parseIntOrThrow = (str: string, radix: number) => { const num = parseInt(str, radix) if (str != num.toString(radix)) { throw new Error(`Could not parse int safely: ${str}`) } return num } /** * Pull metadata from Transifex for the scratch-help project * @returns Promise for a results object containing: * languages - array of supported languages * folders - array of tx resources corresponding to Freshdesk folders * names - array of tx resources corresponding to the Freshdesk metadata */ export const getInputs = async () => { const resourcesPromise = txResourcesObjects(TX_PROJECT) const languagesPromise = txAvailableLanguages(TX_PROJECT) // there are three types of resources differentiated by the file type const foldersPromise = resourcesPromise.then(resources => resources.filter(resource => resource.attributes.i18n_type === 'STRUCTURED_JSON'), ) const namesPromise = resourcesPromise.then(resources => resources.filter(resource => resource.attributes.i18n_type === 'KEYVALUEJSON'), ) // ignore the yaml type because it's not possible to update via API const [languages, folders, names] = await Promise.all([languagesPromise, foldersPromise, namesPromise]) return { languages, folders, names, } } /** * Fetch the current set of valid category and folder IDs from Freshdesk. * Used to detect stale entries in Transifex that refer to deleted Freshdesk items. * @returns Promise for an object containing: * validCategoryIds - set of Freshdesk category IDs that currently exist * validFolderIds - set of Freshdesk folder IDs that currently exist */ export const getValidFreshdeskIds = async () => { const categories = await FD.listCategories() const categoriesWithId = categories.filter((c): c is typeof c & { id: number } => c.id !== undefined) const validCategoryIds = new Set(categoriesWithId.map(c => c.id)) const fdFolders: FreshdeskFolder[] = [] for (const category of categoriesWithId) { const folders = await FD.listFolders(category) fdFolders.push(...folders) } const validFolderIds = new Set(fdFolders.map(f => f.id).filter((id): id is number => id !== undefined)) return { validCategoryIds, validFolderIds } } /** * internal function to serialize saving category and folder name translations to avoid Freshdesk rate limit * @param strings - the string data pulled from Transifex * @param resource - the `attributes` property of the resource object which contains these strings * @param locale - the Transifex locale code corresponding to these strings * @param validIds - set of Freshdesk IDs that currently exist; keys not in this set are skipped * @param warnedKeys - tracks which stale resource+ID combinations have already been reported, to avoid repeating the warning * @returns true if every entry saved without a failure (stale-key skips do not count as failures) */ const serializeNameSave = async ( strings: TransifexStringsKeyValueJson, resource: TransifexResourceObject, locale: string, validIds: Set<number>, warnedKeys: Set<string>, ): Promise<boolean> => { let allOk = true for (const [key, value] of Object.entries(strings)) { // Handle each entry independently: a failure on one item (for example a translation the token // is not allowed to write) must not abort the remaining names for this locale. try { // key is of the form <name>_<id> const words = key.split('_') const id = parseIntOrThrow(words[words.length - 1], 10) if (!validIds.has(id)) { const warnedKey = `${resource.attributes.name}:${id}` if (!warnedKeys.has(warnedKey)) { warnedKeys.add(warnedKey) emitWarning( `Warning: key "${key}" in Transifex resource "${resource.attributes.name}" refers to Freshdesk id ${id} which no longer exists. Remove this key from the Transifex resource.`, // The key is removed from the source, so point at the source language, not a translation. transifexEditorLink({ project: TX_PROJECT, resource: resource.attributes.slug, lang: SOURCE_LOCALE }), ) } continue } // Guard against silently dropping an unexpected resource: this function only knows how to write // category and folder names. A new KEYVALUEJSON names resource would otherwise no-op while // still reporting success. if (resource.attributes.name !== 'categoryNames_json' && resource.attributes.name !== 'folderNames_json') { throw new Error(`Unexpected names resource "${resource.attributes.name}"; don't know how to save it`) } if (DRY_RUN) { plannedWrites++ } else if (resource.attributes.name === 'categoryNames_json') { // A skipped write (Freshdesk reports it already exists) must not advance the baseline. if (!(await FD.updateCategoryTranslation(id, freshdeskLocale(locale), { name: value }))) { allOk = false } } else if (!(await FD.updateFolderTranslation(id, freshdeskLocale(locale), { name: value }))) { allOk = false } } catch (error) { // Record the failure so the job still reports a non-zero exit at the end, then move on to the // next entry. recordFailure( `${resource.attributes.name} entry "${key}" for ${locale}`, error, transifexEditorLink({ project: TX_PROJECT, resource: resource.attributes.slug, lang: locale }), ) allOk = false } } return allOk } /** * We use this specific structure in the `STRUCTUREDJSON` resources associated with our Freshdesk folders. * This should be compatible with (and stricter than) `TransifexStringStructuredJson`. */ interface FreshdeskFolderInTransifex { title: { string: string } description: { string: string } tags: { string: string } } /** * Internal function serialize Freshdesk requests to avoid getting rate limited * @param json object with keys corresponding to article ids * @param locale language code * @param resourceSlug Transifex resource slug for these articles, used to build editor links in warnings and failures * @returns true if every article saved without a failure */ const serializeFolderSave = async ( json: TransifexStrings<FreshdeskFolderInTransifex>, locale: string, resourceSlug: string, ): Promise<boolean> => { let allOk = true for (const [idString, value] of Object.entries(json)) { // Handle each article independently: a failure on one must not abort the rest for this locale. try { const id = parseIntOrThrow(idString, 10) const body: FreshdeskArticleCreate = { title: value.title.string, description: value.description.string, status: FreshdeskArticleStatus.published, } if (Object.prototype.hasOwnProperty.call(value, 'tags')) { const tags = value.tags.string.split(',') // Freshdesk rejects tags longer than 32 characters, so drop them rather than fail the write. const validTags = tags.filter(tag => tag.length < 33) const droppedTags = tags.filter(tag => tag.length >= 33) if (droppedTags.length > 0) { emitWarning( `Dropping ${droppedTags.length} tag(s) over Freshdesk's 32-character limit for article ${id} ` + `(${locale}); shorten them in Transifex: ${droppedTags .map(tag => `"${tag}" (${tag.length} chars)`) .join(', ')}.`, transifexEditorLink({ project: TX_PROJECT, resource: resourceSlug, lang: locale }), ) } body.tags = validTags } if (DRY_RUN) { plannedWrites++ } else if (!(await FD.updateArticleTranslation(id, freshdeskLocale(locale), body))) { // A skipped write (Freshdesk reports it already exists) must not advance the baseline. allOk = false } } catch (error) { // Record the failure so the job still reports a non-zero exit at the end, then move on to the // next article. recordFailure( `article ${idString} for ${locale}`, error, transifexEditorLink({ project: TX_PROJECT, resource: resourceSlug, lang: locale }), ) allOk = false } } return allOk } /** * Process Transifex resource corresponding to a Knowledge base folder on Freshdesk * @param folderAttributes Transifex resource json corresponding to a KB folder * @param locale locale to pull and submit to Freshdesk * @returns true if the folder's articles for this locale all saved without a failure */ export const localizeFolder = async (folderAttributes: TransifexResourceObject, locale: string): Promise<boolean> => { try { const data = await txPull<FreshdeskFolderInTransifex>( TX_PROJECT, folderAttributes.attributes.slug, locale, 'default', ) return await serializeFolderSave(data, locale, folderAttributes.attributes.slug) } catch (e) { recordFailure(`${folderAttributes.attributes.slug} for ${locale}`, e) return false } } /** * Save Transifex resource corresponding to a Knowledge base folder locally for debugging * @param folderAttributes Transifex resource json corresponding to a KB folder * @param locale locale to pull and save * @returns true if the resource was pulled and written to the debug file */ export const debugFolder = async (folderAttributes: TransifexResourceObject, locale: string): Promise<boolean> => { await mkdirp('tmpDebug') try { const data = await txPull(TX_PROJECT, folderAttributes.attributes.slug, locale, 'default') await fsPromises.writeFile( `tmpDebug/${folderAttributes.attributes.slug}_${locale}.json`, JSON.stringify(data, null, 2), ) return true } catch (e) { process.stdout.write(`Error processing ${folderAttributes.attributes.slug}, ${locale}: ${messageOf(e)}\n`) process.exitCode = 1 // not ok return false } } /** * Process KEYVALUEJSON resources from scratch-help on transifex * Category and Folder names are stored as plain json * @param resource Transifex resource json for either CategoryNames or FolderNames * @param locale locale to pull and submit to Freshdesk * @param validCategoryIds - set of Freshdesk category IDs that currently exist * @param validFolderIds - set of Freshdesk folder IDs that currently exist * @param warnedKeys - tracks which stale resource+ID combinations have already been reported, to avoid repeating the warning * @returns true if the names for this locale all saved without a failure */ export const localizeNames = async ( resource: TransifexResourceObject, locale: string, validCategoryIds: Set<number>, validFolderIds: Set<number>, warnedKeys: Set<string>, ): Promise<boolean> => { const validIds = resource.attributes.name === 'categoryNames_json' ? validCategoryIds : validFolderIds try { const data = await txPull<TransifexStringKeyValueJson>(TX_PROJECT, resource.attributes.slug, locale, 'default') return await serializeNameSave(data, resource, locale, validIds, warnedKeys) } catch (e) { recordFailure(`${resource.attributes.slug} for ${locale}`, e) return false } } const BATCH_SIZE = 2 type SaveFn = (item: TransifexResourceObject, language: string) => Promise<boolean> /** * Save a resource's translations in batches (to reduce rate-limit pressure), skipping any locale that * has not changed since the last successful sync. A locale is recorded as synced only after its save * reports success, so a partial or failed run re-syncs that pair next time instead of silently * skipping it. * @param item - Transifex resource object, used for its slug * @param languages - array of locales to consider saving * @param saveFn - async function that saves the item for one locale and returns whether it fully succeeded * @returns a promise that resolves once every changed locale has been attempted */ export const saveItem = async (item: TransifexResourceObject, languages: string[], saveFn: SaveFn): Promise<void> => { const slug = item.attributes.slug // Exclude English (the source), locales Freshdesk can't hold, and pairs that haven't changed. const saveLanguages = languages.filter(l => l !== 'en' && localeSupported(l) && pairChanged(slug, l)) for (let i = 0; i < saveLanguages.length; i += BATCH_SIZE) { // Out of time: leave the rest of this resource's locales for a later run. They stay unmarked, so // the next run still sees them as changed and picks them up. if (runBudgetExhausted()) { deferredForBudget += saveLanguages.length - i break } await Promise.all( saveLanguages.slice(i, i + BATCH_SIZE).map(async l => { try { if (await saveFn(item, l)) { markSynced(slug, l) } } catch (err) { recordFailure( `saving ${slug} for ${l}`, err, transifexEditorLink({ project: TX_PROJECT, resource: slug, lang: l }), ) } }), ) // Persist progress periodically so a run cancelled mid-sync keeps what it finished (see // maybeFlushBaseline); the interval debounce keeps this from writing on every batch. maybeFlushBaseline() } }