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