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 +2 -4
- package/scripts/lib/freshdesk-api.mts +203 -36
- package/scripts/lib/help-utils.mts +51 -32
- package/scripts/tx-pull-help-articles.mts +2 -1
- package/scripts/tx-pull-help-names.mts +9 -1
- package/www/scratch-website.general-l10njson/bn.json +1 -1
- package/www/scratch-website.preview-l10njson/bn.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scratch-l10n",
|
|
3
|
-
"version": "6.1.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
173
|
+
await FD.updateCategoryTranslation(id, freshdeskLocale(locale), { name: value })
|
|
147
174
|
} else if (resource.attributes.name === 'folderNames_json') {
|
|
148
|
-
|
|
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
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
204
|
-
if (status === -1) {
|
|
205
|
-
process.exitCode = 1
|
|
206
|
-
}
|
|
231
|
+
await FD.updateArticleTranslation(id, freshdeskLocale(locale), body)
|
|
207
232
|
} catch (error) {
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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 {
|
|
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": "
|
|
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": "
|
|
11
|
+
"project.faceSensingChip": "ফেস সেন্সিং",
|
|
12
12
|
"project.needsConnection": "সংযোগ প্রয়োজন",
|
|
13
13
|
"project.comments.header": "মন্তব্য",
|
|
14
14
|
"project.comments.toggleOff": "মন্তব্য করা যাবেনা",
|