From cea399a1f93351467594049b5cc329da486beb46 Mon Sep 17 00:00:00 2001 From: Chris Garrity Date: Tue, 28 Apr 2020 16:26:12 -0400 Subject: [PATCH] sync knowledge base sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Pulls data from Freshdesk solutions knowledge base. * category names and folder names are plain key-value-json files * Reformats article strings into a structured json for translation: ``` [ : { title : { string: }, description: { string: <title text> }, tags: { string: <title text> }, … ] ``` --- scripts/freshdesk-api.js | 53 ++++++++++++++ scripts/tx-push-help.js | 146 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 scripts/freshdesk-api.js create mode 100755 scripts/tx-push-help.js diff --git a/scripts/freshdesk-api.js b/scripts/freshdesk-api.js new file mode 100644 index 00000000..dffebfc6 --- /dev/null +++ b/scripts/freshdesk-api.js @@ -0,0 +1,53 @@ +// interface to FreshDesk Solutions (knowledge base) api + +const fetch = require('node-fetch'); +class FreshdeskApi { + + constructor (baseUrl, apiKey) { + this.baseUrl = baseUrl; + this._auth = 'Basic ' + new Buffer(`${apiKey}:X`).toString('base64'); + this.defaultHeaders = { + 'Content-Type': 'application/json', + 'Authorization': this._auth + }; + } + + /** + * Checks the status of a response. If status is not ok, or the body is not json raise exception + * @param {object} res The response object + * @returns {object} the response if it is ok + */ + checkStatus (res) { + if (res.ok) { + if (res.headers.get('content-type').indexOf('application/json') !== -1) { + return res; + } + throw new Error(`response not json: ${res.headers.get('content-type')}`); + } + throw new Error(`response: ${res.statusText}`); + } + + listCategories () { + return fetch(`${this.baseUrl}/api/v2/solutions/categories`, {headers: this.defaultHeaders}) + .then(this.checkStatus) + .then(res => res.json()); + } + + listFolders (category) { + return fetch( + `${this.baseUrl}/api/v2/solutions/categories/${category.id}/folders`, + {headers: this.defaultHeaders}) + .then(this.checkStatus) + .then(res => res.json()); + } + + listArticles (folder) { + return fetch( + `${this.baseUrl}/api/v2/solutions/folders/${folder.id}/articles`, + {headers: this.defaultHeaders}) + .then(this.checkStatus) + .then(res => res.json()); + } +} + +module.exports = FreshdeskApi; diff --git a/scripts/tx-push-help.js b/scripts/tx-push-help.js new file mode 100755 index 00000000..c93798eb --- /dev/null +++ b/scripts/tx-push-help.js @@ -0,0 +1,146 @@ +#!/usr/bin/env babel-node + +/** + * @fileoverview + * Script get Knowledge base articles from Freshdesk and push them to transifex. + */ + +const args = process.argv.slice(2); + +const usage = ` + Pull knowledge base articles from Freshdesk and push to scratch-help project on transifex. Usage: + node tx-push-help.js + NOTE: + FRESHDESK_TOKEN environment variable needs to be set to a FreshDesk API key with + access to the Knowledge Base. + TX_TOKEN environment variable needs to be set with a Transifex API token. See + the Localization page on the GUI wiki for information about setting up Transifex. + `; +// Fail immediately if the API tokens are not defined, or there any argument +if (!process.env.TX_TOKEN || !process.env.FRESHDESK_TOKEN || args.length > 0) { + process.stdout.write(usage); + process.exit(1); +} + +import transifex from 'transifex'; +import FreshdeskApi from './freshdesk-api.js'; + +const FD = new FreshdeskApi('https://mitscratch.freshdesk.com', process.env.FRESHDESK_TOKEN); +const TX_PROJECT = 'scratch-help'; +const TX = new transifex({ + project_slug: TX_PROJECT, + credential: 'api:' + process.env.TX_TOKEN +}); + +const categoryNames = {}; +const folderNames = {}; + +/** + * Generate a transifex id from the name and id field of an objects. Remove spaces and '/' + * from the name and append '.<id>' Transifex ids (slugs) have a max length of 50. Use at most + * 30 characters of the name to allow for Freshdesk id, and a suffix like '_json' + * @param {object} item data from Freshdesk that includes the name and id of a category or folder + * @return {string} generated transifex id + */ +const makeTxId = item => { + return `${item.name.replace(/[ /]/g, '').slice(0, 30)}_${item.id}`; +}; + +const txPushResource = (name, articles, type) => { + const resourceData = { + slug: name, + name: name, + priority: 0, // default to normal priority + i18n_type: type, + content: '{}' + }; + TX.resourceCreateMethod(TX_PROJECT, resourceData, (err) => { + // ignore already created error report others + if (err && err.response.statusCode !== 400) { + process.stdout.write(`Transifex Error: ${err.message}\n`); + process.stdout.write( + `Transifex Error ${err.response.statusCode.toString()}: ${err.response.body}\n`); + process.exitCode = 1; + return; + } + // update Transifex with English source + TX.uploadSourceLanguageMethod(TX_PROJECT, name, + {content: JSON.stringify(articles, null, 2)}, (err1) => { + if (err1) { + process.stdout.write(`Transifex Error:${err1.name}, ${err1.message}\n`); + process.stdout.write(`Transifex Error:${err1.toString()}\n`); + process.exitCode = 1; + } + }); + }); +}; + +/** + * get a flattened list of folders + * @param {category} categories array of categories the folders belong to + * @return {Promise} flattened list of folders + */ +const getFolders = async (categories) => { + let categoryFolders = await Promise.all( // eslint-disable-line no-undef + categories.map(category => FD.listFolders(category)) + ); + return [].concat(...categoryFolders); +}; + +const PUBLISHED = 2; // in Freshdesk, draft status = 1, and published = 2 +const saveArticles = (folder) => { + FD.listArticles(folder) + .then(json => { + let txArticles = json.reduce((strings, current) => { + if (current.status === PUBLISHED) { + strings[`${current.id}`] = { + title: { + string: current.title + }, + description: { + string: current.description + } + }; + if (current.tags.length > 0) { + strings[`${current.id}`].tags = current.tags.toString(); + } + } + return strings; + }, {}); + process.stdout.write(`Push ${folder.name} articles to Transifex\n`); + txPushResource(`${makeTxId(folder)}_json`, txArticles, 'STRUCTURED_JSON'); + }); +}; +const getArticles = async (folders) => { + return Promise.all(folders.map(folder => saveArticles(folder))); // eslint-disable-line no-undef +}; + +const syncSources = async () => { + let status = 0; + status = await FD.listCategories() + .then(json => { + // save category names for translation + for (let cat of json.values()) { + categoryNames[`${makeTxId(cat)}`] = cat.name; + } + return json; + }) + .then(getFolders) + .then(data => { + data.forEach(item => { + folderNames[`${makeTxId(item)}`] = item.name; + }); + process.stdout.write('Push category and folder names to Transifex\n'); + txPushResource('categoryNames_json', categoryNames, 'KEYVALUEJSON'); + txPushResource('folderNames_json', folderNames, 'KEYVALUEJSON'); + return data; + }) + .then(getArticles) + .catch((e) => { + process.stdout.write(`Error:${e.message}\n`); + return 1; + }); + process.exitCode = status; +}; + +syncSources();