scratch-l10n 6.1.133 → 6.1.135

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.
Files changed (166) hide show
  1. package/package.json +2 -2
  2. package/scripts/lib/errors.mts +29 -8
  3. package/scripts/lib/help-utils.mts +149 -24
  4. package/scripts/lib/transifex.mts +11 -2
  5. package/scripts/tx-pull-help-articles.mts +7 -1
  6. package/scripts/tx-pull-locale-articles.mts +10 -2
  7. package/www/scratch-website.cookies-l10njson/ab.json +1 -0
  8. package/www/scratch-website.cookies-l10njson/af.json +1 -0
  9. package/www/scratch-website.cookies-l10njson/am.json +1 -0
  10. package/www/scratch-website.cookies-l10njson/an.json +1 -0
  11. package/www/scratch-website.cookies-l10njson/ar.json +1 -0
  12. package/www/scratch-website.cookies-l10njson/ast.json +1 -0
  13. package/www/scratch-website.cookies-l10njson/az.json +1 -0
  14. package/www/scratch-website.cookies-l10njson/be.json +1 -0
  15. package/www/scratch-website.cookies-l10njson/bg.json +1 -0
  16. package/www/scratch-website.cookies-l10njson/bn.json +1 -0
  17. package/www/scratch-website.cookies-l10njson/ca.json +1 -0
  18. package/www/scratch-website.cookies-l10njson/ckb.json +1 -0
  19. package/www/scratch-website.cookies-l10njson/cs.json +1 -0
  20. package/www/scratch-website.cookies-l10njson/cy.json +1 -0
  21. package/www/scratch-website.cookies-l10njson/da.json +1 -0
  22. package/www/scratch-website.cookies-l10njson/de.json +1 -0
  23. package/www/scratch-website.cookies-l10njson/el.json +1 -0
  24. package/www/scratch-website.cookies-l10njson/en.json +1 -0
  25. package/www/scratch-website.cookies-l10njson/eo.json +1 -0
  26. package/www/scratch-website.cookies-l10njson/es-419.json +1 -0
  27. package/www/scratch-website.cookies-l10njson/es.json +1 -0
  28. package/www/scratch-website.cookies-l10njson/et.json +1 -0
  29. package/www/scratch-website.cookies-l10njson/eu.json +1 -0
  30. package/www/scratch-website.cookies-l10njson/fa.json +1 -0
  31. package/www/scratch-website.cookies-l10njson/fi.json +1 -0
  32. package/www/scratch-website.cookies-l10njson/fil.json +1 -0
  33. package/www/scratch-website.cookies-l10njson/fr.json +1 -0
  34. package/www/scratch-website.cookies-l10njson/fy.json +1 -0
  35. package/www/scratch-website.cookies-l10njson/ga.json +1 -0
  36. package/www/scratch-website.cookies-l10njson/gd.json +1 -0
  37. package/www/scratch-website.cookies-l10njson/gl.json +1 -0
  38. package/www/scratch-website.cookies-l10njson/ha.json +1 -0
  39. package/www/scratch-website.cookies-l10njson/he.json +1 -0
  40. package/www/scratch-website.cookies-l10njson/hi.json +1 -0
  41. package/www/scratch-website.cookies-l10njson/hr.json +1 -0
  42. package/www/scratch-website.cookies-l10njson/ht.json +1 -0
  43. package/www/scratch-website.cookies-l10njson/hu.json +1 -0
  44. package/www/scratch-website.cookies-l10njson/hy.json +1 -0
  45. package/www/scratch-website.cookies-l10njson/id.json +1 -0
  46. package/www/scratch-website.cookies-l10njson/is.json +1 -0
  47. package/www/scratch-website.cookies-l10njson/it.json +1 -0
  48. package/www/scratch-website.cookies-l10njson/ja-Hira.json +1 -0
  49. package/www/scratch-website.cookies-l10njson/ja.json +1 -0
  50. package/www/scratch-website.cookies-l10njson/ka.json +1 -0
  51. package/www/scratch-website.cookies-l10njson/kk.json +1 -0
  52. package/www/scratch-website.cookies-l10njson/km.json +1 -0
  53. package/www/scratch-website.cookies-l10njson/ko.json +1 -0
  54. package/www/scratch-website.cookies-l10njson/ku.json +1 -0
  55. package/www/scratch-website.cookies-l10njson/lt.json +1 -0
  56. package/www/scratch-website.cookies-l10njson/lv.json +1 -0
  57. package/www/scratch-website.cookies-l10njson/mi.json +1 -0
  58. package/www/scratch-website.cookies-l10njson/mn.json +1 -0
  59. package/www/scratch-website.cookies-l10njson/nb.json +1 -0
  60. package/www/scratch-website.cookies-l10njson/nl.json +1 -0
  61. package/www/scratch-website.cookies-l10njson/nn.json +1 -0
  62. package/www/scratch-website.cookies-l10njson/nso.json +1 -0
  63. package/www/scratch-website.cookies-l10njson/oc.json +1 -0
  64. package/www/scratch-website.cookies-l10njson/or.json +1 -0
  65. package/www/scratch-website.cookies-l10njson/pl.json +1 -0
  66. package/www/scratch-website.cookies-l10njson/pt-br.json +1 -0
  67. package/www/scratch-website.cookies-l10njson/pt.json +1 -0
  68. package/www/scratch-website.cookies-l10njson/qu.json +1 -0
  69. package/www/scratch-website.cookies-l10njson/rap.json +1 -0
  70. package/www/scratch-website.cookies-l10njson/ro.json +1 -0
  71. package/www/scratch-website.cookies-l10njson/ru.json +1 -0
  72. package/www/scratch-website.cookies-l10njson/sk.json +1 -0
  73. package/www/scratch-website.cookies-l10njson/sl.json +1 -0
  74. package/www/scratch-website.cookies-l10njson/sr.json +1 -0
  75. package/www/scratch-website.cookies-l10njson/sv.json +1 -0
  76. package/www/scratch-website.cookies-l10njson/sw.json +1 -0
  77. package/www/scratch-website.cookies-l10njson/th.json +1 -0
  78. package/www/scratch-website.cookies-l10njson/tn.json +1 -0
  79. package/www/scratch-website.cookies-l10njson/tr.json +1 -0
  80. package/www/scratch-website.cookies-l10njson/uk.json +1 -0
  81. package/www/scratch-website.cookies-l10njson/uz.json +1 -0
  82. package/www/scratch-website.cookies-l10njson/vi.json +1 -0
  83. package/www/scratch-website.cookies-l10njson/xh.json +1 -0
  84. package/www/scratch-website.cookies-l10njson/zh-cn.json +1 -0
  85. package/www/scratch-website.cookies-l10njson/zh-tw.json +1 -0
  86. package/www/scratch-website.cookies-l10njson/zu.json +1 -0
  87. package/www/scratch-website.splash-l10njson/ab.json +1 -1
  88. package/www/scratch-website.splash-l10njson/af.json +1 -1
  89. package/www/scratch-website.splash-l10njson/am.json +1 -1
  90. package/www/scratch-website.splash-l10njson/an.json +1 -1
  91. package/www/scratch-website.splash-l10njson/ar.json +1 -1
  92. package/www/scratch-website.splash-l10njson/ast.json +1 -1
  93. package/www/scratch-website.splash-l10njson/az.json +1 -1
  94. package/www/scratch-website.splash-l10njson/be.json +1 -1
  95. package/www/scratch-website.splash-l10njson/bg.json +1 -1
  96. package/www/scratch-website.splash-l10njson/bn.json +1 -1
  97. package/www/scratch-website.splash-l10njson/ca.json +1 -1
  98. package/www/scratch-website.splash-l10njson/ckb.json +1 -1
  99. package/www/scratch-website.splash-l10njson/cs.json +1 -1
  100. package/www/scratch-website.splash-l10njson/cy.json +1 -1
  101. package/www/scratch-website.splash-l10njson/da.json +1 -1
  102. package/www/scratch-website.splash-l10njson/de.json +1 -1
  103. package/www/scratch-website.splash-l10njson/el.json +1 -1
  104. package/www/scratch-website.splash-l10njson/en.json +1 -1
  105. package/www/scratch-website.splash-l10njson/eo.json +1 -1
  106. package/www/scratch-website.splash-l10njson/es-419.json +1 -1
  107. package/www/scratch-website.splash-l10njson/es.json +1 -1
  108. package/www/scratch-website.splash-l10njson/et.json +1 -1
  109. package/www/scratch-website.splash-l10njson/eu.json +1 -1
  110. package/www/scratch-website.splash-l10njson/fa.json +1 -1
  111. package/www/scratch-website.splash-l10njson/fi.json +1 -1
  112. package/www/scratch-website.splash-l10njson/fil.json +1 -1
  113. package/www/scratch-website.splash-l10njson/fr.json +1 -1
  114. package/www/scratch-website.splash-l10njson/fy.json +1 -1
  115. package/www/scratch-website.splash-l10njson/ga.json +1 -1
  116. package/www/scratch-website.splash-l10njson/gd.json +1 -1
  117. package/www/scratch-website.splash-l10njson/gl.json +1 -1
  118. package/www/scratch-website.splash-l10njson/ha.json +1 -1
  119. package/www/scratch-website.splash-l10njson/he.json +1 -1
  120. package/www/scratch-website.splash-l10njson/hi.json +1 -1
  121. package/www/scratch-website.splash-l10njson/hr.json +1 -1
  122. package/www/scratch-website.splash-l10njson/ht.json +1 -1
  123. package/www/scratch-website.splash-l10njson/hu.json +1 -1
  124. package/www/scratch-website.splash-l10njson/hy.json +1 -1
  125. package/www/scratch-website.splash-l10njson/id.json +1 -1
  126. package/www/scratch-website.splash-l10njson/is.json +1 -1
  127. package/www/scratch-website.splash-l10njson/it.json +1 -1
  128. package/www/scratch-website.splash-l10njson/ja-Hira.json +1 -1
  129. package/www/scratch-website.splash-l10njson/ja.json +1 -1
  130. package/www/scratch-website.splash-l10njson/ka.json +1 -1
  131. package/www/scratch-website.splash-l10njson/kk.json +1 -1
  132. package/www/scratch-website.splash-l10njson/km.json +1 -1
  133. package/www/scratch-website.splash-l10njson/ko.json +1 -1
  134. package/www/scratch-website.splash-l10njson/ku.json +1 -1
  135. package/www/scratch-website.splash-l10njson/lt.json +1 -1
  136. package/www/scratch-website.splash-l10njson/lv.json +1 -1
  137. package/www/scratch-website.splash-l10njson/mi.json +1 -1
  138. package/www/scratch-website.splash-l10njson/mn.json +1 -1
  139. package/www/scratch-website.splash-l10njson/nb.json +1 -1
  140. package/www/scratch-website.splash-l10njson/nl.json +1 -1
  141. package/www/scratch-website.splash-l10njson/nn.json +1 -1
  142. package/www/scratch-website.splash-l10njson/nso.json +1 -1
  143. package/www/scratch-website.splash-l10njson/oc.json +1 -1
  144. package/www/scratch-website.splash-l10njson/or.json +1 -1
  145. package/www/scratch-website.splash-l10njson/pl.json +1 -1
  146. package/www/scratch-website.splash-l10njson/pt-br.json +1 -1
  147. package/www/scratch-website.splash-l10njson/pt.json +1 -1
  148. package/www/scratch-website.splash-l10njson/qu.json +1 -1
  149. package/www/scratch-website.splash-l10njson/rap.json +1 -1
  150. package/www/scratch-website.splash-l10njson/ro.json +1 -1
  151. package/www/scratch-website.splash-l10njson/ru.json +1 -1
  152. package/www/scratch-website.splash-l10njson/sk.json +1 -1
  153. package/www/scratch-website.splash-l10njson/sl.json +1 -1
  154. package/www/scratch-website.splash-l10njson/sr.json +1 -1
  155. package/www/scratch-website.splash-l10njson/sv.json +1 -1
  156. package/www/scratch-website.splash-l10njson/sw.json +1 -1
  157. package/www/scratch-website.splash-l10njson/th.json +1 -1
  158. package/www/scratch-website.splash-l10njson/tn.json +1 -1
  159. package/www/scratch-website.splash-l10njson/tr.json +1 -1
  160. package/www/scratch-website.splash-l10njson/uk.json +1 -1
  161. package/www/scratch-website.splash-l10njson/uz.json +1 -1
  162. package/www/scratch-website.splash-l10njson/vi.json +1 -1
  163. package/www/scratch-website.splash-l10njson/xh.json +1 -1
  164. package/www/scratch-website.splash-l10njson/zh-cn.json +1 -1
  165. package/www/scratch-website.splash-l10njson/zh-tw.json +1 -1
  166. package/www/scratch-website.splash-l10njson/zu.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scratch-l10n",
3
- "version": "6.1.133",
3
+ "version": "6.1.135",
4
4
  "description": "Localization for the Scratch 3.0 components",
5
5
  "main": "./dist/l10n.js",
6
6
  "browser": "./src/index.mjs",
@@ -81,7 +81,7 @@
81
81
  "jshint": "2.13.6",
82
82
  "json": "9.0.6",
83
83
  "jsonlint": "1.6.3",
84
- "prettier": "3.9.9",
84
+ "prettier": "3.9.10",
85
85
  "rimraf": "2.7.1",
86
86
  "scratch-semantic-release-config": "4.0.1",
87
87
  "semantic-release": "25.0.9",
@@ -11,6 +11,14 @@
11
11
  */
12
12
  export const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err))
13
13
 
14
+ /** Where a JSON parse error falls, as named by a shape-aware locator for {@link describeJsonParseError}. */
15
+ export interface JsonEntryLocation {
16
+ /** Human-readable name of the entry, for the message (for example `key "123.description"`). */
17
+ label: string
18
+ /** The string key, when the locator could pin one, for linking straight to it. */
19
+ key?: string
20
+ }
21
+
14
22
  /**
15
23
  * Describe where a `JSON.parse` failed, for a diagnostic message. V8 reports the byte offset (and, on
16
24
  * newer Node, the line and column) in the error message; combined with the original text this pins the
@@ -19,9 +27,18 @@ export const messageOf = (err: unknown): string => (err instanceof Error ? err.m
19
27
  * be read, so callers fall back to the plain message.
20
28
  * @param error - the caught value (expected to be a `SyntaxError` from `JSON.parse`)
21
29
  * @param text - the exact string that was parsed
22
- * @returns a one-line description (location, nearby key, snippet), or undefined
30
+ * @param locate - optional, for a caller that knows the file's shape: given the text before the error
31
+ * position, name the entry it falls in (a `label` for the message, and the string `key` when known).
32
+ * Used in place of the flat-file "near key" guess, which nested files with escaped quotes in their
33
+ * values easily fool.
34
+ * @returns a one-line `message` (location, entry or nearby key, snippet) plus the located string `key`
35
+ * if any, or undefined
23
36
  */
24
- export const describeJsonParseError = (error: unknown, text: string): string | undefined => {
37
+ export const describeJsonParseError = (
38
+ error: unknown,
39
+ text: string,
40
+ locate?: (before: string) => JsonEntryLocation | undefined,
41
+ ): { message: string; key?: string } | undefined => {
25
42
  if (!(error instanceof SyntaxError) || typeof text !== 'string') {
26
43
  return undefined
27
44
  }
@@ -35,6 +52,15 @@ export const describeJsonParseError = (error: unknown, text: string): string | u
35
52
  }
36
53
  const lineColMatch = /\(line (\d+) column (\d+)\)/.exec(error.message)
37
54
  const where = lineColMatch ? `line ${lineColMatch[1]}, column ${lineColMatch[2]}` : `position ${pos}`
55
+ // A short, whitespace-collapsed window around the failure so the operator can eyeball the bad text.
56
+ const snippet = text
57
+ .slice(Math.max(0, pos - 30), pos + 30)
58
+ .replace(/\s+/g, ' ')
59
+ .trim()
60
+ const entry = locate?.(text.slice(0, pos))
61
+ if (entry) {
62
+ return { message: `malformed JSON in ${entry.label} (${where}): …${snippet}…`, key: entry.key }
63
+ }
38
64
  // Recover the enclosing key of a flat KEYVALUEJSON object: the value that failed to parse is
39
65
  // preceded by `"<key>":`, so scan back from the error position to that key. Heuristic (a colon or
40
66
  // quote inside an earlier string can fool it), so it is reported as "near key", not an exact locator.
@@ -50,11 +76,6 @@ export const describeJsonParseError = (error: unknown, text: string): string | u
50
76
  }
51
77
  }
52
78
  }
53
- // A short, whitespace-collapsed window around the failure so the operator can eyeball the bad text.
54
- const snippet = text
55
- .slice(Math.max(0, pos - 30), pos + 30)
56
- .replace(/\s+/g, ' ')
57
- .trim()
58
79
  const keyPart = nearKey ? ` near key "${nearKey}"` : ''
59
- return `malformed JSON at ${where}${keyPart}: …${snippet}…`
80
+ return { message: `malformed JSON at ${where}${keyPart}: …${snippet}…` }
60
81
  }
@@ -5,7 +5,7 @@
5
5
  import { promises as fsPromises } from 'fs'
6
6
  import { mkdirp } from 'mkdirp'
7
7
  import { emitAnnotation } from './annotations.mts'
8
- import { describeJsonParseError, messageOf } from './errors.mts'
8
+ import { describeJsonParseError, JsonEntryLocation, messageOf } from './errors.mts'
9
9
  import FreshdeskApi, {
10
10
  FreshdeskArticleCreate,
11
11
  FreshdeskArticleStatus,
@@ -28,6 +28,8 @@ import { emitWarning } from './warnings.mts'
28
28
 
29
29
  const FD = new FreshdeskApi('https://mitscratch.freshdesk.com', process.env.FRESHDESK_TOKEN ?? '')
30
30
  const TX_PROJECT = 'scratch-help'
31
+ /** The Transifex resources that hold category and folder names; every other JSON resource holds articles. */
32
+ const NAMES_RESOURCES = ['categoryNames_json', 'folderNames_json']
31
33
 
32
34
  // Collect per-item failures instead of aborting on the first one, so one bad translation or a single
33
35
  // item the token cannot write still lets every other item sync. Freshdesk rate limiting is handled
@@ -35,20 +37,31 @@ const TX_PROJECT = 'scratch-help'
35
37
  // here is a genuine problem worth a human's attention. `reportFailures` prints a consolidated summary
36
38
  // and fails the run at the end, so real errors are surfaced rather than lost in the log.
37
39
  const failures: { context: string; message: string; link?: string }[] = []
38
- const recordFailure = (context: string, error: unknown, link?: string): void => {
40
+ const recordFailure = (
41
+ context: string,
42
+ error: unknown,
43
+ link?: string,
44
+ locate?: (before: string) => JsonEntryLocation | undefined,
45
+ ): void => {
39
46
  const message = messageOf(error)
40
47
  // A txPull error carries the failing (resource, locale) — and, for a parse failure, the raw text —
41
48
  // on its `cause`, so we can build a Transifex link and describe malformed JSON without the call site
42
- // passing anything. An explicit link still wins: some callers know the fix lives on the source (`en`)
49
+ // passing anything. When the parse error can be pinned to one string, the link filters the editor to
50
+ // that string's key. An explicit link still wins: some callers know the fix lives on the source (`en`)
43
51
  // rather than the translation locale the pull used.
44
52
  const cause = txPullErrorCause(error)
53
+ const detail = cause?.buffer != null ? describeJsonParseError(error, cause.buffer, locate) : undefined
45
54
  const resolvedLink =
46
55
  link ??
47
56
  (cause
48
- ? transifexEditorLink({ project: cause.project, resource: cause.resource, lang: cause.locale })
57
+ ? transifexEditorLink({
58
+ project: cause.project,
59
+ resource: cause.resource,
60
+ lang: cause.locale,
61
+ query: detail?.key && `key:${detail.key}`,
62
+ })
49
63
  : undefined)
50
- const detail = cause?.buffer != null ? describeJsonParseError(error, cause.buffer) : undefined
51
- const fullMessage = detail ? `${message} — ${detail}` : message
64
+ const fullMessage = detail ? `${message} — ${detail.message}` : message
52
65
  // Genuine failures go to stderr (like the consolidated summary), so they stand apart from normal output.
53
66
  process.stderr.write(`Error: ${context}: ${fullMessage}${resolvedLink ? ` (${resolvedLink})` : ''}\n`)
54
67
  failures.push({ context, message: fullMessage, link: resolvedLink })
@@ -345,13 +358,16 @@ export const getInputs = async () => {
345
358
  const resourcesPromise = txResourcesObjects(TX_PROJECT)
346
359
  const languagesPromise = txAvailableLanguages(TX_PROJECT)
347
360
 
348
- // there are three types of resources differentiated by the file type
361
+ // Category and folder names live in two fixed KEYVALUEJSON resources; every other JSON resource holds a
362
+ // folder's articles. Classify by name rather than type: some article resources were created as
363
+ // KEYVALUEJSON by mistake, and they download in the same nested shape as the STRUCTURED_JSON ones.
364
+ const isNames = (resource: TransifexResourceObject) => NAMES_RESOURCES.includes(resource.attributes.slug)
365
+ const isJson = (resource: TransifexResourceObject) =>
366
+ resource.attributes.i18n_type === 'STRUCTURED_JSON' || resource.attributes.i18n_type === 'KEYVALUEJSON'
349
367
  const foldersPromise = resourcesPromise.then(resources =>
350
- resources.filter(resource => resource.attributes.i18n_type === 'STRUCTURED_JSON'),
351
- )
352
- const namesPromise = resourcesPromise.then(resources =>
353
- resources.filter(resource => resource.attributes.i18n_type === 'KEYVALUEJSON'),
368
+ resources.filter(resource => isJson(resource) && !isNames(resource)),
354
369
  )
370
+ const namesPromise = resourcesPromise.then(resources => resources.filter(isNames))
355
371
  // ignore the yaml type because it's not possible to update via API
356
372
 
357
373
  const [languages, folders, names] = await Promise.all([languagesPromise, foldersPromise, namesPromise])
@@ -383,6 +399,46 @@ export const getValidFreshdeskIds = async () => {
383
399
  return { validCategoryIds, validFolderIds }
384
400
  }
385
401
 
402
+ /**
403
+ * Freshdesk folders whose article translations are deliberately not written to Freshdesk, even when they're
404
+ * translated in Transifex. Their articles are still pushed to Transifex as source.
405
+ *
406
+ * - 4000040867 "Legal Documents" (Terms of Service, Privacy Policy, and the like): we don't publish
407
+ * translations of legal documents for now. This decision may be revisited.
408
+ */
409
+ const UNSYNCED_FOLDER_IDS = new Set([4000040867])
410
+
411
+ /**
412
+ * Keep only the article resources whose translations should be written to Freshdesk: the folder must still
413
+ * exist, and must not be in {@link UNSYNCED_FOLDER_IDS}. The push only updates resources for current
414
+ * folders, so a resource left behind by a deleted folder keeps its old English source forever. Its articles
415
+ * may have moved to another folder, so pulling it would overwrite their live translations with translations
416
+ * of outdated content. Each such resource is reported once so someone can remove it.
417
+ * @param folders - article resources from {@link getInputs}, named `<folder name>_<folder id>_json`
418
+ * @param validFolderIds - set of Freshdesk folder IDs that currently exist
419
+ * @returns the resources whose translations should sync to Freshdesk
420
+ */
421
+ export const foldersToSync = (folders: TransifexResourceObject[], validFolderIds: Set<number>) =>
422
+ folders.filter(resource => {
423
+ const slug = resource.attributes.slug
424
+ const match = /_(\d+)_json$/.exec(slug)
425
+ if (match && UNSYNCED_FOLDER_IDS.has(Number(match[1]))) {
426
+ // Intentional, so log it rather than raising a warning on every run.
427
+ console.log(`Not syncing Transifex resource "${slug}": its translations are excluded from Freshdesk.`)
428
+ return false
429
+ }
430
+ if (match && validFolderIds.has(Number(match[1]))) {
431
+ return true
432
+ }
433
+ emitWarning(
434
+ match
435
+ ? `Skipping Transifex resource "${slug}": Freshdesk folder ${match[1]} no longer exists. Remove this resource from Transifex.`
436
+ : `Skipping Transifex resource "${slug}": its name doesn't end in a Freshdesk folder ID.`,
437
+ transifexEditorLink({ project: TX_PROJECT, resource: slug, lang: SOURCE_LOCALE }),
438
+ )
439
+ return false
440
+ })
441
+
386
442
  /**
387
443
  * internal function to serialize saving category and folder name translations to avoid Freshdesk rate limit
388
444
  * @param strings - the string data pulled from Transifex
@@ -414,7 +470,12 @@ const serializeNameSave = async (
414
470
  emitWarning(
415
471
  `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.`,
416
472
  // The key is removed from the source, so point at the source language, not a translation.
417
- transifexEditorLink({ project: TX_PROJECT, resource: resource.attributes.slug, lang: SOURCE_LOCALE }),
473
+ transifexEditorLink({
474
+ project: TX_PROJECT,
475
+ resource: resource.attributes.slug,
476
+ lang: SOURCE_LOCALE,
477
+ query: `key:${key}`,
478
+ }),
418
479
  )
419
480
  }
420
481
  continue
@@ -441,7 +502,12 @@ const serializeNameSave = async (
441
502
  recordFailure(
442
503
  `${resource.attributes.name} entry "${key}" for ${locale}`,
443
504
  error,
444
- transifexEditorLink({ project: TX_PROJECT, resource: resource.attributes.slug, lang: locale }),
505
+ transifexEditorLink({
506
+ project: TX_PROJECT,
507
+ resource: resource.attributes.slug,
508
+ lang: locale,
509
+ query: `key:${key}`,
510
+ }),
445
511
  )
446
512
  allOk = false
447
513
  }
@@ -459,6 +525,53 @@ interface FreshdeskFolderInTransifex {
459
525
  tags: { string: string }
460
526
  }
461
527
 
528
+ /**
529
+ * Name the article (and field) that a position in a folder resource's raw JSON falls in, for a parse
530
+ * error. Matches only the structural `"<id>":{"title"…` and `"<field>":{"string":` openers, which don't
531
+ * occur inside the HTML values, so escaped or stray quotes in the content can't throw it off.
532
+ * @param before - the raw text up to the parse error position
533
+ * @returns the article's string key (e.g. `4000123456.description`) and a label for it, or undefined if no
534
+ * article opener precedes the position
535
+ */
536
+ const locateArticle = (before: string): JsonEntryLocation | undefined => {
537
+ const article = [...before.matchAll(/"(\d+)"\s*:\s*\{\s*"(?:title|description|tags)"/g)].pop()
538
+ if (article?.index == null) {
539
+ return undefined
540
+ }
541
+ const field = [
542
+ ...before.slice(article.index).matchAll(/[{,]\s*"(title|description|tags)"\s*:\s*\{\s*"string"\s*:/g),
543
+ ].pop()
544
+ if (!field) {
545
+ return { label: `article ${article[1]}` }
546
+ }
547
+ // The Transifex string key is `<id>.<field>`, which the editor shows and can filter to.
548
+ const key = `${article[1]}.${field[1]}`
549
+ return { label: `key "${key}"`, key }
550
+ }
551
+
552
+ /**
553
+ * Split a translated tags string into Freshdesk tags. The push joins each article's tags with ASCII commas,
554
+ * but translators often use their own language's comma, so also split on the Arabic (،), ideographic (、),
555
+ * and full-width (,) commas. Tags are trimmed, empty ones dropped, and duplicates that differ only in case
556
+ * removed, since Freshdesk rejects those.
557
+ * @param tags - the translated tags string
558
+ * @returns the tags to send to Freshdesk, in their original order
559
+ */
560
+ export const splitTags = (tags: string): string[] => {
561
+ const seen = new Set<string>()
562
+ return tags
563
+ .split(/[,،、,]/)
564
+ .map(tag => tag.trim())
565
+ .filter(tag => {
566
+ const folded = tag.toLowerCase()
567
+ if (!tag || seen.has(folded)) {
568
+ return false
569
+ }
570
+ seen.add(folded)
571
+ return true
572
+ })
573
+ }
574
+
462
575
  /**
463
576
  * Internal function serialize Freshdesk requests to avoid getting rate limited
464
577
  * @param json object with keys corresponding to article ids
@@ -482,7 +595,7 @@ const serializeFolderSave = async (
482
595
  status: FreshdeskArticleStatus.published,
483
596
  }
484
597
  if (Object.prototype.hasOwnProperty.call(value, 'tags')) {
485
- const tags = value.tags.string.split(',')
598
+ const tags = splitTags(value.tags.string)
486
599
  // Freshdesk rejects tags longer than 32 characters, so drop them rather than fail the write.
487
600
  const validTags = tags.filter(tag => tag.length < 33)
488
601
  const droppedTags = tags.filter(tag => tag.length >= 33)
@@ -492,7 +605,12 @@ const serializeFolderSave = async (
492
605
  `(${locale}); shorten them in Transifex: ${droppedTags
493
606
  .map(tag => `"${tag}" (${tag.length} chars)`)
494
607
  .join(', ')}.`,
495
- transifexEditorLink({ project: TX_PROJECT, resource: resourceSlug, lang: locale }),
608
+ transifexEditorLink({
609
+ project: TX_PROJECT,
610
+ resource: resourceSlug,
611
+ lang: locale,
612
+ query: `key:${id}.tags`,
613
+ }),
496
614
  )
497
615
  }
498
616
  body.tags = validTags
@@ -505,12 +623,19 @@ const serializeFolderSave = async (
505
623
  }
506
624
  } catch (error) {
507
625
  // Record the failure so the job still reports a non-zero exit at the end, then move on to the
508
- // next article.
509
- recordFailure(
510
- `article ${idString} for ${locale}`,
511
- error,
512
- transifexEditorLink({ project: TX_PROJECT, resource: resourceSlug, lang: locale }),
513
- )
626
+ // next article. Freshdesk won't accept an article translation until its folder's name is
627
+ // translated too, so for that error point at the folder's entry in the names resource (keyed by
628
+ // this resource's slug without `_json`) instead of the article. Otherwise filter the editor to
629
+ // the article's strings (a `key:<id>` search matches its title, description, and tags).
630
+ const link = /"field"\s*:\s*"folder_name"/.test(messageOf(error))
631
+ ? transifexEditorLink({
632
+ project: TX_PROJECT,
633
+ resource: 'folderNames_json',
634
+ lang: locale,
635
+ query: `key:${resourceSlug.replace(/_json$/, '')}`,
636
+ })
637
+ : transifexEditorLink({ project: TX_PROJECT, resource: resourceSlug, lang: locale, query: `key:${idString}` })
638
+ recordFailure(`article ${idString} for ${locale}`, error, link)
514
639
  allOk = false
515
640
  }
516
641
  }
@@ -533,7 +658,7 @@ export const localizeFolder = async (folderAttributes: TransifexResourceObject,
533
658
  )
534
659
  return await serializeFolderSave(data, locale, folderAttributes.attributes.slug)
535
660
  } catch (e) {
536
- recordFailure(`${folderAttributes.attributes.slug} for ${locale}`, e)
661
+ recordFailure(`${folderAttributes.attributes.slug} for ${locale}`, e, undefined, locateArticle)
537
662
  return false
538
663
  }
539
664
  }
@@ -561,8 +686,8 @@ export const debugFolder = async (folderAttributes: TransifexResourceObject, loc
561
686
  }
562
687
 
563
688
  /**
564
- * Process KEYVALUEJSON resources from scratch-help on transifex
565
- * Category and Folder names are stored as plain json
689
+ * Process the category and folder names resources from scratch-help on transifex
690
+ * Category and Folder names are stored as plain json (KEYVALUEJSON)
566
691
  * @param resource Transifex resource json for either CategoryNames or FolderNames
567
692
  * @param locale locale to pull and submit to Freshdesk
568
693
  * @param validCategoryIds - set of Freshdesk category IDs that currently exist
@@ -16,7 +16,8 @@ export const SOURCE_LOCALE = 'en'
16
16
 
17
17
  /**
18
18
  * Build a deep link to the Transifex online editor for a resource in one language, matching the URL
19
- * the web app uses: `app.transifex.com/<org>/<project>/translate/#<lang>/<resource>[/<stringId>]`.
19
+ * the web app uses: `app.transifex.com/<org>/<project>/translate/#<lang>/<resource>[/<stringId>]`, or
20
+ * `…/<resource>?q=<query>` to open the editor with a search applied.
20
21
  * @param params - link target
21
22
  * @param params.project - project slug (for example, `scratch-help`)
22
23
  * @param params.resource - resource slug (for example, `Accessibility_4000040849_json`)
@@ -24,6 +25,8 @@ export const SOURCE_LOCALE = 'en'
24
25
  * translation locale for a bad translation
25
26
  * @param params.stringId - optional Transifex numeric string id to focus a single string; omitted, the
26
27
  * link lands on the resource (with its first string shown)
28
+ * @param params.query - optional editor search, as typed into its search box (for example,
29
+ * `key:4000156778.description` to filter to one string by its key); takes precedence over `stringId`
27
30
  * @returns the editor URL
28
31
  */
29
32
  export const transifexEditorLink = (params: {
@@ -31,9 +34,15 @@ export const transifexEditorLink = (params: {
31
34
  resource: string
32
35
  lang: string
33
36
  stringId?: string | number
37
+ query?: string
34
38
  }): string => {
35
- const { project, resource, lang, stringId } = params
39
+ const { project, resource, lang, stringId, query } = params
36
40
  const base = `https://app.transifex.com/${ORG_NAME}/${project}/translate/#${lang}/${resource}`
41
+ if (query != null) {
42
+ // No slash before `?q=`. The editor rewrites its address bar to `<resource>/?q=…`, but that form
43
+ // lands on the project overview (query ignored) when opened fresh.
44
+ return `${base}?q=${encodeURIComponent(query)}`
45
+ }
37
46
  return stringId == null ? base : `${base}/${stringId}`
38
47
  }
39
48
 
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import {
7
7
  getInputs,
8
+ getValidFreshdeskIds,
9
+ foldersToSync,
8
10
  saveItem,
9
11
  localizeFolder,
10
12
  initChangeDetection,
@@ -29,7 +31,11 @@ if (!process.env.TX_TOKEN || !process.env.FRESHDESK_TOKEN || args.length > 0) {
29
31
  }
30
32
 
31
33
  await initChangeDetection()
32
- const { languages, folders } = await getInputs()
34
+ const [{ languages, folders: allFolders }, { validFolderIds }] = await Promise.all([
35
+ getInputs(),
36
+ getValidFreshdeskIds(),
37
+ ])
38
+ const folders = foldersToSync(allFolders, validFolderIds)
33
39
  reportSkippedLocales(languages)
34
40
  console.log('Processing articles pulled from Transifex')
35
41
  await Promise.all(folders.map(item => saveItem(item, languages, localizeFolder)))
@@ -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, saveItem, localizeFolder, debugFolder } from './lib/help-utils.mts'
6
+ import {
7
+ getInputs,
8
+ getValidFreshdeskIds,
9
+ foldersToSync,
10
+ saveItem,
11
+ localizeFolder,
12
+ debugFolder,
13
+ } from './lib/help-utils.mts'
7
14
 
8
15
  const args = process.argv.slice(2)
9
16
  const usage = `
@@ -29,6 +36,7 @@ if (locale === '-d') {
29
36
  }
30
37
  const saveFn = debug ? debugFolder : localizeFolder
31
38
 
32
- const { folders } = await getInputs()
39
+ const [{ folders: allFolders }, { validFolderIds }] = await Promise.all([getInputs(), getValidFreshdeskIds()])
40
+ const folders = foldersToSync(allFolders, validFolderIds)
33
41
  console.log('Processing articles pulled from Transifex')
34
42
  await Promise.all(folders.map(item => saveItem(item, [locale], saveFn)))
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Used to save the user’s current search preference.",
31
31
  "cookies.functional.scratchLanguage": "Used to save information about the user’s preferred language.",
32
32
  "cookies.functional.scratchTheme": "Used to save information about the user’s preferred color mode.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Used to track if the user has seen a privacy policy update notification",
34
35
  "cookies.functional.wistia": "Contains a timestamp for the Scratch Website’s video-content. This allows the user to resume watching without having to start over, if the user leaves the video or Scratch Website.",
35
36
  "cookies.functional.minilogSettings": "Unclassified",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Used to save the user’s current search preference.",
31
31
  "cookies.functional.scratchLanguage": "Used to save information about the user’s preferred language.",
32
32
  "cookies.functional.scratchTheme": "Used to save information about the user’s preferred color mode.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Used to track if the user has seen a privacy policy update notification",
34
35
  "cookies.functional.wistia": "Contains a timestamp for the Scratch Website’s video-content. This allows the user to resume watching without having to start over, if the user leaves the video or Scratch Website.",
35
36
  "cookies.functional.minilogSettings": "Unclassified",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Used to save the user’s current search preference.",
31
31
  "cookies.functional.scratchLanguage": "Used to save information about the user’s preferred language.",
32
32
  "cookies.functional.scratchTheme": "Used to save information about the user’s preferred color mode.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Used to track if the user has seen a privacy policy update notification",
34
35
  "cookies.functional.wistia": "Contains a timestamp for the Scratch Website’s video-content. This allows the user to resume watching without having to start over, if the user leaves the video or Scratch Website.",
35
36
  "cookies.functional.minilogSettings": "Unclassified",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Used to save the user’s current search preference.",
31
31
  "cookies.functional.scratchLanguage": "Used to save information about the user’s preferred language.",
32
32
  "cookies.functional.scratchTheme": "Used to save information about the user’s preferred color mode.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Used to track if the user has seen a privacy policy update notification",
34
35
  "cookies.functional.wistia": "Contains a timestamp for the Scratch Website’s video-content. This allows the user to resume watching without having to start over, if the user leaves the video or Scratch Website.",
35
36
  "cookies.functional.minilogSettings": "Unclassified",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "تُستخدم لحفظ تفضيلات البحث الحالية للمستخدم.",
31
31
  "cookies.functional.scratchLanguage": "تُستخدم لحفظ معلومات حول اللغة المفضلة للمستخدم.",
32
32
  "cookies.functional.scratchTheme": "يُستخدم لحفظ معلومات حول وضع الألوان المفضل للمستخدم.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "يُستخدم لتتبع ما إذا كان المستخدم قد شاهد إشعار تحديث سياسة الخصوصية",
34
35
  "cookies.functional.wistia": "يحتوي على طابع زمني لمحتوى الفيديو الخاص بموقع Scratch. يتيح ذلك للمستخدم استئناف المشاهدة دون الحاجة إلى البدء من جديد، إذا غادر المستخدم الفيديو أو موقع Scratch.",
35
36
  "cookies.functional.minilogSettings": "غير مصنف",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Used to save the user’s current search preference.",
31
31
  "cookies.functional.scratchLanguage": "Used to save information about the user’s preferred language.",
32
32
  "cookies.functional.scratchTheme": "Used to save information about the user’s preferred color mode.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Used to track if the user has seen a privacy policy update notification",
34
35
  "cookies.functional.wistia": "Contains a timestamp for the Scratch Website’s video-content. This allows the user to resume watching without having to start over, if the user leaves the video or Scratch Website.",
35
36
  "cookies.functional.minilogSettings": "Unclassified",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Used to save the user’s current search preference.",
31
31
  "cookies.functional.scratchLanguage": "Used to save information about the user’s preferred language.",
32
32
  "cookies.functional.scratchTheme": "Used to save information about the user’s preferred color mode.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Used to track if the user has seen a privacy policy update notification",
34
35
  "cookies.functional.wistia": "Contains a timestamp for the Scratch Website’s video-content. This allows the user to resume watching without having to start over, if the user leaves the video or Scratch Website.",
35
36
  "cookies.functional.minilogSettings": "Unclassified",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Выкарыстоўваецца для захавання бягучых параметраў пошуку карыстальніка.",
31
31
  "cookies.functional.scratchLanguage": "Выкарыстоўваецца для захавання інфармацыі аб мове, якую карыстальнік аддае перавагу.",
32
32
  "cookies.functional.scratchTheme": "Выкарыстоўваецца для захавання інфармацыі аб абраным каляровым рэжыме карыстальніка.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Выкарыстоўваецца для адсочвання таго, ці бачыў карыстальнік апавяшчэнне аб абнаўленні палітыкі прыватнасці",
34
35
  "cookies.functional.wistia": "Змяшчае пазнаку часу для відэакантэнту сайта Scratch. Гэта дазваляе карыстальніку аднавіць прагляд без неабходнасці пачынаць спачатку, калі карыстальнік пакідае відэа ці вэб-сайт Scratch.",
35
36
  "cookies.functional.minilogSettings": "Некласіфікаваны",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Used to save the user’s current search preference.",
31
31
  "cookies.functional.scratchLanguage": "Used to save information about the user’s preferred language.",
32
32
  "cookies.functional.scratchTheme": "Used to save information about the user’s preferred color mode.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Used to track if the user has seen a privacy policy update notification",
34
35
  "cookies.functional.wistia": "Contains a timestamp for the Scratch Website’s video-content. This allows the user to resume watching without having to start over, if the user leaves the video or Scratch Website.",
35
36
  "cookies.functional.minilogSettings": "Unclassified",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "ব্যবহারকারীর বর্তমান সার্চ পছন্দ সংরক্ষণ করতে ব্যবহৃত হয়।",
31
31
  "cookies.functional.scratchLanguage": "ব্যবহারকারীর পছন্দের ভাষা সম্পর্কে তথ্য সংরক্ষণ করতে ব্যবহৃত হয়।",
32
32
  "cookies.functional.scratchTheme": "ব্যবহারকারীর পছন্দের রঙের মোড সম্পর্কে তথ্য সংরক্ষণ করতে ব্যবহৃত হয়।",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "ব্যবহারকারী একটি প্রাইভেসি নীতি আপডেট নোটিফিকেশন দেখেছে কিনা তা ট্র্যাক করতে ব্যবহৃত হয়",
34
35
  "cookies.functional.wistia": "Scratch ওয়েবসাইটের ভিডিও-বিষয়বস্তুর জন্য একটি টাইমস্ট্যাম্প ধারণ করে। এটি ব্যবহারকারীকে ভিডিও বা Scratch ওয়েবসাইট ছেড়ে গেলে আবার প্রথম থেকে শুরু না করে দেখা চালিয়ে যেতে দেয়।",
35
36
  "cookies.functional.minilogSettings": "শ্রেণীবিভক্ত করা হয়নি এমন",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Used to save the user’s current search preference.",
31
31
  "cookies.functional.scratchLanguage": "Used to save information about the user’s preferred language.",
32
32
  "cookies.functional.scratchTheme": "Used to save information about the user’s preferred color mode.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Used to track if the user has seen a privacy policy update notification",
34
35
  "cookies.functional.wistia": "Contains a timestamp for the Scratch Website’s video-content. This allows the user to resume watching without having to start over, if the user leaves the video or Scratch Website.",
35
36
  "cookies.functional.minilogSettings": "Unclassified",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Used to save the user’s current search preference.",
31
31
  "cookies.functional.scratchLanguage": "Used to save information about the user’s preferred language.",
32
32
  "cookies.functional.scratchTheme": "Used to save information about the user’s preferred color mode.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Used to track if the user has seen a privacy policy update notification",
34
35
  "cookies.functional.wistia": "Contains a timestamp for the Scratch Website’s video-content. This allows the user to resume watching without having to start over, if the user leaves the video or Scratch Website.",
35
36
  "cookies.functional.minilogSettings": "Unclassified",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Slouží k uložení uživatelových aktuálních vyhledávacích preferencí.",
31
31
  "cookies.functional.scratchLanguage": "Slouží k ukládání informací o preferovaném jazyce uživatele.",
32
32
  "cookies.functional.scratchTheme": "Slouží k uložení informací o uživatelem preferovaném barevném režimu.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Používá se ke sledování, zda se uživateli zobrazilo oznámení o aktualizaci zásad ochrany osobních údajů",
34
35
  "cookies.functional.wistia": "Obsahuje časové razítko pro videoobsah webových stránek Scratch. To umožňuje uživateli pokračovat ve sledování, aniž by musel začínat znovu, pokud uživatel opustí video nebo Scratch web.",
35
36
  "cookies.functional.minilogSettings": "Neklasifikováno",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Fe'i defnyddir i gadw dewis chwilio cyfredol y defnyddiwr.",
31
31
  "cookies.functional.scratchLanguage": "Fe'i defnyddir i gadw gwybodaeth am ddewis iaith y defnyddiwr.",
32
32
  "cookies.functional.scratchTheme": "Mae'n cael ei ddefnyddio i gadw gwybodaeth am ddewis iaith y defnyddiwr.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Fe'i defnyddir i olrhain a yw'r defnyddiwr wedi gweld hysbysiad diweddaru polisi preifatrwydd",
34
35
  "cookies.functional.wistia": "Yn cynnwys stamp amser ar gyfer cynnwys fideo Gwefan Scratch. Mae hyn yn caniatáu i'r defnyddiwr ailddechrau gwylio heb orfod dechrau drosodd, os yw'r defnyddiwr yn gadael y fideo neu Scratch Gwefan.",
35
36
  "cookies.functional.minilogSettings": "Annosbarthedig",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Used to save the user’s current search preference.",
31
31
  "cookies.functional.scratchLanguage": "Used to save information about the user’s preferred language.",
32
32
  "cookies.functional.scratchTheme": "Used to save information about the user’s preferred color mode.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Used to track if the user has seen a privacy policy update notification",
34
35
  "cookies.functional.wistia": "Contains a timestamp for the Scratch Website’s video-content. This allows the user to resume watching without having to start over, if the user leaves the video or Scratch Website.",
35
36
  "cookies.functional.minilogSettings": "Unclassified",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Wird verwendet, um die aktuellen Sucheinstellungen des Benutzers zu speichern.",
31
31
  "cookies.functional.scratchLanguage": "Wird verwendet, um Informationen über die bevorzugte Sprache des Benutzers zu speichern.",
32
32
  "cookies.functional.scratchTheme": "Wird zum Speichern von Informationen über den bevorzugten Farbmodus des Benutzers verwendet.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Wird verwendet, um zu verfolgen, ob der Benutzer eine Benachrichtigung über eine Aktualisierung der Datenschutzrichtlinie gesehen hat",
34
35
  "cookies.functional.wistia": "Enthält einen Zeitstempel für den Videoinhalt der Scratch-Website. Dadurch kann der Benutzer die Wiedergabe fortsetzen, ohne von vorne beginnen zu müssen, wenn er das Video oder die Scratch-Website verlässt.",
35
36
  "cookies.functional.minilogSettings": "Nicht klassifiziert",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Used to save the user’s current search preference.",
31
31
  "cookies.functional.scratchLanguage": "Used to save information about the user’s preferred language.",
32
32
  "cookies.functional.scratchTheme": "Used to save information about the user’s preferred color mode.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Used to track if the user has seen a privacy policy update notification",
34
35
  "cookies.functional.wistia": "Contains a timestamp for the Scratch Website’s video-content. This allows the user to resume watching without having to start over, if the user leaves the video or Scratch Website.",
35
36
  "cookies.functional.minilogSettings": "Unclassified",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Used to save the user’s current search preference.",
31
31
  "cookies.functional.scratchLanguage": "Used to save information about the user’s preferred language.",
32
32
  "cookies.functional.scratchTheme": "Used to save information about the user’s preferred color mode.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Used to track if the user has seen a privacy policy update notification",
34
35
  "cookies.functional.wistia": "Contains a timestamp for the Scratch Website’s video-content. This allows the user to resume watching without having to start over, if the user leaves the video or Scratch Website.",
35
36
  "cookies.functional.minilogSettings": "Unclassified",
@@ -30,6 +30,7 @@
30
30
  "cookies.functional.exploreBy": "Used to save the user’s current search preference.",
31
31
  "cookies.functional.scratchLanguage": "Used to save information about the user’s preferred language.",
32
32
  "cookies.functional.scratchTheme": "Used to save information about the user’s preferred color mode.",
33
+ "cookies.functional.donateBannerAb": "Used to remember which homepage donate banner to show while we compare banner versions.",
33
34
  "cookies.functional.scratchPolicySeen": "Used to track if the user has seen a privacy policy update notification",
34
35
  "cookies.functional.wistia": "Contains a timestamp for the Scratch Website’s video-content. This allows the user to resume watching without having to start over, if the user leaves the video or Scratch Website.",
35
36
  "cookies.functional.minilogSettings": "Unclassified",