@crowdin/app-project-module 0.28.0-9 → 0.28.0

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/README.md CHANGED
@@ -2,7 +2,990 @@
2
2
 
3
3
  Module that will automatically add all necessary endpoints for Crowdin App.
4
4
 
5
- :bookmark: See the [docs](/docs) for more information.
5
+ It either can extends your [Express](http://expressjs.com/) application or even create it for you.
6
+
7
+ Module expose two main methods:
8
+
9
+ - `createApp` to fully create for you Express application
10
+ - `addCrowdinEndpoints` to extend your Express application
11
+
12
+ In both options you will need to provide Crowdin App configuration file. Please refer to jsdoc for more details.
13
+
14
+ `addCrowdinEndpoints` will return an object with several methods to help you add your own custom logic [see example](#resources).
15
+
16
+ - `saveMetadata` to save metadata (may be associated with an organization, project, etc)
17
+ - `getMetadata` to get metadata
18
+ - `deleteMetadata` to delete metadata (usually useful in `onUninstall` hook)
19
+ - `getUserSettings` to get settings that users manage in the integration module
20
+ - `establishCrowdinConnection` method that accept jwt token that you may forward from module UI and it will return back Crowdin client instance and the context.
21
+
22
+ ## Table of Contents
23
+
24
+ - [Installation](#installation)
25
+ - [Sample Project Integration App](#sample-project-integration-app)
26
+ - [Payment](#payment)
27
+ - [Authorization](#authorization)
28
+ - [Custom login form](#customize-your-app-login-form)
29
+ - [OAuth2 login](#oauth2-support)
30
+ - [Storage](#storage)
31
+ - [SQLite](#sqlite)
32
+ - [PostgreSQL](#postgresql)
33
+ - [MySQL](#mysql)
34
+ - [Settings window](#settings-window)
35
+ - [Info window](#info-window)
36
+ - [Background tasks](#background-tasks)
37
+ - [Errors handling](#errors-handling)
38
+ - [Error propagation](#error-propagation)
39
+ - [Error interceptor](#error-interceptor)
40
+ - [Debug mode](#debug-mode)
41
+ - [Modules](#modules)
42
+ - [Custom File Format](#custom-file-format)
43
+ - [Custom MT](#custom-mt)
44
+ - [Profile Resources Menu](#profile-resources-menu)
45
+ - [Other modules](#other-modules)
46
+ - [Other options](#other-options)
47
+ - [Contributing](#contributing)
48
+ - [Seeking Assistance](#seeking-assistance)
49
+ - [License](#license)
50
+
51
+ ## Installation
52
+
53
+ ### npm
54
+
55
+ `npm i @crowdin/app-project-module`
56
+
57
+ ### yarn
58
+
59
+ `yarn add @crowdin/app-project-module`
60
+
61
+ ## Sample Project Integration App
62
+
63
+ ```javascript
64
+ const crowdinModule = require('@crowdin/app-project-module');
65
+ const crowdinAppFunctions = require('@crowdin/crowdin-apps-functions');
66
+ const axios = require('axios').default;
67
+
68
+ const configuration = {
69
+ baseUrl: 'https://123.ngrok.io',
70
+ clientId: 'clientId',
71
+ clientSecret: 'clientSecret',
72
+ scopes: [
73
+ crowdinModule.Scope.PROJECTS,
74
+ crowdinModule.Scope.TRANSLATION_MEMORIES
75
+ ],
76
+ name: 'Sample App',
77
+ identifier: 'sample-app',
78
+ description: 'Sample App description',
79
+ dbFolder: __dirname,
80
+ imagePath: __dirname + '/' + 'logo.png',
81
+ crowdinUrls: { // custom urls to override
82
+ // apiUrl: 'https://<copy_name>.crowdin.dev/api/v2', // 'https://<org_name>.<copy_name>.crowdin.dev/api/v2' for enterprise
83
+ // accountUrl: 'https://accounts.<copy_name>.crowdin.dev/oauth/token', // (default https://accounts.crowdin.com/oauth/token)
84
+ // subscriptionUrl: 'https://<copy_name>.crowdin.dev' // (default https://crowdin.com or https://org.api.crowdin.com)
85
+ },
86
+ projectIntegration: {
87
+ withRootFolder: true,
88
+ withCronSync: {
89
+ crowdin: true,
90
+ integration: true,
91
+ },
92
+ webhooks: { //enable webhook
93
+ crowdinWebhookUrl: '/notify', // override crowdin webhook endpoint
94
+ integrationWebhookUrl: '/update-entity', // override integration webhook endpoint
95
+ urlParam: 'information', // override request param name
96
+ crowdinWebhooks: async (client, projectId, available, appSettings) => { // optional
97
+ // here you need to create crowdin webhooks if the default doesn't work for you
98
+ // used with crowdinWebhookInterceptor
99
+ await client.webhooksApi.addWebhook(projectId, {
100
+ 'Application webhook',
101
+ url: 'https://123.ngrok.io/notify',
102
+ events: ['file.translated'],
103
+ requestType: 'POST',
104
+ });
105
+ },
106
+ crowdinWebhookInterceptor: async (projectId, client, appRootFolder, appSettings, syncSettings) => { // optional
107
+ // used with crowdinWebhooks
108
+ return {
109
+ '101': ['de', 'fr'],
110
+ '103': ['de'],
111
+ };
112
+ }
113
+ integrationWebhooks: async (credentials, urlParam, available, appSettings, syncSettings) => {
114
+ // here you need to create webhooks on the integration side
115
+ // used with integrationWebhookInterceptor or RabbitMQ queue
116
+ await axios.post('https://site.com/api/webhooks');
117
+ },
118
+ integrationWebhookInterceptor: async (projectId, client, rootFolder, appSettings, syncSettings, webhookRequest) => {
119
+ // used with integrationWebhooks
120
+ return [
121
+ {
122
+ id: '47282999980',
123
+ name: 'Home page',
124
+ parentId: '1',
125
+ type: 'json',
126
+ },
127
+ {
128
+ id: '73291251883',
129
+ name: 'Intro',
130
+ parentId: '2',
131
+ type: 'json',
132
+ },
133
+ ];
134
+ },
135
+ queueUrl: 'amqp://localhost:5672', // RabbitMQ url to listen to the webhook queue,
136
+ },
137
+ integrationPagination: true, //enable pagination on integration files list
138
+ integrationOneLevelFetching: true, //turn on request when opening a directory and pass its id
139
+ integrationSearchListener: true, //turn on search listener and pass search string
140
+ uploadTranslations: true, //enable the option to download translations from integration.
141
+ getIntegrationFiles: async (credentials, appSettings, parentId, search, page) => {
142
+ //here you need to fetch files/objects from integration
143
+ const res = [
144
+ {
145
+ id: '12',
146
+ name: 'File from integration',
147
+ type: 'json',
148
+ parentId: '10'
149
+ },
150
+ {
151
+ id: '14',
152
+ name: 'File from integration2',
153
+ type: 'xml',
154
+ parentId: '11'
155
+ },
156
+ {
157
+ id: '10',
158
+ name: 'Folder from integration'
159
+ },
160
+ {
161
+ id: '11',
162
+ name: 'Folder from integratio2'
163
+ customContent: `<div style='color: red;'>Custom Folder name</div>`,
164
+ parentId: '1'
165
+ },
166
+ {
167
+ id: '1',
168
+ name: 'Branch from integratio',
169
+ nodeType: '2'
170
+ },
171
+ ];
172
+ return {
173
+ data: res,
174
+ message: 'Test',
175
+ stopPagination: true, //send if no next files page
176
+ };
177
+ },
178
+ getFileProgress: async (projectId, client, fileId) => { //Use this function if you require a customized translation progress.
179
+ return {
180
+ [fileId]: [
181
+ {
182
+ languageId: 'uk',
183
+ eTag: '49a76a1632973b00175dac942976f7c6',
184
+ words: {
185
+ total: 180,
186
+ translated: 0,
187
+ approved: 0
188
+ },
189
+ phrases: {
190
+ total: 6,
191
+ translated: 0,
192
+ approved: 0
193
+ },
194
+ translationProgress: 100,
195
+ approvalProgress: 0
196
+ }
197
+ ]
198
+ }
199
+ },
200
+ updateCrowdin: async (projectId, client, credentials, request, rootFolder, appSettings, uploadTranslations) => {
201
+ //here you need to get data from integration and upload it to Crowdin
202
+ console.log(`Request for updating data in Crowdin ${JSON.stringify(request)}`);
203
+ const directories = await client.sourceFilesApi
204
+ .withFetchAll()
205
+ .listProjectDirectories(projectId);
206
+ const { folder, files } = await crowdinAppFunctions.getOrCreateFolder({
207
+ directories: directories.data.map((d) => d.data),
208
+ client,
209
+ projectId,
210
+ directoryName: 'Folder from integration',
211
+ parentDirectory: rootFolder
212
+ });
213
+ const fileContent = {
214
+ title: 'Hello World',
215
+ };
216
+ const fileName = 'integration.json';
217
+ const fileId = await crowdinAppFunctions.updateOrCreateFile({
218
+ client,
219
+ projectId,
220
+ name: fileName,
221
+ title: 'Sample file from integration',
222
+ type: 'json',
223
+ directoryId: folder.id,
224
+ data: fileContent,
225
+ file: files.find((f) => f.name === 'integration.json'),
226
+ });
227
+
228
+ if (uploadTranslations) {
229
+ const fileTranslation = {
230
+ title: 'Hola Mundo',
231
+ };
232
+ await crowdinAppFunctions.uploadTranslations(
233
+ client,
234
+ projectId,
235
+ fileId,
236
+ 'es',
237
+ fileName,
238
+ fileTranslation,
239
+ );
240
+ }
241
+
242
+ return {
243
+ message: 'Some message',
244
+ };
245
+ },
246
+ updateIntegration: async (projectId, client, credentials, request, rootFolder, appSettings) => {
247
+ //here should be logic to get translations from Crowdin and upload them to integration
248
+ console.log(`Request for updating data in Integration ${JSON.stringify(request)}`);
249
+ const directories = await client.sourceFilesApi
250
+ .withFetchAll()
251
+ .listProjectDirectories(projectId);
252
+ const { files } = await crowdinAppFunctions.getFolder({
253
+ directories: directories.data.map((d) => d.data),
254
+ client,
255
+ projectId,
256
+ directoryName: 'Folder from integration',
257
+ parentDirectory: rootFolder
258
+ });
259
+ const file = files.find((f) => f.name === 'integration.json');
260
+ if (file) {
261
+ const translationsLink =
262
+ await client.translationsApi.buildProjectFileTranslation(
263
+ projectId,
264
+ file.id,
265
+ { targetLanguageId: 'uk' },
266
+ );
267
+ if (!translationsLink) {
268
+ return;
269
+ }
270
+ const response = await axios.get(translationsLink.data.url);
271
+ console.log(response.data);
272
+ }
273
+ },
274
+ onLogout: async (projectId, client, credentials, appSettings) => {
275
+ //cleanup logic
276
+ }
277
+ }
278
+ };
279
+
280
+ crowdinModule.createApp(configuration);
281
+ ```
282
+
283
+ ## Payment
284
+
285
+ By default App does not have any subscription and it's free to use. But you can override this.
286
+
287
+ ```javascript
288
+ configuration.pricing = {
289
+ plantType: 'recurring',
290
+ trial: 14, //amount of days to use app for free
291
+ trialCrowdin: 14, //amount of days specifically in crowdin workspace
292
+ trialEnterprise: 30, //amount of days specifically for enterprise
293
+ cachingSeconds: 12 * 60 * 60, //time in seconds of how long to cache subscription info
294
+ infoDisplayDaysThreshold: 14 //number of days threshold to check if subscription info should be displayed (if not defined then info will be always visible)
295
+ };
296
+ ```
297
+
298
+ ## Authorization
299
+
300
+ ### Customize your app login form
301
+
302
+ By default, login page for your app will require only to enter `apiToken` to communicate with third party service.
303
+ But there is also a possibility to customize it.
304
+
305
+ ```javascript
306
+ configuration.projectIntegration.loginForm = {
307
+ fields: [
308
+ {
309
+ key: 'username',
310
+ label: 'Username',
311
+ },
312
+ {
313
+ key: 'password',
314
+ label: 'Password',
315
+ type: 'password'
316
+ },
317
+ {
318
+ label: 'Api creds',
319
+ },
320
+ {
321
+ helpText: 'Api Key for http requests',
322
+ key: 'apiKey',
323
+ label: 'Api Key'
324
+ },
325
+ {
326
+ key: 'server',
327
+ label: 'Data center',
328
+ type: 'select',
329
+ defaultValue: '1',
330
+ options: [
331
+ {
332
+ value: '1',
333
+ label: 'USA'
334
+ },
335
+ {
336
+ value: '2',
337
+ label: 'EU'
338
+ }
339
+ ]
340
+ },
341
+ {
342
+ key: 'apiKey',
343
+ label: 'Api key',
344
+ type: 'textarea'
345
+ },
346
+ {
347
+ key: 'fileSecret',
348
+ label: 'Upload file',
349
+ type: 'file',
350
+ accept: '.txt'
351
+ },
352
+ ]
353
+ };
354
+ ```
355
+
356
+ ### OAuth2 support
357
+
358
+ In case if third party service uses OAuth2 for authorization use `oauthLogin` field to configure it.
359
+ `loginForm` **in this case should remain undefined**.
360
+
361
+ Github example:
362
+
363
+ ```javascript
364
+ configuration.projectIntegration.oauthLogin = {
365
+ authorizationUrl: 'https://github.com/login/oauth/authorize',
366
+ clientId: 'github_app_client_id',
367
+ clientSecret: 'github_app_client_secret',
368
+ accessTokenUrl: 'https://github.com/login/oauth/access_token'
369
+ }
370
+ ```
371
+
372
+ Google example:
373
+
374
+ ```javascript
375
+ configuration.projectIntegration.oauthLogin = {
376
+ scope: 'https%3A//www.googleapis.com/auth/userinfo.email',
377
+ authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
378
+ clientId: 'google_web_app_client_id',
379
+ clientSecret: 'google_web_app_client_secret',
380
+ accessTokenUrl: 'https://oauth2.googleapis.com/token',
381
+ extraAutorizationUrlParameters: {
382
+ response_type: 'code',
383
+ access_type: 'offline',
384
+ prompt: 'consent'
385
+ },
386
+ extraAccessTokenParameters: {
387
+ grant_type: 'authorization_code'
388
+ },
389
+ extraRefreshTokenParameters: {
390
+ grant_type: 'refresh_token'
391
+ },
392
+ refresh: true
393
+ }
394
+ ```
395
+
396
+ The `oauthLogin` property allows you to customize many different properties, mappings, etc. So that you can integrate with any OAuth2 implementation.
397
+ Main default values:
398
+
399
+ - redirect uri prefix will be `/oauth/code`
400
+ - client id field name in url parameters and in request payload will be `client_id`
401
+ - client secret field name in request payload will be `client_secret`
402
+ - access token field name should be `access_token`
403
+ - be default assumption is that token do not have any expiration date, to change this behavior use `refresh` flag so then refresh token and expires in will be taken into consideration
404
+ - access token field name should be `refresh_token`
405
+ - expires in field name should be `expires_in` (value should be in seconds)
406
+
407
+ This module rely that OAuth2 protocol is implemented by third party service in this way:
408
+
409
+ - request for access token should be done via POST request to `accessTokenUrl` with JSON body that will contain at least `clientId`, `clientSecret`, `code` and `redirectUri` (also possible to add extra fields via `extraAccessTokenParameters` property)
410
+ - request to refresh token should be done via POST request to `accessTokenUrl` (or `refreshTokenUrl` if defined) with JSON body that will contain at least `clientId`, `clientSecret` and `refreshToken` (also possible to add extra fields via `extraRefreshTokenParameters` property)
411
+ - both requests will return JSON response with body that contains `accessToken` and, if enabled, `refreshToken` (optional) and `expireIn`
412
+
413
+ To override those requests please use `performGetTokenRequest` and `performRefreshTokenRequest` (e.g. when requests should be done with different HTTP methods or data should be tranfered as query string or form data).
414
+
415
+ Mailup example:
416
+
417
+ ```javascript
418
+ const clientId = 'client_id';
419
+ const clientSecret = 'client_secret';
420
+ const tokenUrl = 'https://services.mailup.com/Authorization/OAuth/Token';
421
+
422
+ configuration.projectIntegration.oauthLogin = {
423
+ authorizationUrl: 'https://services.mailup.com/Authorization/OAuth/LogOn',
424
+ clientId,
425
+ clientSecret,
426
+ extraAutorizationUrlParameters: {
427
+ response_type: 'code'
428
+ },
429
+ refresh: true,
430
+ performGetTokenRequest: async (code, query, url) => {
431
+ //query is an object with all query params
432
+ //url is an url string that OAuth server used to call us back
433
+ const url = `${tokenUrl}?code=${code}&grant_type=authorization_code`;
434
+ const headers = {
435
+ 'Authorization': `Bearer ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`
436
+ };
437
+ return (await axios.get(url, { headers })).data;
438
+ },
439
+ performRefreshTokenRequest: async (credentials) => {
440
+ const params = {
441
+ refresh_token: credentials.refreshToken,
442
+ grant_type: 'refresh_token',
443
+ client_id: clientId,
444
+ client_secret: clientSecret
445
+ };
446
+ const data = Object.keys(params)
447
+ .map((key) => `${key}=${encodeURIComponent(params[key])}`)
448
+ .join('&');
449
+ const headers = {
450
+ 'Content-Type': 'application/x-www-form-urlencoded'
451
+ };
452
+ return (await axios.post(tokenUrl, data, { headers })).data;
453
+ }
454
+ }
455
+ ```
456
+
457
+ In addition you can specify extra fields on login screen that you can use to dynamically build authorization url.
458
+
459
+ ```javascript
460
+ configuration.projectIntegration.oauthLogin.loginFields = [
461
+ {
462
+ key: 'region',
463
+ label: 'Region',
464
+ type: 'select',
465
+ defaultValue: 'NA',
466
+ options: [
467
+ {
468
+ value: 'NA',
469
+ label: 'North American'
470
+ },
471
+ {
472
+ value: 'EU',
473
+ label: 'Europe'
474
+ },
475
+ {
476
+ value: 'AZURE_NA',
477
+ label: 'Azure North American'
478
+ }
479
+ ]
480
+ }
481
+ ];
482
+
483
+ configuration.projectIntegration.oauthLogin.getAuthorizationUrl = (redirectUrl, loginForm) => {
484
+ const region = loginForm.region;
485
+ if (region === 'EU') {
486
+ return `https://eu-region.com?client_id=<client-id>&redirect_uri=${redirectUrl}`;
487
+ } else {
488
+ return `https://default-region.com?client_id=<client-id>&redirect_uri=${redirectUrl}`;
489
+ }
490
+ };
491
+ ```
492
+
493
+ Please refer to jsdoc for more details.
494
+
495
+ ## Storage
496
+
497
+ Module can be configured to use following storages:
498
+
499
+ ### SQLite
500
+
501
+ ```javascript
502
+ //specify folder where sqlite db file will be located
503
+ configuration.dbFolder = __dirname;
504
+ ```
505
+
506
+ ### PostgreSQL
507
+
508
+ ```javascript
509
+ configuration.postgreConfig = {
510
+ host: 'localhost',
511
+ user: 'postgres',
512
+ password: 'password',
513
+ database: 'test'
514
+ };
515
+ ```
516
+
517
+ ### MySQL
518
+
519
+ ```javascript
520
+ configuration.mysqlConfig = {
521
+ host: 'localhost',
522
+ user: 'root',
523
+ password: 'password',
524
+ database: 'test'
525
+ };
526
+ ```
527
+
528
+ ## Settings window
529
+
530
+ It is also possible to define settings window for your app where users can customize integration flow.
531
+
532
+ ```javascript
533
+ configuration.projectIntegration.getConfiguration = (projectId, crowdinClient, integrationCredentials) => {
534
+ return [
535
+ {
536
+ label: 'GENERAL'
537
+ },
538
+ {
539
+ key: 'flag',
540
+ label: 'Checkbox',
541
+ type: 'checkbox'
542
+ },
543
+ {
544
+ key: 'text',
545
+ label: 'Text input',
546
+ type: 'text',
547
+ helpText: 'Help text'
548
+ },
549
+ {
550
+ key: 'option',
551
+ label: 'Select',
552
+ type: 'select',
553
+ defaultValue: '12',
554
+ isSearchable: true, //allow to search for option(s), default is `false`
555
+ isMulti: true, //allow to select multiple options, default is `false`
556
+ options: [
557
+ {
558
+ value: '12',
559
+ label: 'Option'
560
+ }
561
+ ]
562
+ }
563
+ ]
564
+ }
565
+
566
+ //auto reload on settings updates
567
+ configuration.projectIntegration.reloadOnConfigSave = true;
568
+ ```
569
+
570
+ ## Info window
571
+
572
+ You also can define section with some information notes or help section for your app.
573
+
574
+ ```javascript
575
+ configuration.projectIntegration.infoModal = {
576
+ title: 'Info',
577
+ content: `
578
+ <h1>This is your app help section</h1>
579
+ </br>
580
+ <h2>This is just an example</h2>
581
+ `
582
+ }
583
+ ```
584
+
585
+ ## Background tasks
586
+
587
+ In order to register background tasks that app will invoke periodically invoke you can use `cronJobs` field.
588
+
589
+ ```javascript
590
+ configuration.projectIntegration.cronJobs = [
591
+ {
592
+ //every 10 seconds
593
+ expression: '*/10 * * * * *',
594
+ task: (projectId, client, apiCredentials, appRootFolder, config) => {
595
+ console.log(`Running background task for project : ${projectId}`);
596
+ console.log(`Api credentials : ${JSON.stringify(apiCredentials)}`);
597
+ console.log(`App config : ${JSON.stringify(config)}`);
598
+ console.log(appRootFolder ? JSON.stringify(appRootFolder) : 'No root folder');
599
+ }
600
+ }
601
+ ]
602
+ ```
603
+
604
+ For cron syntax guide please refer to this [documentation](https://github.com/node-cron/node-cron#cron-syntax).
605
+
606
+ ## Errors Handling
607
+
608
+ ### Error propagation
609
+
610
+ In case if something is wrong with app settings or credentials are invalid you can throw an explanation message that will be then visible on the UI side.
611
+ e.g. check if entered credentials are valid:
612
+
613
+ ```javascript
614
+ configuration.projectIntegration.checkConnection = (credentials) => {
615
+ if (!credentials.password || credentials.password.length < 6) {
616
+ throw 'Password is too weak';
617
+ }
618
+ //or call an service API with those credentials and check if request will be successful
619
+ };
620
+ ```
621
+
622
+ Or if you need to manually control users liveness session you can throw an error with `401` code then your app will automatically do a log out action.
623
+ e.g. when your service has some specific session duration timeout or extra conditions which are not covered by this framework
624
+
625
+ ```javascript
626
+ configuration.projectIntegration.getIntegrationFiles = async (credentials, appSettings) => {
627
+ //do a request/custom logic here
628
+ const sessionStillValid = false;
629
+ if (!sessionStillValid) {
630
+ throw {
631
+ message: 'session expired',
632
+ code: 401
633
+ }
634
+ }
635
+ //business logic
636
+ }
637
+ ```
638
+
639
+ ### Error interceptor
640
+
641
+ You can also provide interceptor to catch errors and process them (e.g. to log them in the centralized place).
642
+
643
+ ```javascript
644
+ const Sentry = require('@sentry/node');
645
+
646
+ Sentry.init({
647
+ dsn: "https://examplePublicKey@o0.ingest.sentry.io/0",
648
+ tracesSampleRate: 1.0,
649
+ });
650
+
651
+ configuration.onError = (error) => {
652
+ Sentry.captureException(e);
653
+ };
654
+ ```
655
+
656
+ ### Debug mode
657
+
658
+ Also you can turn on the debug mode and application will log everything (useful of debugging).
659
+
660
+ ```javascript
661
+ configuration.logger = {
662
+ enabled: true
663
+ };
664
+ ```
665
+
666
+ Or even you can pass you own function to log messages (e.g. send them to external monitoring system).
667
+
668
+ ```javascript
669
+ configuration.logger = {
670
+ enabled: true,
671
+ log: (message) => {
672
+ console.log(message);
673
+ }
674
+ };
675
+ ```
676
+
677
+ ## Modules
678
+
679
+ ### Custom File Format
680
+
681
+ Example of [custom file format module](https://support.crowdin.com/crowdin-apps-modules/#custom-file-format-module).
682
+
683
+ ```javascript
684
+ const crowdinModule = require('@crowdin/app-project-module');
685
+ const convert = require('xml-js');
686
+
687
+ const configuration = {
688
+ baseUrl: 'https://123.ngrok.io',
689
+ clientId: 'clientId',
690
+ clientSecret: 'clientSecret',
691
+ name: 'Sample App',
692
+ identifier: 'sample-app',
693
+ description: 'Sample App description',
694
+ dbFolder: __dirname,
695
+ imagePath: __dirname + '/' + 'logo.png',
696
+ customFileFormat: {
697
+ filesFolder: __dirname,
698
+ type: 'type-xyz',
699
+ multilingual: false,
700
+ autoUploadTranslations: true, //useful when single language format
701
+ signaturePatterns: {
702
+ fileName: '^.+\.xml$'
703
+ },
704
+ customSrxSupported: true,
705
+ parseFile: async (file, req, client, context, projectId) => {
706
+ const xml = convert.xml2json(file, { compact: true, spaces: 4 });
707
+ const fileContent = JSON.parse(xml);
708
+ //parse logic
709
+ const strings = [];
710
+ return { strings, error: 'Some error message' };
711
+ },
712
+ buildFile: async (file, req, strings, client, context, projectId) => {
713
+ const xml = convert.xml2json(file, { compact: true, spaces: 4 });
714
+ const fileContent = JSON.parse(xml);
715
+ //build logic
716
+ const contentFile = convert.json2xml(
717
+ fileContent,
718
+ {
719
+ compact: true,
720
+ ignoreComment: false,
721
+ spaces: 4
722
+ }
723
+ );
724
+ return { contentFile }
725
+ }
726
+ }
727
+ };
728
+
729
+ crowdinModule.createApp(configuration);
730
+ ```
731
+
732
+ Also custom file format module can support strings export.
733
+
734
+ ```javascript
735
+ const crowdinModule = require('@crowdin/app-project-module');
736
+ const convert = require('xml-js');
737
+
738
+ const configuration = {
739
+ baseUrl: 'https://123.ngrok.io',
740
+ clientId: 'clientId',
741
+ clientSecret: 'clientSecret',
742
+ name: 'Sample App',
743
+ identifier: 'sample-app',
744
+ description: 'Sample App description',
745
+ dbFolder: __dirname,
746
+ imagePath: __dirname + '/' + 'logo.png',
747
+ customFileFormat: {
748
+ filesFolder: __dirname,
749
+ type: 'type-xyz',
750
+ stringsExport: true,
751
+ multilingualExport: true,
752
+ extensions: [
753
+ '.resx'
754
+ ],
755
+ exportStrings: async (req, strings, client, context, projectId) => {
756
+ const file = req.file;
757
+ //export logic
758
+ return { contentFile: '' }
759
+ }
760
+ }
761
+ };
762
+
763
+ crowdinModule.createApp(configuration);
764
+ ```
765
+
766
+ By default custom file format will use `filesFolder` as a temporary folder to store huge responses. But it can be overridden:
767
+
768
+ ```javascript
769
+ configuration.customFileFormat.storeFile = (content) => {
770
+ //logic to store file, e.g. put it to AWS S3
771
+ return '<url-to-download>';
772
+ };
773
+ ```
774
+
775
+ ### Custom MT
776
+
777
+ Example of [custom mt module](https://support.crowdin.com/crowdin-apps-modules/#custom-mt-machine-translation-module).
778
+
779
+ ```javascript
780
+ const crowdinModule = require('@crowdin/app-project-module');
781
+
782
+ const configuration = {
783
+ baseUrl: 'https://123.ngrok.io',
784
+ clientId: 'clientId',
785
+ clientSecret: 'clientSecret',
786
+ name: 'Sample App',
787
+ identifier: 'sample-app',
788
+ description: 'Sample App description',
789
+ dbFolder: __dirname,
790
+ imagePath: __dirname + '/' + 'logo.png',
791
+ customMT: {
792
+ translate: async (client, context, projectId, source, target, strings) => {
793
+ //translate strings
794
+ const translations = ['hello', 'world'];
795
+ if (source === 'fr') {
796
+ throw 'Source language is not supported by the model';
797
+ }
798
+ return translations;
799
+ }
800
+ }
801
+ };
802
+
803
+ crowdinModule.createApp(configuration);
804
+ ```
805
+
806
+ ### Profile Resources Menu
807
+
808
+ Example of [resources module](https://support.crowdin.com/crowdin-apps-modules/#resources-module).
809
+
810
+ ```javascript
811
+ const crowdinModule = require('@crowdin/app-project-module');
812
+
813
+ const configuration = {
814
+ baseUrl: 'https://123.ngrok.io',
815
+ clientId: 'clientId',
816
+ clientSecret: 'clientSecret',
817
+ name: 'Sample App',
818
+ identifier: 'sample-app',
819
+ description: 'Sample App description',
820
+ dbFolder: __dirname,
821
+ imagePath: __dirname + '/' + 'logo.png',
822
+ profileResourcesMenu: {
823
+ fileName: 'setup.html',
824
+ uiPath: __dirname + '/' + 'public',
825
+ environments: 'crowdin-enterprise',
826
+ },
827
+ };
828
+
829
+ crowdinModule.createApp(configuration);
830
+ ```
831
+
832
+ The `profileResourcesMenu` module can work as an extension to other modules be something like a configuration UI for them.
833
+
834
+ ```javascript
835
+ const crowdinModule = require('@crowdin/app-project-module');
836
+ const express = require('express');
837
+
838
+ const app = express();
839
+
840
+ const configuration = {
841
+ baseUrl: 'https://123.ngrok.io',
842
+ clientId: 'clientId',
843
+ clientSecret: 'clientSecret',
844
+ name: 'Sample App',
845
+ identifier: 'sample-app',
846
+ description: 'Sample App description',
847
+ dbFolder: __dirname,
848
+ imagePath: __dirname + '/' + 'logo.png',
849
+ profileResourcesMenu: {
850
+ fileName: 'setup.html',
851
+ uiPath: __dirname + '/' + 'public',
852
+ environments: 'crowdin',
853
+ },
854
+ customMT: {
855
+ translate,
856
+ },
857
+ onUninstall: cleanup
858
+ };
859
+
860
+ const crowdinApp = crowdinModule.addCrowdinEndpoints(app, configuration);
861
+
862
+ async function cleanup(organization, allCredentials) {
863
+ //Cleanup logic
864
+ await crowdinApp.deleteMetadata(organization);
865
+ }
866
+
867
+ async function translate(crowdinClient, context, projectId, source, target, strings) {
868
+ const organization = context.jwtPayload.domain || context.jwtPayload.context.organization_id;
869
+ const metadata = await crowdinApp.getMetadata(organization);
870
+ //do translation based on metadata
871
+ const translations = ['hello', 'world'];
872
+ return translations;
873
+ }
874
+
875
+ //extra endpoints for resources UI
876
+ app.post('/metadata', async (req, res) => {
877
+ const { context } = await crowdinApp.establishCrowdinConnection(req.query.jwt);
878
+ const organization = context.jwtPayload.domain || context.jwtPayload.context.organization_id;
879
+ const metadata = await crowdinApp.getMetadata(organization);
880
+ await crowdinApp.saveMetadata(organization, req.body);
881
+ res.status(204).end();
882
+ });
883
+
884
+ app.get('/metadata', async (req, res) => {
885
+ const { context } = await crowdinApp.establishCrowdinConnection(req.query.jwt);
886
+ const organization = context.jwtPayload.domain || context.jwtPayload.context.organization_id;
887
+ const metadata = await crowdinApp.getMetadata(organization) || {};
888
+ res.status(200).send(metadata);
889
+ });
890
+
891
+ app.listen(3000, () => console.log('Crowdin app started'));
892
+ ```
893
+
894
+ ### Other modules
895
+
896
+ Framework also supports following modules:
897
+
898
+ - [organization-menu module](https://support.crowdin.com/enterprise/crowdin-apps-modules/#organization-menu-module)
899
+ - [editor-right-panel module](https://support.crowdin.com/crowdin-apps-modules/#editor-panels-module)
900
+ - [project-menu module](https://support.crowdin.com/crowdin-apps-modules/#project-menu-module)
901
+ - [project-menu-crowdsource module](https://developer.crowdin.com/crowdin-apps-module-project-menu-crowdsource/)
902
+ - [project-tools module](https://support.crowdin.com/crowdin-apps-modules/#tools-module)
903
+ - [project-reports module](https://support.crowdin.com/crowdin-apps-modules/#reports-module)
904
+
905
+ Example of Project Reports Module:
906
+
907
+ ```javascript
908
+ const crowdinModule = require('@crowdin/app-project-module');
909
+
910
+ const configuration = {
911
+ baseUrl: 'https://123.ngrok.io',
912
+ clientId: 'clientId',
913
+ clientSecret: 'clientSecret',
914
+ name: 'Sample App',
915
+ identifier: 'sample-app',
916
+ description: 'Sample App description',
917
+ dbFolder: __dirname,
918
+ imagePath: __dirname + '/' + 'logo.png',
919
+ projectReports: { //can be editorRightPanel, projectMenu, projectTools, projectMenuCrowdsource
920
+ imagePath: __dirname + '/' + 'reports.png',
921
+ fileName: 'reports.html', //optional, only needed if file is not index.html
922
+ uiPath: __dirname + '/' + 'public', // folder where UI of the module is located (js, html, css files)
923
+ allowUnauthorized: true //make module publicly available without crowdin context
924
+ },
925
+ };
926
+
927
+ crowdinModule.createApp(configuration);
928
+ ```
929
+
930
+ ## Other options
931
+
932
+ ### Options for Crowdin JWT token validation
933
+
934
+ ```js
935
+ configuration.jwtValidationOptions = {
936
+ ignoreExpiration: false, //ignore check if jwt is expired or not
937
+ };
938
+ ```
939
+
940
+ ### App authentication type
941
+
942
+ ```js
943
+ configuration.authenticationType = 'authorization_code'; //default is "crowdin_app"
944
+ ```
945
+
946
+ ### Disable global error handling
947
+
948
+ This module will handle all `unhandledRejection` and `uncaughtException` errors, log them and not kill the Node process.
949
+ Usually this means that code was not properly designed and contains unsafe places. And not always this built in behaviour will be suitable.
950
+ Therefore you can disable it:
951
+
952
+ ```js
953
+ configuration.disableGlobalErrorHandling = true;
954
+ ```
955
+
956
+ ### Use React JSON Schema forms as module front-end
957
+
958
+ For all modules that have UI (except of `projectIntegration` module) you can use React JSON Schema forms ([docs](/https://github.com/rjsf-team/react-jsonschema-form)) as front-end.
959
+ To achieve this you can specify property `formSchema` in module definition.
960
+ ```js
961
+ configuration.projectMenu = {
962
+ formSchema: {
963
+ "title": "A registration form",
964
+ "description": "A simple form example.",
965
+ "type": "object",
966
+ "required": [
967
+ "firstName",
968
+ "lastName"
969
+ ],
970
+ "properties": {
971
+ "firstName": {
972
+ "type": "string",
973
+ "title": "First name",
974
+ "default": "Chuck"
975
+ },
976
+ "lastName": {
977
+ "type": "string",
978
+ "title": "Last name"
979
+ },
980
+ }
981
+ }
982
+ };
983
+ ```
984
+ By default, form data will be stored in app metadata. To retrieve data you can use this code:
985
+ ```js
986
+ crowdinApp.getMetadata(`${organizationId}-${projectId}`);
987
+ ```
988
+ To customize this behavior you can provide `formDataUrl` property that will contain URL to custom action in your app. This action should support both GET request for fetching data and POST request to save new data.
6
989
 
7
990
  ## Contributing
8
991