scratch-l10n 6.1.93 → 6.1.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,16 +2,30 @@
2
2
  * @file
3
3
  * Helper functions for syncing Freshdesk knowledge base articles with Transifex
4
4
  */
5
- import { promises as fsPromises, appendFileSync } from 'fs'
5
+ import { promises as fsPromises } from 'fs'
6
6
  import { mkdirp } from 'mkdirp'
7
- import FreshdeskApi, { FreshdeskArticleCreate, FreshdeskArticleStatus, FreshdeskFolder } from './freshdesk-api.mts'
7
+ import { messageOf } from './errors.mts'
8
+ import FreshdeskApi, {
9
+ FreshdeskArticleCreate,
10
+ FreshdeskArticleStatus,
11
+ FreshdeskFolder,
12
+ logAuthenticatedAgent,
13
+ } from './freshdesk-api.mts'
8
14
  import { TransifexStringKeyValueJson, TransifexStringsKeyValueJson, TransifexStrings } from './transifex-formats.mts'
9
15
  import { TransifexResourceObject } from './transifex-objects.mts'
10
16
  import { txPull, txResourcesObjects, txAvailableLanguages } from './transifex.mts'
17
+ import { emitWarning } from './warnings.mts'
11
18
 
12
19
  const FD = new FreshdeskApi('https://mitscratch.freshdesk.com', process.env.FRESHDESK_TOKEN ?? '')
13
20
  const TX_PROJECT = 'scratch-help'
14
21
 
22
+ /**
23
+ * Log which agent the configured Freshdesk token authenticates as, to aid in diagnosing permission
24
+ * problems. Never throws.
25
+ * @returns a promise that resolves once the agent has been logged
26
+ */
27
+ export const logFreshdeskAgent = (): Promise<void> => logAuthenticatedAgent(FD)
28
+
15
29
  const freshdeskLocale = (locale: string): string => {
16
30
  // map between Transifex locale and Freshdesk. Two letter codes are usually fine
17
31
  const localeMap: Record<string, string> = {
@@ -46,13 +60,6 @@ const parseIntOrThrow = (str: string, radix: number) => {
46
60
  return num
47
61
  }
48
62
 
49
- const emitWarning = (warning: string) => {
50
- console.warn(warning)
51
- if (process.env.WARNINGS_FILE) {
52
- appendFileSync(process.env.WARNINGS_FILE, warning + '\n')
53
- }
54
- }
55
-
56
63
  /**
57
64
  * Pull metadata from Transifex for the scratch-help project
58
65
  * @returns Promise for a results object containing:
@@ -118,27 +125,42 @@ const serializeNameSave = async (
118
125
  warnedKeys: Set<string>,
119
126
  ): Promise<void> => {
120
127
  for (const [key, value] of Object.entries(strings)) {
121
- // key is of the form <name>_<id>
122
- const words = key.split('_')
123
- const id = parseIntOrThrow(words[words.length - 1], 10)
124
- if (!validIds.has(id)) {
125
- const warnedKey = `${resource.attributes.name}:${id}`
126
- if (!warnedKeys.has(warnedKey)) {
127
- warnedKeys.add(warnedKey)
128
- emitWarning(
129
- `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.`,
130
- )
128
+ // Handle each entry independently: a failure on one item (for example a translation the token
129
+ // is not allowed to write) must not abort the remaining names for this locale.
130
+ try {
131
+ // key is of the form <name>_<id>
132
+ const words = key.split('_')
133
+ const id = parseIntOrThrow(words[words.length - 1], 10)
134
+ if (!validIds.has(id)) {
135
+ const warnedKey = `${resource.attributes.name}:${id}`
136
+ if (!warnedKeys.has(warnedKey)) {
137
+ warnedKeys.add(warnedKey)
138
+ emitWarning(
139
+ `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.`,
140
+ )
141
+ }
142
+ continue
131
143
  }
132
- continue
133
- }
134
- let status
135
- if (resource.attributes.name === 'categoryNames_json') {
136
- status = await FD.updateCategoryTranslation(id, freshdeskLocale(locale), { name: value })
137
- }
138
- if (resource.attributes.name === 'folderNames_json') {
139
- status = await FD.updateFolderTranslation(id, freshdeskLocale(locale), { name: value })
140
- }
141
- if (status === -1) {
144
+ let status
145
+ if (resource.attributes.name === 'categoryNames_json') {
146
+ status = await FD.updateCategoryTranslation(id, freshdeskLocale(locale), { name: value })
147
+ } else if (resource.attributes.name === 'folderNames_json') {
148
+ status = await FD.updateFolderTranslation(id, freshdeskLocale(locale), { name: value })
149
+ } else {
150
+ // Guard against silently dropping an unexpected resource: this function only knows how to
151
+ // write category and folder names. A new KEYVALUEJSON names resource would otherwise no-op
152
+ // while still reporting success.
153
+ throw new Error(`Unexpected names resource "${resource.attributes.name}"; don't know how to save it`)
154
+ }
155
+ if (status === -1) {
156
+ process.exitCode = 1
157
+ }
158
+ } catch (error) {
159
+ // The FreshdeskApi update method already logged the specific error. Record the failure so the
160
+ // job still exits non-zero, then move on to the next entry.
161
+ process.stdout.write(
162
+ `Failed to save an entry in ${resource.attributes.name} for ${locale} (key "${key}"): ${messageOf(error)}\n`,
163
+ )
142
164
  process.exitCode = 1
143
165
  }
144
166
  }
@@ -162,22 +184,30 @@ interface FreshdeskFolderInTransifex {
162
184
  */
163
185
  const serializeFolderSave = async (json: TransifexStrings<FreshdeskFolderInTransifex>, locale: string) => {
164
186
  for (const [idString, value] of Object.entries(json)) {
165
- const id = parseIntOrThrow(idString, 10)
166
- const body: FreshdeskArticleCreate = {
167
- title: value.title.string,
168
- description: value.description.string,
169
- status: FreshdeskArticleStatus.published,
170
- }
171
- if (Object.prototype.hasOwnProperty.call(value, 'tags')) {
172
- const tags = value.tags.string.split(',')
173
- const validTags = tags.filter(tag => tag.length < 33)
174
- if (validTags.length !== tags.length) {
175
- emitWarning(`Warning: tags too long in ${id} for ${locale}`)
187
+ // Handle each article independently: a failure on one must not abort the rest for this locale.
188
+ try {
189
+ const id = parseIntOrThrow(idString, 10)
190
+ const body: FreshdeskArticleCreate = {
191
+ title: value.title.string,
192
+ description: value.description.string,
193
+ status: FreshdeskArticleStatus.published,
176
194
  }
177
- body.tags = validTags
178
- }
179
- const status = await FD.updateArticleTranslation(id, freshdeskLocale(locale), body)
180
- if (status === -1) {
195
+ if (Object.prototype.hasOwnProperty.call(value, 'tags')) {
196
+ const tags = value.tags.string.split(',')
197
+ const validTags = tags.filter(tag => tag.length < 33)
198
+ if (validTags.length !== tags.length) {
199
+ emitWarning(`Warning: tags too long in ${id} for ${locale}`)
200
+ }
201
+ body.tags = validTags
202
+ }
203
+ const status = await FD.updateArticleTranslation(id, freshdeskLocale(locale), body)
204
+ if (status === -1) {
205
+ process.exitCode = 1
206
+ }
207
+ } catch (error) {
208
+ // The FreshdeskApi update method already logged the specific error. Record the failure so the
209
+ // job still exits non-zero, then move on to the next article.
210
+ process.stdout.write(`Failed to save article ${idString} for ${locale}: ${messageOf(error)}\n`)
181
211
  process.exitCode = 1
182
212
  }
183
213
  }
@@ -3,6 +3,7 @@
3
3
  * Utilities for interfacing with Transifex API 3.
4
4
  */
5
5
  import { transifexApi, Collection, JsonApiResource } from '@transifex/api'
6
+ import { messageOf } from './errors.mts'
6
7
  import { TransifexStrings } from './transifex-formats.mts'
7
8
  import { TransifexLanguageObject, TransifexResourceObject } from './transifex-objects.mts'
8
9
 
@@ -17,6 +18,98 @@ transifexApi.setup({
17
18
  auth: process.env.TX_TOKEN,
18
19
  })
19
20
 
21
+ /** Base delay for exponential backoff between transient-error retries, in milliseconds. */
22
+ const TX_RETRY_BASE_MS = 1_000
23
+ /** Maximum number of attempts for an operation that fails with a transient (retryable) error. */
24
+ const TX_MAX_TRANSIENT_RETRIES = 5
25
+ /** How often to poll an async upload for completion, in milliseconds. */
26
+ const TX_UPLOAD_POLL_INTERVAL_MS = 2_000
27
+ /**
28
+ * Overall budget for a single async upload to reach a terminal state, in milliseconds.
29
+ * This bounds the poll loop so a stuck upload fails fast (with a clear message) instead of
30
+ * polling forever — the workflow's `timeout-minutes` is only a last-resort backstop.
31
+ */
32
+ const TX_UPLOAD_TIMEOUT_MS = 5 * 60_000
33
+ /** Per-request timeout for downloading a resource from the CDN, in milliseconds. */
34
+ const TX_DOWNLOAD_TIMEOUT_MS = 60_000
35
+
36
+ const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
37
+
38
+ /**
39
+ * Decide whether an error is worth retrying: server-side 5xx, rate limiting (429), or a transient
40
+ * network failure. Client errors (4xx other than 429) are not retried — they won't fix themselves.
41
+ * @param err - the thrown error, from the Transifex SDK (`JsonApiException`), `fetch`, or the network stack
42
+ * @returns true if retrying the same operation might succeed
43
+ */
44
+ const isTransientError = (err: unknown): boolean => {
45
+ const e = (err ?? {}) as {
46
+ statusCode?: number
47
+ status?: number
48
+ code?: string
49
+ name?: string
50
+ cause?: { code?: string }
51
+ }
52
+ // A `fetch` aborted by `AbortSignal.timeout` rejects with a `TimeoutError` that carries no status
53
+ // or code; the only abort in this module is that timeout, so treat it (and a bare abort) as worth
54
+ // retrying.
55
+ if (e.name === 'TimeoutError' || e.name === 'AbortError') {
56
+ return true
57
+ }
58
+ const status = e.statusCode ?? e.status
59
+ if (typeof status === 'number') {
60
+ return status === 429 || (status >= 500 && status < 600)
61
+ }
62
+ const code = e.code ?? e.cause?.code
63
+ return (
64
+ code === 'ECONNRESET' ||
65
+ code === 'ECONNREFUSED' ||
66
+ code === 'ETIMEDOUT' ||
67
+ code === 'EAI_AGAIN' ||
68
+ code === 'UND_ERR_SOCKET' ||
69
+ code === 'UND_ERR_CONNECT_TIMEOUT'
70
+ )
71
+ }
72
+
73
+ /**
74
+ * Run an async operation, retrying with exponential backoff when it fails with a transient error.
75
+ * Non-transient errors (for example a 404) are re-thrown immediately so callers can handle them.
76
+ * @template T - the resolved type of the operation
77
+ * @param label - short description of the operation, used in retry log lines
78
+ * @param fn - the operation to run; called once per attempt
79
+ * @returns the resolved value of `fn`
80
+ */
81
+ const withRetry = async function <T>(label: string, fn: () => Promise<T>): Promise<T> {
82
+ let lastError: unknown
83
+ for (let attempt = 1; attempt <= TX_MAX_TRANSIENT_RETRIES; attempt++) {
84
+ try {
85
+ return await fn()
86
+ } catch (err) {
87
+ lastError = err
88
+ if (!isTransientError(err) || attempt === TX_MAX_TRANSIENT_RETRIES) {
89
+ throw err
90
+ }
91
+ const delay = TX_RETRY_BASE_MS * 2 ** (attempt - 1)
92
+ console.warn(
93
+ `${label}: transient error on attempt ${attempt}/${TX_MAX_TRANSIENT_RETRIES}, ` +
94
+ `retrying in ${delay}ms: ${messageOf(err)}`,
95
+ )
96
+ await sleep(delay)
97
+ }
98
+ }
99
+ // Unreachable: the loop either returns or throws, but TypeScript can't prove it.
100
+ throw lastError
101
+ }
102
+
103
+ /**
104
+ * The subset of an async-upload resource instance that we poll. The SDK's generated types model
105
+ * `reload` as requiring an `include` argument, but at runtime it is optional; this shape lets us
106
+ * call `reload()` with no arguments while staying type-checked.
107
+ */
108
+ interface AsyncUploadResource {
109
+ get(key: string): unknown
110
+ reload(): Promise<void>
111
+ }
112
+
20
113
  /*
21
114
  * The Transifex JS API wraps the Transifex JSON API, and is built around the concept of a `Collection`.
22
115
  * A `Collection` begins as a URL builder: methods like `filter` and `sort` add query parameters to the URL.
@@ -111,24 +204,47 @@ export const txPull = async function <T>(
111
204
  ): Promise<TransifexStrings<T>> {
112
205
  let buffer: string | null = null
113
206
  try {
114
- const url = await getResourceLocation(project, resource, locale, mode)
207
+ // Creating the download event itself polls Transifex until the file is ready; retry transient
208
+ // failures (5xx / network blips) so one bad response doesn't sink the whole pull.
209
+ const url = await withRetry(`txPull download event for ${resource}/${locale}`, () =>
210
+ getResourceLocation(project, resource, locale, mode),
211
+ )
212
+ let lastError: unknown
115
213
  for (let i = 0; i < 5; i++) {
116
214
  if (i > 0) {
117
- console.log(`Retrying txPull download after ${i} failed attempt(s)`)
215
+ const delay = TX_RETRY_BASE_MS * 2 ** (i - 1)
216
+ console.log(
217
+ `Retrying txPull download for ${resource}/${locale} after ${i} failed attempt(s); waiting ${delay}ms`,
218
+ )
219
+ await sleep(delay)
118
220
  }
119
221
  try {
120
- const response = await fetch(url)
222
+ const response = await fetch(url, { signal: AbortSignal.timeout(TX_DOWNLOAD_TIMEOUT_MS) })
121
223
  if (!response.ok) {
122
- throw new Error(`Failed to download resource: ${response.statusText}`)
224
+ const err = new Error(
225
+ `Failed to download resource: HTTP ${response.status} ${response.statusText}`,
226
+ ) as Error & { status: number }
227
+ err.status = response.status
228
+ throw err
123
229
  }
124
230
  buffer = await response.text()
125
231
  break
126
232
  } catch (e) {
127
- console.error(e, { project, resource, locale, buffer })
233
+ lastError = e
234
+ console.error(`txPull download attempt ${i + 1} failed for ${resource}/${locale}: ${messageOf(e)}`)
235
+ // Only 5xx / 429 / network / timeout failures are worth retrying. A non-transient failure
236
+ // (for example a 403 or 404) won't fix itself, so fail fast and surface the real cause
237
+ // instead of burning the remaining attempts.
238
+ if (!isTransientError(e)) {
239
+ throw e
240
+ }
128
241
  }
129
242
  }
130
- if (!buffer) {
131
- throw Error(`txPull download failed after 5 retries: ${url}`)
243
+ if (buffer === null) {
244
+ throw new Error(
245
+ `txPull download failed after 5 attempts for ${resource}/${locale} (${url}): ` +
246
+ `${(lastError as Error | undefined)?.message ?? 'unknown error'}`,
247
+ )
132
248
  }
133
249
  return JSON.parse(buffer) as TransifexStrings<T>
134
250
  } catch (e) {
@@ -205,10 +321,42 @@ export const txPush = async function (project: string, resource: string, sourceS
205
321
  },
206
322
  }
207
323
 
208
- await transifexApi.ResourceStringsAsyncUpload.upload({
209
- resource: resourceObj,
210
- content: JSON.stringify(sourceStrings),
211
- })
324
+ // `ResourceStringsAsyncUpload.upload()` creates the upload and then polls until its status is
325
+ // `succeeded`. That poll has no timeout and no exit for a `failed` status, so a rejected upload
326
+ // (or one stuck in `pending`) loops forever — historically until the CI job's 6-hour limit, and
327
+ // any transient 502 on a poll crashed the whole job with an unhelpful stack trace. We do the
328
+ // create-then-poll ourselves so we can bound it, retry transient blips, and surface the actual
329
+ // reason an upload failed.
330
+ const upload = (await withRetry(`txPush create upload for "${resource}"`, () =>
331
+ transifexApi.ResourceStringsAsyncUpload.create({
332
+ resource: resourceObj,
333
+ content: JSON.stringify(sourceStrings),
334
+ content_encoding: 'text',
335
+ // The generated type insists on id/attributes/relationships/links, but the upload resource
336
+ // takes this flatter shape — the same one `ResourceStringsAsyncUpload.upload()` passes through.
337
+ } as unknown as Parameters<typeof transifexApi.ResourceStringsAsyncUpload.create>[0]),
338
+ )) as unknown as AsyncUploadResource
339
+
340
+ const deadline = Date.now() + TX_UPLOAD_TIMEOUT_MS
341
+ for (;;) {
342
+ const status = upload.get('status') as string | undefined
343
+ if (status === 'succeeded') {
344
+ return
345
+ }
346
+ if (status === 'failed') {
347
+ // On failure the upload carries `errors` (and sometimes `details`) explaining why.
348
+ const errorInfo = upload.get('errors') ?? upload.get('details') ?? 'no error detail provided'
349
+ throw new Error(`Transifex upload failed for resource "${resource}": ${JSON.stringify(errorInfo)}`)
350
+ }
351
+ if (Date.now() >= deadline) {
352
+ throw new Error(
353
+ `Transifex upload for resource "${resource}" did not reach a terminal state within ` +
354
+ `${TX_UPLOAD_TIMEOUT_MS / 1000}s (last status: ${status ?? 'unknown'}).`,
355
+ )
356
+ }
357
+ await sleep(TX_UPLOAD_POLL_INTERVAL_MS)
358
+ await withRetry(`txPush poll upload for "${resource}"`, () => upload.reload())
359
+ }
212
360
  }
213
361
 
214
362
  /**
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @file
3
+ * Shared helper for reporting non-fatal problems during help sync.
4
+ */
5
+ import { appendFileSync } from 'fs'
6
+ import { messageOf } from './errors.mts'
7
+
8
+ /**
9
+ * Log a warning to the console and, when the `WARNINGS_FILE` environment variable is set, append it
10
+ * to that file. CI reads the file after the sync to surface warnings in the job summary and to send
11
+ * a notification, so a warning is the right tool for a problem worth a human's attention that should
12
+ * not fail the run (for example, a resource we deliberately skip).
13
+ * @param warning - the warning message; a trailing newline is added when written to the file
14
+ */
15
+ export const emitWarning = (warning: string): void => {
16
+ console.warn(warning)
17
+ if (process.env.WARNINGS_FILE) {
18
+ // The file write is best-effort: emitWarning is the non-fatal path (often called from a catch
19
+ // block), so a failed append must not turn a warning into a crash.
20
+ try {
21
+ appendFileSync(process.env.WARNINGS_FILE, warning + '\n')
22
+ } catch (error) {
23
+ console.warn(`Could not append to WARNINGS_FILE "${process.env.WARNINGS_FILE}": ${messageOf(error)}`)
24
+ }
25
+ }
26
+ }
@@ -3,7 +3,7 @@
3
3
  * @file
4
4
  * Script to pull scratch-help translations from transifex and push to FreshDesk.
5
5
  */
6
- import { getInputs, getValidFreshdeskIds, saveItem, localizeNames } from './lib/help-utils.mts'
6
+ import { getInputs, getValidFreshdeskIds, saveItem, localizeNames, logFreshdeskAgent } from './lib/help-utils.mts'
7
7
 
8
8
  const args = process.argv.slice(2)
9
9
 
@@ -22,6 +22,8 @@ if (!process.env.TX_TOKEN || !process.env.FRESHDESK_TOKEN || args.length > 0) {
22
22
  process.exit(1)
23
23
  }
24
24
 
25
+ await logFreshdeskAgent()
26
+
25
27
  const [{ languages, names }, { validCategoryIds, validFolderIds }] = await Promise.all([
26
28
  getInputs(),
27
29
  getValidFreshdeskIds(),
@@ -3,9 +3,15 @@
3
3
  * @file
4
4
  * Script get Knowledge base articles from Freshdesk and push them to transifex.
5
5
  */
6
- import FreshdeskApi, { FreshdeskArticleStatus, FreshdeskCategory, FreshdeskFolder } from './lib/freshdesk-api.mts'
6
+ import FreshdeskApi, {
7
+ FreshdeskArticleStatus,
8
+ FreshdeskCategory,
9
+ FreshdeskFolder,
10
+ logAuthenticatedAgent,
11
+ } from './lib/freshdesk-api.mts'
7
12
  import { TransifexStringsKeyValueJson, TransifexStringsStructuredJson } from './lib/transifex-formats.mts'
8
13
  import { txPush, txCreateResource, JsonApiException } from './lib/transifex.mts'
14
+ import { emitWarning } from './lib/warnings.mts'
9
15
 
10
16
  const args = process.argv.slice(2)
11
17
 
@@ -30,6 +36,17 @@ const TX_PROJECT = 'scratch-help'
30
36
  const categoryNames: TransifexStringsKeyValueJson = {}
31
37
  const folderNames: TransifexStringsKeyValueJson = {}
32
38
 
39
+ // Collect per-resource failures instead of aborting on the first one, so a single failing folder or
40
+ // category (for example one the token cannot access) still lets every other resource sync. The
41
+ // script fails at the end if anything meaningful failed, so a persistent problem is surfaced for
42
+ // investigation rather than silently dropped.
43
+ const failures: { context: string; message: string }[] = []
44
+ const recordFailure = (context: string, error: unknown) => {
45
+ const message = error instanceof Error ? error.message : String(error)
46
+ console.error(`Error: ${context}: ${message}`)
47
+ failures.push({ context, message })
48
+ }
49
+
33
50
  /**
34
51
  * Generate a transifex resource slug from the name and ID of a Freshdesk object.
35
52
  * Strips characters not allowed in Transifex slugs (only `[a-zA-Z0-9_-]` are permitted).
@@ -50,12 +67,22 @@ const txPushResource = async (
50
67
  articles: TransifexStringsStructuredJson | TransifexStringsKeyValueJson,
51
68
  type: string,
52
69
  ) => {
70
+ // Transifex rejects an upload with no extractable strings (`parse_error: No strings could be
71
+ // extracted`). That used to leave the upload stuck in a non-`succeeded` state forever, hanging
72
+ // the whole sync. An empty resource almost always means a Freshdesk folder with no published
73
+ // articles, which is a content situation rather than a sync failure: warn and skip it.
74
+ if (Object.keys(articles).length === 0) {
75
+ emitWarning(`Skipping Transifex resource "${name}": no strings to push (empty content).`)
76
+ return
77
+ }
78
+
53
79
  const resourceData = {
54
80
  slug: name,
55
81
  name: name,
56
82
  i18nType: type,
57
- priority: 0, // default to normal priority
58
- content: articles,
83
+ // `txCreateResource` reads `sourceStrings` (not `content`); passing the wrong key left a newly
84
+ // created resource empty until a later run populated it.
85
+ sourceStrings: articles,
59
86
  }
60
87
 
61
88
  try {
@@ -77,9 +104,18 @@ const txPushResource = async (
77
104
  * @param categories - array of categories the folders belong to
78
105
  * @returns flattened list of folders from all requested categories
79
106
  */
80
- const getFolders = async (categories: FreshdeskCategory[]) => {
81
- const categoryFolders = await Promise.all(categories.map(category => FD.listFolders(category)))
82
- return ([] as FreshdeskCategory[]).concat(...categoryFolders)
107
+ const getFolders = async (categories: FreshdeskCategory[]): Promise<FreshdeskFolder[]> => {
108
+ const categoryFolders = await Promise.all(
109
+ categories.map(async (category): Promise<FreshdeskFolder[]> => {
110
+ try {
111
+ return await FD.listFolders(category)
112
+ } catch (error) {
113
+ recordFailure(`list folders for category "${category.name}" (id ${category.id})`, error)
114
+ return []
115
+ }
116
+ }),
117
+ )
118
+ return categoryFolders.flat()
83
119
  }
84
120
 
85
121
  /**
@@ -87,7 +123,8 @@ const getFolders = async (categories: FreshdeskCategory[]) => {
87
123
  * @param folder - The folder object
88
124
  */
89
125
  const saveArticles = async (folder: FreshdeskFolder) => {
90
- await FD.listArticles(folder).then(async json => {
126
+ try {
127
+ const json = await FD.listArticles(folder)
91
128
  const txArticles = json.reduce((strings: TransifexStringsStructuredJson, current) => {
92
129
  if (current.status === FreshdeskArticleStatus.published) {
93
130
  strings[String(current.id)] = {
@@ -106,13 +143,15 @@ const saveArticles = async (folder: FreshdeskFolder) => {
106
143
  }, {})
107
144
  process.stdout.write(`Push ${folder.name} articles to Transifex\n`)
108
145
  await txPushResource(`${makeTxSlug(folder)}_json`, txArticles, 'STRUCTURED_JSON')
109
- })
146
+ } catch (error) {
147
+ recordFailure(`folder "${folder.name}" (id ${folder.id})`, error)
148
+ }
110
149
  }
111
150
 
112
151
  /**
113
152
  * @param folders - Array of folders containing articles to be saved
114
153
  */
115
- const saveArticleFolders = async (folders: FreshdeskCategory[]) => {
154
+ const saveArticleFolders = async (folders: FreshdeskFolder[]) => {
116
155
  await Promise.all(folders.map(folder => saveArticles(folder)))
117
156
  }
118
157
 
@@ -133,12 +172,25 @@ const syncSources = async () => {
133
172
  })
134
173
  process.stdout.write('Push category and folder names to Transifex\n')
135
174
  await Promise.all([
136
- txPushResource('categoryNames_json', categoryNames, 'KEYVALUEJSON'),
137
- txPushResource('folderNames_json', folderNames, 'KEYVALUEJSON'),
175
+ txPushResource('categoryNames_json', categoryNames, 'KEYVALUEJSON').catch(error =>
176
+ recordFailure('categoryNames_json', error),
177
+ ),
178
+ txPushResource('folderNames_json', folderNames, 'KEYVALUEJSON').catch(error =>
179
+ recordFailure('folderNames_json', error),
180
+ ),
138
181
  ])
139
182
  return data
140
183
  })
141
184
  .then(saveArticleFolders)
142
185
  }
143
186
 
187
+ await logAuthenticatedAgent(FD)
144
188
  await syncSources()
189
+
190
+ if (failures.length > 0) {
191
+ console.error(`\n${failures.length} resource(s) failed to push to Transifex:`)
192
+ for (const failure of failures) {
193
+ console.error(` - ${failure.context}: ${failure.message}`)
194
+ }
195
+ process.exitCode = 1
196
+ }
@@ -49,7 +49,7 @@
49
49
  "general.loadMore": "Բեռնել ավելին",
50
50
  "general.learnMore": "Սովորել ավելին",
51
51
  "general.male": "Արական",
52
- "general.membership": "Membership",
52
+ "general.membership": "Անդամակցություն",
53
53
  "general.messages": "Հաղորդագրություն",
54
54
  "general.mitAccessibility": "MIT Accessibility",
55
55
  "general.month": "Ամիս",