scratch-l10n 6.1.99 → 6.1.100

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scratch-l10n",
3
- "version": "6.1.99",
3
+ "version": "6.1.100",
4
4
  "description": "Localization for the Scratch 3.0 components",
5
5
  "main": "./dist/l10n.js",
6
6
  "browser": "./src/index.mjs",
@@ -76,13 +76,11 @@
76
76
  "eslint": "9.39.5",
77
77
  "eslint-config-scratch": "11.0.53",
78
78
  "format-message-cli": "6.2.4",
79
- "globals": "17.7.0",
79
+ "globals": "17.8.0",
80
80
  "husky": "8.0.3",
81
81
  "jshint": "2.13.6",
82
82
  "json": "9.0.6",
83
83
  "jsonlint": "1.6.3",
84
- "p-limit": "2.3.0",
85
- "p-queue": "3.2.0",
86
84
  "prettier": "3.9.6",
87
85
  "rimraf": "2.7.1",
88
86
  "scratch-semantic-release-config": "4.0.1",
@@ -1,5 +1,6 @@
1
1
  // interface to FreshDesk Solutions (knowledge base) api
2
2
  import { messageOf } from './errors.mts'
3
+ import { emitWarning } from './warnings.mts'
3
4
 
4
5
  /**
5
6
  * Extend
@@ -164,6 +165,68 @@ export interface FreshdeskAgent {
164
165
  }
165
166
  }
166
167
 
168
+ const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
169
+
170
+ /** Interval used before the server has told us its limit, in ms (conservative, avoids an opening burst). */
171
+ const FD_START_INTERVAL_MS = 300
172
+ /** Never pace faster than this, even if the advertised limit would allow it, in milliseconds. */
173
+ const FD_MIN_REQUEST_INTERVAL_MS = 100
174
+ /** Ceiling on the interval the backoff will grow to, in milliseconds. */
175
+ const FD_MAX_REQUEST_INTERVAL_MS = 10_000
176
+ /** Fraction of the advertised per-minute budget we aim to use, leaving headroom for bursts and other clients. */
177
+ const FD_RATE_SAFETY_FACTOR = 0.75
178
+ /** When the advertised remaining budget drops to this fraction of the total, pause to let it refill. */
179
+ const FD_LOW_REMAINING_FRACTION = 0.1
180
+ /** How long to hold the schedule when the remaining budget runs low, in milliseconds. */
181
+ const FD_LOW_REMAINING_PAUSE_MS = 3_000
182
+ /** Extra multiplier applied to the interval on a 429, a safety net for when the headers under-report. */
183
+ const FD_RATE_LIMIT_BACKOFF_FACTOR = 1.5
184
+ /** Maximum attempts (the initial request plus retries) for a single request that keeps getting 429ed. */
185
+ const FD_MAX_RATE_LIMIT_ATTEMPTS = 5
186
+ /** Fallback wait when a 429 arrives without a usable `Retry-After` header, in milliseconds. */
187
+ const FD_DEFAULT_RETRY_AFTER_MS = 30_000
188
+ /**
189
+ * Cap on how long we will actually wait for a single `Retry-After`, in milliseconds. Freshdesk can
190
+ * return a punitive multi-minute delay once it decides a client is abusing the API; honoring that
191
+ * verbatim would hang the job past its timeout. We cap the wait so the run fails fast and reports
192
+ * instead. Correct pacing should keep us from ever earning such a penalty in the first place.
193
+ */
194
+ const FD_MAX_RETRY_AFTER_MS = 120_000
195
+
196
+ /**
197
+ * Parse a `Retry-After` header into milliseconds. Freshdesk sends a delta in seconds, but the HTTP
198
+ * spec also allows an HTTP-date, so both forms are handled.
199
+ * @param value - the raw header value, or null if absent
200
+ * @returns the delay in milliseconds, or null if the header was missing or unparseable
201
+ */
202
+ const parseRetryAfterMs = (value: string | null): number | null => {
203
+ if (!value) {
204
+ return null
205
+ }
206
+ const seconds = Number(value)
207
+ if (Number.isFinite(seconds)) {
208
+ // A negative delay is malformed; fall back to the default (null) rather than retrying immediately.
209
+ return seconds < 0 ? null : seconds * 1000
210
+ }
211
+ const dateMs = Date.parse(value)
212
+ return Number.isNaN(dateMs) ? null : Math.max(0, dateMs - Date.now())
213
+ }
214
+
215
+ /**
216
+ * Parse a non-negative integer rate-limit header (for example `X-RateLimit-Total`).
217
+ * @param value - the raw header value, or null if absent
218
+ * @returns the parsed value, or null if the header was missing or not a finite number
219
+ */
220
+ const parseIntHeader = (value: string | null): number | null => {
221
+ if (!value) {
222
+ return null
223
+ }
224
+ const n = Number(value)
225
+ // Reject anything that isn't a whole, non-negative count: this value drives request pacing, so a
226
+ // stray decimal or negative from a malformed header should fall back to the defaults, not skew it.
227
+ return Number.isInteger(n) && n >= 0 ? n : null
228
+ }
229
+
167
230
  /**
168
231
  * Wrapper for Freshdesk's REST API
169
232
  */
@@ -171,7 +234,25 @@ export class FreshdeskApi {
171
234
  baseUrl: string
172
235
  private _auth: string
173
236
  defaultHeaders: { 'Content-Type': string; Authorization: string }
174
- rateLimited: boolean
237
+ /**
238
+ * Current minimum spacing between request starts, in milliseconds, shared across every concurrent
239
+ * caller. Starts conservative and is set from the server's advertised rate-limit headers once they
240
+ * are seen (see {@link applyRateHeaders}); a 429 nudges it up as a safety net. This is what keeps
241
+ * the fan-out over folders and locales under the limit.
242
+ */
243
+ private requestIntervalMs = FD_START_INTERVAL_MS
244
+ /** Serializes request starts: each `gate()` call chains onto this so only one is timed at a time. */
245
+ private queue: Promise<void> = Promise.resolve()
246
+ /** Timestamp (ms) when the previous request actually started, used to space the next one. */
247
+ private lastRequestStart = 0
248
+ /** Timestamp (ms) before which no request may start; set by a 429 or a low remaining-budget signal. */
249
+ private pauseUntil = 0
250
+ /** Whether the server's advertised limit has been logged yet (logged once, to aid diagnosis). */
251
+ private limitLogged = false
252
+ /** Count of 429 responses seen this run; a nonzero value means the limiter had to back off. */
253
+ rateLimitHits = 0
254
+ /** Whether the one-time "rate limiting activated" warning has been emitted this run. */
255
+ private rateLimitReported = false
175
256
 
176
257
  constructor(baseUrl: string, apiKey: string) {
177
258
  this.baseUrl = baseUrl
@@ -180,7 +261,114 @@ export class FreshdeskApi {
180
261
  'Content-Type': 'application/json',
181
262
  Authorization: this._auth,
182
263
  }
183
- this.rateLimited = false
264
+ }
265
+
266
+ /**
267
+ * Wait for this request's turn. Requests are serialized on a single queue and each starts at least
268
+ * `requestIntervalMs` after the previous one (and never before {@link pauseUntil}). The wait is
269
+ * computed when the request reaches the front of the queue — not reserved up front — so an interval
270
+ * change from a fresh rate-limit header takes effect immediately. That is what keeps the fan-out
271
+ * over folders and locales under the limit without a burst locking in a too-fast schedule.
272
+ * @returns a promise that resolves when it is this request's turn to start
273
+ */
274
+ private async gate(): Promise<void> {
275
+ const run = this.queue.then(async () => {
276
+ const target = Math.max(this.lastRequestStart + this.requestIntervalMs, this.pauseUntil)
277
+ const wait = target - Date.now()
278
+ if (wait > 0) {
279
+ await sleep(wait)
280
+ }
281
+ this.lastRequestStart = Date.now()
282
+ })
283
+ // Keep the chain alive whether or not the timing step rejects (it shouldn't, but be safe).
284
+ this.queue = run.catch(() => undefined)
285
+ return run
286
+ }
287
+
288
+ /**
289
+ * Steer pacing from Freshdesk's advertised rate-limit headers so we throttle *before* hitting the
290
+ * wall rather than only reacting to 429s. Freshdesk returns `X-RateLimit-Total` (the per-minute
291
+ * budget) and `X-RateLimit-Remaining` on every response: we pace to a safe fraction of the budget
292
+ * and pause as the remaining budget runs low. When the headers are absent the interval is left
293
+ * alone and we fall back to the 429 backoff.
294
+ * @param headers - the response headers to read the advertised limits from
295
+ */
296
+ private applyRateHeaders(headers: Headers): void {
297
+ const total = parseIntHeader(headers.get('X-RateLimit-Total'))
298
+ if (total === null || total <= 0) {
299
+ return
300
+ }
301
+ // Aim for a fraction of the budget: interval = one minute / (budget * safety).
302
+ const target = 60_000 / (total * FD_RATE_SAFETY_FACTOR)
303
+ this.requestIntervalMs = Math.min(Math.max(target, FD_MIN_REQUEST_INTERVAL_MS), FD_MAX_REQUEST_INTERVAL_MS)
304
+ if (!this.limitLogged) {
305
+ this.limitLogged = true
306
+ console.log(
307
+ `Freshdesk advertised rate limit: ${total}/min; pacing requests ` +
308
+ `~${Math.round(this.requestIntervalMs)}ms apart.`,
309
+ )
310
+ }
311
+ const remaining = parseIntHeader(headers.get('X-RateLimit-Remaining'))
312
+ if (remaining !== null && remaining <= total * FD_LOW_REMAINING_FRACTION) {
313
+ // Budget nearly exhausted: hold the shared schedule briefly so the window can refill before we
314
+ // spend the last of it and trip a 429.
315
+ this.pauseUntil = Math.max(this.pauseUntil, Date.now() + FD_LOW_REMAINING_PAUSE_MS)
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Perform a fetch through the rate limiter: paced against the shared schedule, and transparently
321
+ * retried on 429 while honoring `Retry-After`. Any non-429 response (success or a real error) is
322
+ * returned as-is for the caller's {@link checkStatus} to interpret; a 429 that survives every
323
+ * retry is likewise returned so it surfaces as a genuine, reported failure rather than a silent
324
+ * skip. Only rate limiting is handled here — other transient failures keep their existing behavior.
325
+ * @param url - absolute endpoint to call
326
+ * @param init - method, body, and headers for the fetch
327
+ * @returns the final response
328
+ */
329
+ private async request(url: string, init: RequestInit): Promise<Response> {
330
+ for (let attempt = 1; ; attempt++) {
331
+ await this.gate()
332
+ const res = await fetch(url, init)
333
+ // Learn the server's limit from every response (success or 429) and pace to it.
334
+ this.applyRateHeaders(res.headers)
335
+ if (res.status !== 429) {
336
+ return res
337
+ }
338
+ this.rateLimitHits++
339
+ // Surface the first back-off through the shared warnings channel (job summary + Slack), once
340
+ // per run, so a job that recovers and stays green still flags that it is brushing the limit.
341
+ if (!this.rateLimitReported) {
342
+ this.rateLimitReported = true
343
+ emitWarning(
344
+ 'Freshdesk rate limiting was encountered during the help sync. Requests are being paced ' +
345
+ 'and retried (honoring Retry-After), so the run should still complete; if this recurs, ' +
346
+ 'consider reducing request volume or raising the Freshdesk rate limit.',
347
+ )
348
+ }
349
+ const requested = parseRetryAfterMs(res.headers.get('Retry-After')) ?? FD_DEFAULT_RETRY_AFTER_MS
350
+ // Cap the honored wait so a punitive multi-minute Retry-After can't hang the job; give up
351
+ // rather than sleep past the timeout. Return the 429 with its body still intact so
352
+ // checkStatus can read and surface Freshdesk's actual reason for the failure.
353
+ if (requested > FD_MAX_RETRY_AFTER_MS || attempt >= FD_MAX_RATE_LIMIT_ATTEMPTS) {
354
+ return res
355
+ }
356
+ // We are going to retry and abandon this response, so drain its body now to free the socket
357
+ // before we wait. (The give-up paths above deliberately leave it unread for checkStatus.)
358
+ await res.text().catch(() => undefined)
359
+ // Safety net on top of the header-driven pacing: widen the interval and hold the shared
360
+ // schedule until the retry window passes, so every request backs off together rather than only
361
+ // the one that was rejected.
362
+ this.requestIntervalMs = Math.min(
363
+ this.requestIntervalMs * FD_RATE_LIMIT_BACKOFF_FACTOR,
364
+ FD_MAX_REQUEST_INTERVAL_MS,
365
+ )
366
+ this.pauseUntil = Math.max(this.pauseUntil, Date.now() + requested)
367
+ console.warn(
368
+ `Freshdesk rate limited (429); waiting ~${Math.round(requested)}ms then retrying ` +
369
+ `(attempt ${attempt}/${FD_MAX_RATE_LIMIT_ATTEMPTS}, pace now ${Math.round(this.requestIntervalMs)}ms).`,
370
+ )
371
+ }
184
372
  }
185
373
 
186
374
  /**
@@ -217,13 +405,13 @@ export class FreshdeskApi {
217
405
  }
218
406
 
219
407
  async listCategories(): Promise<FreshdeskCategory[]> {
220
- const res = await fetch(`${this.baseUrl}/api/v2/solutions/categories`, { headers: this.defaultHeaders })
408
+ const res = await this.request(`${this.baseUrl}/api/v2/solutions/categories`, { headers: this.defaultHeaders })
221
409
  await this.checkStatus(res)
222
410
  return (await res.json()) as FreshdeskCategory[]
223
411
  }
224
412
 
225
413
  async listFolders(category: FreshdeskCategory): Promise<FreshdeskFolder[]> {
226
- const res = await fetch(`${this.baseUrl}/api/v2/solutions/categories/${category.id}/folders`, {
414
+ const res = await this.request(`${this.baseUrl}/api/v2/solutions/categories/${category.id}/folders`, {
227
415
  headers: this.defaultHeaders,
228
416
  })
229
417
  await this.checkStatus(res)
@@ -231,7 +419,7 @@ export class FreshdeskApi {
231
419
  }
232
420
 
233
421
  async listArticles(folder: FreshdeskFolder) {
234
- const res = await fetch(`${this.baseUrl}/api/v2/solutions/folders/${folder.id}/articles`, {
422
+ const res = await this.request(`${this.baseUrl}/api/v2/solutions/folders/${folder.id}/articles`, {
235
423
  headers: this.defaultHeaders,
236
424
  })
237
425
  await this.checkStatus(res)
@@ -245,7 +433,7 @@ export class FreshdeskApi {
245
433
  * @returns the agent's id and contact details
246
434
  */
247
435
  async getAuthenticatedAgent(): Promise<FreshdeskAgent> {
248
- const res = await fetch(`${this.baseUrl}/api/v2/agents/me`, { headers: this.defaultHeaders })
436
+ const res = await this.request(`${this.baseUrl}/api/v2/agents/me`, { headers: this.defaultHeaders })
249
437
  await this.checkStatus(res)
250
438
  return (await res.json()) as FreshdeskAgent
251
439
  }
@@ -254,13 +442,9 @@ export class FreshdeskApi {
254
442
  id: FreshdeskCategory['id'],
255
443
  locale: string,
256
444
  body: FreshdeskCategoryCreate,
257
- ): Promise<FreshdeskCategory | -1> {
258
- if (this.rateLimited) {
259
- process.stdout.write(`Rate limited, skipping id: ${id} for ${locale}\n`)
260
- return -1
261
- }
445
+ ): Promise<FreshdeskCategory> {
262
446
  try {
263
- const res = await fetch(`${this.baseUrl}/api/v2/solutions/categories/${id}/${locale}`, {
447
+ const res = await this.request(`${this.baseUrl}/api/v2/solutions/categories/${id}/${locale}`, {
264
448
  method: 'put',
265
449
  body: JSON.stringify(body),
266
450
  headers: this.defaultHeaders,
@@ -271,7 +455,7 @@ export class FreshdeskApi {
271
455
  const httpError = err as HttpError
272
456
  if (httpError.code === 404) {
273
457
  // not found, try create instead
274
- const res2 = await fetch(`${this.baseUrl}/api/v2/solutions/categories/${id}/${locale}`, {
458
+ const res2 = await this.request(`${this.baseUrl}/api/v2/solutions/categories/${id}/${locale}`, {
275
459
  method: 'post',
276
460
  body: JSON.stringify(body),
277
461
  headers: this.defaultHeaders,
@@ -279,9 +463,6 @@ export class FreshdeskApi {
279
463
  await this.checkStatus(res2)
280
464
  return (await res2.json()) as FreshdeskCategory
281
465
  }
282
- if (httpError.code === 429) {
283
- this.rateLimited = true
284
- }
285
466
  process.stdout.write(`Error processing id ${id} for locale ${locale}: ${httpError.message}\n`)
286
467
  throw err
287
468
  }
@@ -291,13 +472,9 @@ export class FreshdeskApi {
291
472
  id: FreshdeskFolder['id'],
292
473
  locale: string,
293
474
  body: FreshdeskFolderCreate,
294
- ): Promise<FreshdeskFolder | -1> {
295
- if (this.rateLimited) {
296
- process.stdout.write(`Rate limited, skipping id: ${id} for ${locale}\n`)
297
- return -1
298
- }
475
+ ): Promise<FreshdeskFolder> {
299
476
  try {
300
- const res = await fetch(`${this.baseUrl}/api/v2/solutions/folders/${id}/${locale}`, {
477
+ const res = await this.request(`${this.baseUrl}/api/v2/solutions/folders/${id}/${locale}`, {
301
478
  method: 'put',
302
479
  body: JSON.stringify(body),
303
480
  headers: this.defaultHeaders,
@@ -308,7 +485,7 @@ export class FreshdeskApi {
308
485
  const httpError = err as HttpError
309
486
  if (httpError.code === 404) {
310
487
  // not found, try create instead
311
- const res2 = await fetch(`${this.baseUrl}/api/v2/solutions/folders/${id}/${locale}`, {
488
+ const res2 = await this.request(`${this.baseUrl}/api/v2/solutions/folders/${id}/${locale}`, {
312
489
  method: 'post',
313
490
  body: JSON.stringify(body),
314
491
  headers: this.defaultHeaders,
@@ -316,9 +493,6 @@ export class FreshdeskApi {
316
493
  await this.checkStatus(res2)
317
494
  return (await res2.json()) as FreshdeskFolder
318
495
  }
319
- if (httpError.code === 429) {
320
- this.rateLimited = true
321
- }
322
496
  process.stdout.write(`Error processing id ${id} for locale ${locale}: ${httpError.message}\n`)
323
497
  throw err
324
498
  }
@@ -328,13 +502,9 @@ export class FreshdeskApi {
328
502
  id: FreshdeskArticle['id'],
329
503
  locale: string,
330
504
  body: FreshdeskArticleCreate,
331
- ): Promise<FreshdeskArticle | -1> {
332
- if (this.rateLimited) {
333
- process.stdout.write(`Rate limited, skipping id: ${id} for ${locale}\n`)
334
- return -1
335
- }
505
+ ): Promise<FreshdeskArticle> {
336
506
  try {
337
- const res = await fetch(`${this.baseUrl}/api/v2/solutions/articles/${id}/${locale}`, {
507
+ const res = await this.request(`${this.baseUrl}/api/v2/solutions/articles/${id}/${locale}`, {
338
508
  method: 'put',
339
509
  body: JSON.stringify(body),
340
510
  headers: this.defaultHeaders,
@@ -345,7 +515,7 @@ export class FreshdeskApi {
345
515
  const httpError = err as HttpError
346
516
  if (httpError.code === 404) {
347
517
  // not found, try create instead
348
- const res2 = await fetch(`${this.baseUrl}/api/v2/solutions/articles/${id}/${locale}`, {
518
+ const res2 = await this.request(`${this.baseUrl}/api/v2/solutions/articles/${id}/${locale}`, {
349
519
  method: 'post',
350
520
  body: JSON.stringify(body),
351
521
  headers: this.defaultHeaders,
@@ -353,9 +523,6 @@ export class FreshdeskApi {
353
523
  await this.checkStatus(res2)
354
524
  return (await res2.json()) as FreshdeskArticle
355
525
  }
356
- if (httpError.code === 429) {
357
- this.rateLimited = true
358
- }
359
526
  process.stdout.write(`Error processing id ${id} for locale ${locale}: ${httpError.message}\n`)
360
527
  throw err
361
528
  }
@@ -19,6 +19,34 @@ import { emitWarning } from './warnings.mts'
19
19
  const FD = new FreshdeskApi('https://mitscratch.freshdesk.com', process.env.FRESHDESK_TOKEN ?? '')
20
20
  const TX_PROJECT = 'scratch-help'
21
21
 
22
+ // Collect per-item failures instead of aborting on the first one, so one bad translation or a single
23
+ // item the token cannot write still lets every other item sync. Freshdesk rate limiting is handled
24
+ // transparently by the client (paced requests, `Retry-After`-aware retries), so anything that reaches
25
+ // here is a genuine problem worth a human's attention. `reportFailures` prints a consolidated summary
26
+ // and fails the run at the end, so real errors are surfaced rather than lost in the log.
27
+ const failures: { context: string; message: string }[] = []
28
+ const recordFailure = (context: string, error: unknown): void => {
29
+ const message = messageOf(error)
30
+ // Genuine failures go to stderr (like the consolidated summary), so they stand apart from normal output.
31
+ process.stderr.write(`Error: ${context}: ${message}\n`)
32
+ failures.push({ context, message })
33
+ }
34
+
35
+ /**
36
+ * Print a summary of everything that failed during the sync and mark the process as failed if there
37
+ * was anything. Call once, after all saves have completed.
38
+ */
39
+ export const reportFailures = (): void => {
40
+ if (failures.length === 0) {
41
+ return
42
+ }
43
+ console.error(`\n${failures.length} item(s) failed to sync to Freshdesk:`)
44
+ for (const failure of failures) {
45
+ console.error(` - ${failure.context}: ${failure.message}`)
46
+ }
47
+ process.exitCode = 1
48
+ }
49
+
22
50
  /**
23
51
  * Log which agent the configured Freshdesk token authenticates as, to aid in diagnosing permission
24
52
  * problems. Never throws.
@@ -141,27 +169,20 @@ const serializeNameSave = async (
141
169
  }
142
170
  continue
143
171
  }
144
- let status
145
172
  if (resource.attributes.name === 'categoryNames_json') {
146
- status = await FD.updateCategoryTranslation(id, freshdeskLocale(locale), { name: value })
173
+ await FD.updateCategoryTranslation(id, freshdeskLocale(locale), { name: value })
147
174
  } else if (resource.attributes.name === 'folderNames_json') {
148
- status = await FD.updateFolderTranslation(id, freshdeskLocale(locale), { name: value })
175
+ await FD.updateFolderTranslation(id, freshdeskLocale(locale), { name: value })
149
176
  } else {
150
177
  // Guard against silently dropping an unexpected resource: this function only knows how to
151
178
  // write category and folder names. A new KEYVALUEJSON names resource would otherwise no-op
152
179
  // while still reporting success.
153
180
  throw new Error(`Unexpected names resource "${resource.attributes.name}"; don't know how to save it`)
154
181
  }
155
- if (status === -1) {
156
- process.exitCode = 1
157
- }
158
182
  } 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
- )
164
- process.exitCode = 1
183
+ // Record the failure so the job still reports a non-zero exit at the end, then move on to the
184
+ // next entry.
185
+ recordFailure(`${resource.attributes.name} entry "${key}" for ${locale}`, error)
165
186
  }
166
187
  }
167
188
  }
@@ -194,21 +215,24 @@ const serializeFolderSave = async (json: TransifexStrings<FreshdeskFolderInTrans
194
215
  }
195
216
  if (Object.prototype.hasOwnProperty.call(value, 'tags')) {
196
217
  const tags = value.tags.string.split(',')
218
+ // Freshdesk rejects tags longer than 32 characters, so drop them rather than fail the write.
197
219
  const validTags = tags.filter(tag => tag.length < 33)
198
- if (validTags.length !== tags.length) {
199
- emitWarning(`Warning: tags too long in ${id} for ${locale}`)
220
+ const droppedTags = tags.filter(tag => tag.length >= 33)
221
+ if (droppedTags.length > 0) {
222
+ emitWarning(
223
+ `Dropping ${droppedTags.length} tag(s) over Freshdesk's 32-character limit for article ${id} ` +
224
+ `(${locale}); shorten them in Transifex: ${droppedTags
225
+ .map(tag => `"${tag}" (${tag.length} chars)`)
226
+ .join(', ')}.`,
227
+ )
200
228
  }
201
229
  body.tags = validTags
202
230
  }
203
- const status = await FD.updateArticleTranslation(id, freshdeskLocale(locale), body)
204
- if (status === -1) {
205
- process.exitCode = 1
206
- }
231
+ await FD.updateArticleTranslation(id, freshdeskLocale(locale), body)
207
232
  } 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`)
211
- process.exitCode = 1
233
+ // Record the failure so the job still reports a non-zero exit at the end, then move on to the
234
+ // next article.
235
+ recordFailure(`article ${idString} for ${locale}`, error)
212
236
  }
213
237
  }
214
238
  }
@@ -228,8 +252,7 @@ export const localizeFolder = async (folderAttributes: TransifexResourceObject,
228
252
  )
229
253
  await serializeFolderSave(data, locale)
230
254
  } catch (e) {
231
- process.stdout.write(`Error processing ${folderAttributes.attributes.slug}, ${locale}: ${(e as Error).message}\n`)
232
- process.exitCode = 1 // not ok
255
+ recordFailure(`${folderAttributes.attributes.slug} for ${locale}`, e)
233
256
  }
234
257
  }
235
258
 
@@ -274,10 +297,7 @@ export const localizeNames = async (
274
297
  const validIds = resource.attributes.name === 'categoryNames_json' ? validCategoryIds : validFolderIds
275
298
  await txPull<TransifexStringKeyValueJson>(TX_PROJECT, resource.attributes.slug, locale, 'default')
276
299
  .then(data => serializeNameSave(data, resource, locale, validIds, warnedKeys))
277
- .catch(e => {
278
- process.stdout.write(`Error saving ${resource.attributes.slug}, ${locale}: ${(e as Error).message}\n`)
279
- process.exitCode = 1 // not ok
280
- })
300
+ .catch(e => recordFailure(`${resource.attributes.slug} for ${locale}`, e))
281
301
  }
282
302
 
283
303
  const BATCH_SIZE = 2
@@ -293,9 +313,8 @@ type SaveFn = (item: TransifexResourceObject, language: string) => Promise<void>
293
313
  export const saveItem = async (item: TransifexResourceObject, languages: string[], saveFn: SaveFn) => {
294
314
  const saveLanguages = languages.filter(l => l !== 'en') // exclude English from update
295
315
  for (let i = 0; i < saveLanguages.length; i += BATCH_SIZE) {
296
- await Promise.all(saveLanguages.slice(i, i + BATCH_SIZE).map(l => saveFn(item, l))).catch(err => {
297
- process.stdout.write(`Error saving item:${(err as Error).message}\n${JSON.stringify(item, null, 2)}\n`)
298
- process.exitCode = 1 // not ok
299
- })
316
+ await Promise.all(saveLanguages.slice(i, i + BATCH_SIZE).map(l => saveFn(item, l))).catch(err =>
317
+ recordFailure(`saving ${item.attributes.slug}`, err),
318
+ )
300
319
  }
301
320
  }
@@ -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, saveItem, localizeFolder } from './lib/help-utils.mts'
6
+ import { getInputs, saveItem, localizeFolder, reportFailures } from './lib/help-utils.mts'
7
7
 
8
8
  const args = process.argv.slice(2)
9
9
  const usage = `
@@ -24,3 +24,4 @@ if (!process.env.TX_TOKEN || !process.env.FRESHDESK_TOKEN || args.length > 0) {
24
24
  const { languages, folders } = await getInputs()
25
25
  console.log('Processing articles pulled from Transifex')
26
26
  await Promise.all(folders.map(item => saveItem(item, languages, localizeFolder)))
27
+ reportFailures()
@@ -3,7 +3,14 @@
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, logFreshdeskAgent } from './lib/help-utils.mts'
6
+ import {
7
+ getInputs,
8
+ getValidFreshdeskIds,
9
+ saveItem,
10
+ localizeNames,
11
+ logFreshdeskAgent,
12
+ reportFailures,
13
+ } from './lib/help-utils.mts'
7
14
 
8
15
  const args = process.argv.slice(2)
9
16
 
@@ -37,3 +44,4 @@ await Promise.all(
37
44
  ),
38
45
  ),
39
46
  )
47
+ reportFailures()
@@ -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 প্রবেশযােগ্যতা",
55
55
  "general.month": "মাস",
@@ -8,7 +8,7 @@
8
8
  "project.text2SpeechChip": "টেক্সট টু স্পিচ",
9
9
  "project.translateChip": "অনুবাদ",
10
10
  "project.videoSensingChip": "ভিডিও অনুমান করা",
11
- "project.faceSensingChip": "Face Sensing",
11
+ "project.faceSensingChip": "ফেস সেন্সিং",
12
12
  "project.needsConnection": "সংযোগ প্রয়োজন",
13
13
  "project.comments.header": "মন্তব্য",
14
14
  "project.comments.toggleOff": "মন্তব্য করা যাবেনা",