@crowdin/app-project-module
Version:
Module that generates for you all common endpoints for serving standalone Crowdin App
594 lines (591 loc) • 28.1 kB
JavaScript
;
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getRootFolder = getRootFolder;
exports.getOauthRoute = getOauthRoute;
exports.applyIntegrationModuleDefaults = applyIntegrationModuleDefaults;
exports.constructOauthUrl = constructOauthUrl;
exports.getOAuthPollingId = getOAuthPollingId;
exports.getOAuthLoginFormId = getOAuthLoginFormId;
exports.groupFieldsByCategory = groupFieldsByCategory;
exports.buildMenuItems = buildMenuItems;
const crowdin_1 = require("../../../util/app-functions/crowdin");
const types_1 = require("../types");
function getRootFolder(config, integration, client, projectId) {
return __awaiter(this, void 0, void 0, function* () {
if (!integration.withRootFolder) {
return;
}
const folder = integration.appFolderName || config.name;
const directories = (yield client.sourceFilesApi.withFetchAll().listProjectDirectories(projectId)).data.map((d) => d.data);
const { folder: rootFolder } = yield (0, crowdin_1.getOrCreateFolder)({
directories,
client,
projectId,
directoryName: folder,
});
return rootFolder;
});
}
function getOauthRoute(integration) {
var _a;
return ((_a = integration.oauthLogin) === null || _a === void 0 ? void 0 : _a.redirectUriRoute) || '/oauth/code';
}
function applyIntegrationModuleDefaults(config, integration) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
if (!integration.getCrowdinFiles) {
integration.getCrowdinFiles = (_a) => __awaiter(this, [_a], void 0, function* ({ projectId, client, rootFolder, mode }) {
let options = {};
if (rootFolder) {
options = {
directoryId: rootFolder.id,
recursion: 'true',
};
}
const needDirectories = !mode || mode === 'directories' || mode === 'files';
const needFiles = !mode || mode === 'files';
const [branchesResponse, directoriesResponse, filesResponse] = yield Promise.all([
client.sourceFilesApi.withFetchAll().listProjectBranches(projectId),
needDirectories
? client.sourceFilesApi.withFetchAll().listProjectDirectories(projectId, options)
: { data: [] },
needFiles ? client.sourceFilesApi.withFetchAll().listProjectFiles(projectId, options) : { data: [] },
]);
const allBranches = branchesResponse.data.map((d) => d.data);
const allDirectories = directoriesResponse.data.map((d) => d.data);
const allFiles = filesResponse.data.map((d) => d.data);
const branchesMap = new Map(allBranches.map((b) => [b.id, b]));
const addedBranchIds = new Set();
const res = [];
const addBranchIfNeeded = (branchId) => {
const branch = branchesMap.get(branchId);
if (branch && !addedBranchIds.has(branch.id)) {
addedBranchIds.add(branch.id);
res.push({
id: branch.id.toString(),
name: branch.name,
nodeType: '2',
});
}
return branch === null || branch === void 0 ? void 0 : branch.id;
};
if (!mode || mode === 'directories') {
allDirectories.forEach((e) => {
let parentId = rootFolder && e.directoryId === rootFolder.id ? undefined : e.directoryId;
if (!parentId && e.branchId) {
parentId = addBranchIfNeeded(e.branchId);
}
res.push({
id: e.id.toString(),
parentId: parentId ? parentId.toString() : undefined,
name: e.name,
});
});
}
if (!mode || mode === 'files') {
const directoryIds = mode === 'files'
? allDirectories.map((d) => d.id)
: res.filter((item) => !item.type && item.nodeType !== '2').map((d) => parseInt(d.id));
const filteredFiles = allFiles.filter((f) => (rootFolder && f.directoryId === rootFolder.id) ||
directoryIds.includes(f.directoryId) ||
(!rootFolder && !f.directoryId));
filteredFiles.forEach((e) => {
let parentId = rootFolder && e.directoryId === rootFolder.id ? undefined : e.directoryId;
if (!parentId && e.branchId) {
parentId = addBranchIfNeeded(e.branchId);
}
res.push({
id: e.id.toString(),
parentId: parentId ? parentId.toString() : undefined,
name: e.title || e.name,
type: e.type,
excludedTargetLanguages: e.excludedTargetLanguages,
});
});
}
return res;
});
}
if (!integration.getFileProgress) {
integration.getFileProgress = (_a) => __awaiter(this, [_a], void 0, function* ({ projectId, client, fileId }) {
const progress = yield client.translationStatusApi.withFetchAll().getFileProgress(projectId, fileId);
return { [fileId]: progress.data.map((e) => e.data) };
});
}
if (!integration.updateFilesTargetLanguages) {
integration.updateFilesTargetLanguages = (_a) => __awaiter(this, [_a], void 0, function* ({ projectId, client, fileIds, excludedTargetLanguages }) {
for (const fileId of fileIds) {
yield client.sourceFilesApi.editFile(projectId, fileId, [
{
op: 'replace',
path: '/excludedTargetLanguages',
value: excludedTargetLanguages,
},
]);
}
});
}
if (!integration.oauthLogin && !integration.loginForm) {
integration.loginForm = {
fields: [
{
helpText: 'You need to create standard api key',
key: 'apikey',
label: `${config.name} API Key`,
},
],
};
}
if ((integration.filterByPathIntegrationFiles === undefined || integration.filterByPathIntegrationFiles) &&
!integration.integrationOneLevelFetching) {
integration.filterByPathIntegrationFiles = true;
}
const getUserSettings = integration.getConfiguration;
integration.getConfiguration = (_a) => __awaiter(this, [_a], void 0, function* ({ projectId, client: crowdinClient, credentials, settings, clientId, }) {
var _b, _c, _d;
let fields = [];
const project = (yield crowdinClient.projectsGroupsApi.getProject(projectId));
if (getUserSettings) {
fields = yield getUserSettings({
projectId,
client: crowdinClient,
credentials,
settings,
clientId,
});
}
const defaultSettings = [];
if (project.data.inContext) {
defaultSettings.push({
key: 'inContext',
label: 'Sync In-Context Pseudo Language',
type: 'checkbox',
defaultValue: 'false',
category: types_1.DefaultCategory.ADVANCED,
position: 3,
helpTextHtml: `
<p><strong>What is In-Context?</strong></p>
<p>Crowdin In-Context allows translators to see and edit translations directly on your live website or application.</p>
<p><strong>Enable this setting if:</strong></p>
<ul style="margin: 8px 0; padding-left: 20px;">
<li>You have integrated the Crowdin In-Context script in your website/app</li>
<li>You want to sync the pseudo-language for in-context translation testing</li>
</ul>
<p style="margin-top: 8px;">
<a href="https://support.crowdin.com/in-context-localization/" target="_blank" rel="noopener noreferrer">
Learn more about In-Context →
</a>
</p>
`,
});
}
if (integration.withCronSync || integration.webhooks) {
defaultSettings.push({
key: 'auto-sync-info',
type: 'notice',
label: 'How Auto Sync works',
helpTextHtml: `
<p><strong>Auto Sync is disabled by default.</strong> Here's how to enable it:</p>
<ol style="margin: 8px 0 0 0; padding-left: 20px;">
<li><strong>Choose a schedule</strong> below (how often to sync)</li>
<li><strong>Select files:</strong> In the file browser, right-click on files or folders</li>
<li>Click <strong>"Enable Auto Sync"</strong> from the context menu</li>
</ol>
<p style="margin: 8px 0 0 0;">
💡 Only files/folders with Auto Sync enabled will be synchronized automatically.
</p>
`,
category: types_1.DefaultCategory.SYNC,
position: -1,
});
const userSchedule = fields.find((field) => 'key' in field && field.key === 'schedule');
if (userSchedule) {
userSchedule.position = (_b = userSchedule.position) !== null && _b !== void 0 ? _b : 0;
userSchedule.category = types_1.DefaultCategory.SYNC;
}
else {
defaultSettings.push({
key: 'schedule',
label: 'Sync schedule',
helpText: `Defines how often content is synced between ${config.name} and Crowdin. Make sure Auto Sync is enabled for selected directories and files in the dual pane view.`,
type: 'select',
defaultValue: '0',
category: types_1.DefaultCategory.SYNC,
position: 0,
options: [
{
value: '0',
label: 'Disabled',
},
{
value: '1',
label: '1 hour',
},
{
value: '3',
label: '3 hours',
},
{
value: '6',
label: '6 hours',
},
{
value: '12',
label: '12 hours',
},
{
value: '24',
label: '24 hours',
},
],
});
}
if ((_c = integration.syncNewElements) === null || _c === void 0 ? void 0 : _c.crowdin) {
defaultSettings.push({
key: 'new-crowdin-files',
label: 'Automatically sync new translations from Crowdin',
type: 'checkbox',
helpText: 'Automatically include newly added Crowdin files and folders in Auto Sync.',
dependencySettings: JSON.stringify([{ '#schedule-settings': { type: '!equal', value: ['0'] } }]),
category: types_1.DefaultCategory.SYNC,
position: 1,
});
}
if ((_d = integration.syncNewElements) === null || _d === void 0 ? void 0 : _d.integration) {
defaultSettings.push({
key: 'new-integration-files',
label: `Automatically sync new content from ${config.name}`,
type: 'checkbox',
helpText: `Automatically include new ${config.name} files and folders in Auto Sync as soon as they appear.`,
dependencySettings: JSON.stringify([{ '#schedule-settings': { type: '!equal', value: ['0'] } }]),
category: types_1.DefaultCategory.SYNC,
position: 2,
});
}
if (integration.uploadTranslations) {
defaultSettings.push({
labelHtml: `<b>Translation sync settings (${config.name} → Crowdin)</b>`,
category: types_1.DefaultCategory.SYNC,
position: 3,
}, {
key: 'importEqSuggestions',
label: 'Add translations that are the same as the source text',
type: 'checkbox',
helpText: 'Upload translations that match the source text. Useful when the source and target are intentionally identical, for example brand names, numbers, or code snippets.',
category: types_1.DefaultCategory.SYNC,
position: 4,
}, {
key: 'autoApproveImported',
label: 'Mark added translations as Approved in Crowdin',
type: 'checkbox',
helpText: 'Mark uploaded translations as Approved in Crowdin, bypassing proofreading. Leave off if your workflow requires a reviewer to approve translations manually.',
category: types_1.DefaultCategory.SYNC,
position: 5,
}, {
key: 'translateHidden',
label: 'Add translations for hidden source strings in Crowdin',
type: 'checkbox',
helpText: 'Also upload translations for source strings marked as hidden in Crowdin. Hidden strings are excluded from the translator workflow but can still receive translations from your integration.',
category: types_1.DefaultCategory.SYNC,
position: 6,
});
}
defaultSettings.push({
key: 'condition',
label: 'Files export settings',
type: 'select',
defaultValue: '0',
helpText: 'Decides which Crowdin files are exported on each sync. "Export all" includes every file; "Export translated only" skips files with no translations; "Export approved only" requires translations to be approved by a proofreader.',
dependencySettings: JSON.stringify([{ '#schedule-settings': { type: '!equal', value: ['0'] } }]),
category: types_1.DefaultCategory.SYNC,
position: 7,
options: [
{
value: '0',
label: 'Export all',
},
{
value: '1',
label: 'Export translated only',
},
{
value: '2',
label: 'Export approved only',
},
],
});
}
if (integration.filterByPathIntegrationFiles) {
defaultSettings.push({
label: 'File Filters',
category: types_1.DefaultCategory.ADVANCED,
position: 0,
}, {
key: 'includeByFilePath',
label: 'Show only files matching pattern',
type: 'textarea',
helpTextHtml: `
<p>Enter file path patterns to include for synchronization. Use wildcard selectors:</p>
<ul style="margin: 8px 0; padding-left: 20px;">
<li><code>*</code> - matches any characters except /</li>
<li><code>**</code> - matches any characters including /</li>
</ul>
<p><strong>Examples:</strong></p>
<ul style="margin: 8px 0; padding-left: 20px;">
<li><code>/pages/**</code> - all files in pages folder</li>
<li><code>/**/*.json</code> - all JSON files</li>
<li><code>/content/**/*.md</code> - all Markdown files in content</li>
</ul>
`,
category: types_1.DefaultCategory.ADVANCED,
position: 1,
}, {
key: 'excludeByFilePath',
label: 'Hide files matching pattern',
type: 'textarea',
helpTextHtml: `
<p>Enter path patterns for files or folders to exclude. Use wildcard selectors:</p>
<ul style="margin: 8px 0; padding-left: 20px;">
<li><code>*</code> - matches any characters except /</li>
<li><code>**</code> - matches any characters including /</li>
</ul>
<p><strong>Examples:</strong></p>
<ul style="margin: 8px 0; padding-left: 20px;">
<li><code>/drafts/**</code> - ignore drafts folder</li>
<li><code>/**/.git/**</code> - ignore all .git folders</li>
<li><code>/**/test/**</code> - ignore all test folders</li>
</ul>
`,
category: types_1.DefaultCategory.ADVANCED,
position: 2,
});
}
if (integration.skipIntegrationNodes && integration.skipIntegrationNodesToggle) {
defaultSettings.push({
key: 'skipIntegrationNodesToggle',
label: integration.skipIntegrationNodesToggle.title,
type: 'checkbox',
helpText: integration.skipIntegrationNodesToggle.description,
defaultValue: integration.skipIntegrationNodesToggle.value,
category: types_1.DefaultCategory.GENERAL,
});
}
return [...defaultSettings, ...fields];
});
if (!integration.checkConnection) {
integration.checkConnection = (credentials) => __awaiter(this, void 0, void 0, function* () {
yield integration.getIntegrationFiles({ credentials });
});
}
if (integration.webhooks && !((_a = integration.webhooks) === null || _a === void 0 ? void 0 : _a.urlParam)) {
integration.webhooks.urlParam = 'crowdinData';
}
if (!((_b = integration.filtering) === null || _b === void 0 ? void 0 : _b.hasOwnProperty('crowdinLanguages'))) {
integration.filtering = Object.assign(Object.assign({}, (integration.filtering || {})), { crowdinLanguages: true });
}
integration.filtering.integrationFileStatus = Object.assign(Object.assign({}, (integration.integrationOneLevelFetching ? {} : { notSynced: true })), integration.filtering.integrationFileStatus);
if (!((_c = integration.filtering) === null || _c === void 0 ? void 0 : _c.hasOwnProperty('integrationFilterConfig'))) {
const filterItems = [
{
value: 'all',
label: 'All',
},
];
if ((_e = (_d = integration.filtering) === null || _d === void 0 ? void 0 : _d.integrationFileStatus) === null || _e === void 0 ? void 0 : _e.isNew) {
filterItems.push({
value: 'isNew',
label: 'New',
});
}
if ((_g = (_f = integration.filtering) === null || _f === void 0 ? void 0 : _f.integrationFileStatus) === null || _g === void 0 ? void 0 : _g.isUpdated) {
filterItems.push({
value: 'isUpdated',
label: 'Modified',
});
}
if ((_j = (_h = integration.filtering) === null || _h === void 0 ? void 0 : _h.integrationFileStatus) === null || _j === void 0 ? void 0 : _j.failed) {
filterItems.push({
value: 'failed',
label: 'Sync Error',
});
}
if ((_l = (_k = integration.filtering) === null || _k === void 0 ? void 0 : _k.integrationFileStatus) === null || _l === void 0 ? void 0 : _l.notSynced) {
filterItems.push({
value: 'notSynced',
label: 'Never Synced',
});
}
if ((_o = (_m = integration.filtering) === null || _m === void 0 ? void 0 : _m.integrationFileStatus) === null || _o === void 0 ? void 0 : _o.synced) {
filterItems.push({
value: 'synced',
label: 'Previously Synced',
});
}
integration.filtering = Object.assign(Object.assign({}, (integration.filtering || {})), { integrationFilterConfig: filterItems.length > 1
? [
{
key: 'file',
type: 'list_single',
label: 'File',
items: filterItems,
defaultValue: 'all',
defaultLabel: 'All',
},
]
: [] });
}
if (!integration.userErrorLifetimeDays) {
integration.userErrorLifetimeDays = 30;
}
integration.jobStoreType = integration.jobStoreType || 'db';
}
function constructOauthUrl({ config, integration, clientId, loginForm, }) {
var _a, _b, _c, _d, _e;
const oauth = integration.oauthLogin;
if (!oauth) {
return;
}
if (oauth.getAuthorizationUrl) {
if (!loginForm) {
return;
}
let url = oauth.getAuthorizationUrl({
redirectUrl: `${config.baseUrl}${getOauthRoute(integration)}`,
loginForm,
});
url += `&${((_a = oauth.fieldsMapping) === null || _a === void 0 ? void 0 : _a.state) || 'state'}=${Buffer.from(clientId).toString('base64')}`;
return url;
}
if (!oauth.authorizationUrl) {
return;
}
let url = oauth.authorizationUrl || '';
url += `?${((_b = oauth.fieldsMapping) === null || _b === void 0 ? void 0 : _b.clientId) || 'client_id'}=${oauth.clientId}`;
url += `&${((_c = oauth.fieldsMapping) === null || _c === void 0 ? void 0 : _c.redirectUri) || 'redirect_uri'}=${config.baseUrl}${getOauthRoute(integration)}`;
url += `&${((_d = oauth.fieldsMapping) === null || _d === void 0 ? void 0 : _d.state) || 'state'}=${Buffer.from(clientId).toString('base64')}`;
if (oauth.scope) {
url += `&${((_e = oauth.fieldsMapping) === null || _e === void 0 ? void 0 : _e.scope) || 'scope'}=${oauth.scope}`;
}
if (oauth.extraAutorizationUrlParameters) {
Object.entries(oauth.extraAutorizationUrlParameters).forEach(([key, value]) => (url += `&${key}=${value}`));
}
return url;
}
function getOAuthPollingId(clientId) {
return `oauth_${clientId}`;
}
function getOAuthLoginFormId(clientId) {
return `oauth_form_${clientId}`;
}
function groupFieldsByCategory(fields) {
const groupedFields = fields.reduce((acc, field) => {
const category = (field === null || field === void 0 ? void 0 : field.category) || types_1.DefaultCategory.GENERAL;
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(field);
return acc;
}, {});
// Sort fields by position within each category
Object.keys(groupedFields).forEach((category) => {
groupedFields[category].sort((a, b) => {
// If neither has position, maintain original order
if (!('position' in a) && !('position' in b)) {
return 0;
}
// If only one has position, the one without position goes to the end
if (!('position' in a)) {
return 1;
}
if (!('position' in b)) {
return -1;
}
// If both have position, sort by position value (lower numbers first)
return (a.position || 0) - (b.position || 0);
});
});
// Define category order: General Settings → Sync settings → Advanced
const categoryOrder = {
[types_1.DefaultCategory.GENERAL]: 1,
[types_1.DefaultCategory.SYNC]: 2,
[types_1.DefaultCategory.ADVANCED]: 3,
};
return Object.entries(groupedFields)
.sort(([categoryA], [categoryB]) => {
const orderA = categoryOrder[categoryA] || 999;
const orderB = categoryOrder[categoryB] || 999;
return orderA - orderB;
})
.map(([category, fields]) => ({
name: category,
fields,
}));
}
function buildMenuItems(integration, appName) {
const integrationMenuItems = [];
if (integration.matchCrowdinFilesToIntegrationFiles) {
integrationMenuItems.push({
label: `Translations: Sync to ${appName}`,
title: `Sync translations to ${appName}`,
action: 'syncTranslationsToIntegration',
});
}
if (integration.uploadTranslations) {
integrationMenuItems.push({
label: 'Translations: Sync to Crowdin',
title: 'Sync translations to Crowdin',
action: 'uploadTranslations',
});
}
if (integration.forcePushSources) {
integrationMenuItems.push({
label: 'Source Files: Sync to Crowdin (Forced)',
title: 'Force sync files to Crowdin',
action: 'forcePushSources',
});
}
if (integration.excludedTargetLanguages) {
integrationMenuItems.push({
label: 'Change Files Target Languages',
title: 'Upload file for translation into selected languages',
action: 'excludedTargetLanguages',
});
}
const crowdinMenuItems = [];
if (integration.matchCrowdinFilesToIntegrationFiles) {
crowdinMenuItems.push({
label: 'Source Files: Sync to Crowdin',
action: 'syncSourcesToCrowdin',
});
}
if (integration.matchCrowdinFilesToIntegrationFiles) {
crowdinMenuItems.push({
label: 'Translations: Sync to Crowdin',
action: 'syncTranslationsToCrowdin',
});
}
if (integration.excludedTargetLanguages) {
crowdinMenuItems.push({
label: 'Change Files Target Languages',
action: 'excludedTargetLanguages',
});
}
if (integration.forcePushTranslations) {
crowdinMenuItems.push({
label: `Translations: Sync to ${appName} (Forced)`,
action: 'forcePushTranslations',
});
}
return {
integrationButtonMenuItems: integrationMenuItems.length > 0 ? JSON.stringify(integrationMenuItems) : null,
crowdinButtonMenuItems: crowdinMenuItems.length > 0 ? JSON.stringify(crowdinMenuItems) : null,
};
}