Skip to content

Commit

Permalink
feat(qna): csv import/export
Browse files Browse the repository at this point in the history
  • Loading branch information
epaminond committed Jun 29, 2018
1 parent fc0a0b1 commit 394a922
Show file tree
Hide file tree
Showing 7 changed files with 376 additions and 10 deletions.
42 changes: 42 additions & 0 deletions packages/functionals/botpress-qna/README.md
Expand Up @@ -24,6 +24,48 @@ The following properties can be configured either in the `qna.json` file or usin

Go to the bot admin panel and choose Q&A from the left hand side menu.

## Programmatic usage

`botpress-qna` exposes public API for importing/exporting questions: `bp.qna.import` and `bp.qna.export`.
See examples below on how to use them.

```js
// Importing questions provided as an array of objects
const questions = [
{ questions: ['Question1', 'Question2'], action: 'text', answer: 'Answer1' },
{ questions: ['Question3'], action: 'redirect', answer: 'main.flow.json#some-node' }
]
await bp.qna.import(questions)

// Importing questions from json-string
const questionsJson = `[
{ "questions": [ "Question1", "Question2" ], "action": "text", "answer": "Answer1" },
{ "questions": [ "Question3" ], "action": "redirect", "answer": "main.flow.json#some-node" }
]`
await bp.qna.import(questions, { format: 'json' })

// Importing questions from csv-string
// Note: consequtive questions with similar answer will be merged into one record with multiple questions
const questionsCsv =
`"Question1","text","Answer1"
"Question2","text","Answer1"
"Question3","redirect","main.flow.json#some-node"`
await bp.qna.import(questions, { format: 'csv' })
```

```js
const questionsExported = await bp.qna.export() // Should return structure similar to "questions" const in previous example

const questionsFlatExported = await bp.qna.export({ flat: true })
// Should return a flat structure with question-string in each record like this (might be useful for exporting to CSV):
//
// [
// { question: 'Question1', action: 'text', answer: 'Answer1' },
// { question: 'Question2', action: 'text', answer: 'Answer1' },
// { question: 'Question3', action: 'redirect', answer: 'main.flow.json#some-node' }
// ]
```

# Contributing

The best way to help right now is by helping with the exising issues here on GitHub and by reporting new issues!
Expand Down
7 changes: 6 additions & 1 deletion packages/functionals/botpress-qna/package.json
Expand Up @@ -56,8 +56,13 @@
},
"dependencies": {
"bluebird": "^3.5.1",
"csv-parse": "^2.5.0",
"json2csv": "^4.1.5",
"lodash": "^4.17.4",
"mkdirp": "^0.5.1",
"nanoid": "^1.0.1"
"moment": "^2.22.2",
"multer": "^1.3.0",
"nanoid": "^1.0.1",
"yn": "^2.0.0"
}
}
65 changes: 63 additions & 2 deletions packages/functionals/botpress-qna/src/index.js
@@ -1,5 +1,11 @@
import Storage from './storage'
import { processEvent } from './middleware'
import * as parsers from './parsers.js'
import multer from 'multer'
import { Parser as Json2csvParser } from 'json2csv'
import yn from 'yn'
import moment from 'moment'
import Promise from 'bluebird'

let storage
let logger
Expand Down Expand Up @@ -30,6 +36,38 @@ module.exports = {
})
},
ready(bp) {
bp.qna = {
/**
* Parses and imports questions; consecutive questions with similar answer get merged
* @param {String|Array.<{question: String, action: String, answer: String}>} questions
* @param {Object} options
* @param {String} [options.format] - format of "questions" string ('csv' or 'json')
* @returns {Promise} Promise object represents an array of ids of imported questions
*/
import(questions, { format = 'json' } = {}) {
const questionsToSave = typeof questions === 'string' ? parsers[`${format}Parse`](questions) : questions
return Promise.each(questionsToSave, question => storage.saveQuestion({ ...question, enabled: true }))
},
/**
* @async
* Fetches questions and represents them as json
* @param {Object} options
* @param {Boolean} [options.flat = false] - whether multiple questions get split into separate records
* @returns {Array.<{questions: Array, question: String, action: String, answer: String}>}
*/
async export({ flat = false } = {}) {
return (await storage.getQuestions()).flatMap(
({ data: { questions, answer: textAnswer, action, redirectFlow, redirectNode } }) => {
const answer = action === 'text' ? textAnswer : [redirectFlow, redirectNode].filter(Boolean).join('#')
if (!flat) {
return { questions, action, answer }
}
return questions.map(question => ({ question, action, answer }))
}
)
}
}

const router = bp.getRouter('botpress-qna')

router.get('/', async (req, res) => {
Expand All @@ -43,7 +81,7 @@ module.exports = {

router.post('/', async (req, res) => {
try {
const id = await storage.saveQuestion(null, req.body)
const id = await storage.saveQuestion(req.body)
res.send(id)
} catch (e) {
logger.error('QnA Error', e, e.stack)
Expand All @@ -53,7 +91,7 @@ module.exports = {

router.put('/:question', async (req, res) => {
try {
await storage.saveQuestion(req.params.question, req.body)
await storage.saveQuestion(req.body, req.params.question)
res.end()
} catch (e) {
logger.error('QnA Error', e, e.stack)
Expand All @@ -70,5 +108,28 @@ module.exports = {
res.status(500).send(e.message || 'Error')
}
})

router.get('/csv', async (req, res) => {
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-disposition', `attachment; filename=qna_${moment().format('DD-MM-YYYY')}.csv`)
const json2csvParser = new Json2csvParser({ fields: ['question', 'action', 'answer'], header: false })
res.end(json2csvParser.parse(await bp.qna.export({ flat: true })))
})

const upload = multer()
router.post('/csv', upload.single('csv'), async (req, res) => {
if (yn(req.body.isReplace)) {
const questions = await storage.getQuestions()
await Promise.each(questions, ({ id }) => storage.deleteQuestion(id))
}

try {
await bp.qna.import(req.file.buffer.toString(), { format: 'csv' })
res.end()
} catch (e) {
logger.error('QnA Error:', e)
res.status(400).send(e.message || 'Error')
}
})
}
}
27 changes: 27 additions & 0 deletions packages/functionals/botpress-qna/src/parsers.js
@@ -0,0 +1,27 @@
import parseCsvToJson from 'csv-parse/lib/sync'

const parseFlow = str => {
const [redirectFlow, redirectNode = ''] = str.split('#')
return { redirectFlow, redirectNode }
}

export const jsonParse = jsonContent =>
jsonContent.map(({ questions, answer: instruction, action }) => {
if (!['text', 'redirect'].includes(action)) {
throw new Error('Failed to process CSV-row: action should be either "text" or "redirect"')
}
const answer = action === 'text' ? instruction : ''
const flowParams = action === 'redirect' ? parseFlow(instruction) : { redirectFlow: '', redirectNode: '' }
return { questions, action, answer, ...flowParams }
})

export const csvParse = csvContent => {
const mergeRows = (acc, { question, answer, action }) => {
const [prevRow] = acc.slice(-1)
if (prevRow && prevRow.answer === answer) {
return [...acc.slice(0, acc.length - 1), { ...prevRow, questions: [...prevRow.questions, question] }]
}
return [...acc, { answer, action, questions: [question] }]
}
return jsonParse(parseCsvToJson(csvContent, { columns: ['question', 'action', 'answer'] }).reduce(mergeRows, []))
}
2 changes: 1 addition & 1 deletion packages/functionals/botpress-qna/src/storage.js
Expand Up @@ -47,7 +47,7 @@ export default class Storage {
}
}

async saveQuestion(id, data) {
async saveQuestion(data, id = null) {
id = id || getQuestionId(data)
if (data.enabled) {
await this.bp.nlu.storage.saveIntent(getIntentId(id), {
Expand Down
106 changes: 102 additions & 4 deletions packages/functionals/botpress-qna/src/views/index.js
Expand Up @@ -13,7 +13,10 @@ import {
Panel,
ButtonToolbar,
Button,
Well
Well,
HelpBlock,
Modal,
Alert
} from 'react-bootstrap'
import Select from 'react-select'

Expand Down Expand Up @@ -51,6 +54,11 @@ const ACTIONS = {
}

export default class QnaAdmin extends Component {
constructor(props) {
super(props)
this.csvDownloadableLink = React.createRef()
}

createEmptyQuestion() {
return {
id: null,
Expand Down Expand Up @@ -240,6 +248,7 @@ export default class QnaAdmin extends Component {
this.shouldAutofocus = false

const { showBulkImport } = this.state
const saveSign = `${isDirty ? '* ' : ''}Save`

return (
<Well bsSize="small" bsClass={classnames('well', style.qna, { [style.pale]: !data.enabled })}>
Expand Down Expand Up @@ -321,7 +330,7 @@ export default class QnaAdmin extends Component {
onClick={() => (index != null ? onEdit(index) : onCreate())}
disabled={!isDirty || !this.canSave(data)}
>
{index != null ? `${isDirty ? '* ' : ''}Save` : 'Create'}
{index != null ? saveSign : 'Create'}
</Button>
</ButtonToolbar>
</Well>
Expand All @@ -344,18 +353,107 @@ export default class QnaAdmin extends Component {
return some(questions, q => q.indexOf(filter) >= 0)
}

uploadCsv = () => {
const formData = new FormData()
formData.set('isReplace', this.state.isCsvUploadReplace)
formData.append('csv', this.state.csvToUpload)
const headers = { 'Content-Type': 'multipart/form-data' }
this.props.bp.axios
.post('/api/botpress-qna/csv', formData, { headers })
.then(() => {
this.setState({ importCsvModalShow: false })
this.fetchData()
})
.catch(({ response: { data: csvUploadError } }) => this.setState({ csvUploadError }))
}

downloadCsv = () =>
// We can't just download file directly due to security restrictions
this.props.bp.axios({ url: '/api/botpress-qna/csv', responseType: 'blob' }).then(response => {
this.setState(
{
csvDownloadableLinkHref: window.URL.createObjectURL(new Blob([response.data])),
csvDownloadableFileName: /filename=(.*\.csv)/.exec(response.headers['content-disposition'])[1]
},
() => this.csvDownloadableLink.current.click()
)
})

render() {
return (
<Panel>
<a
ref={this.csvDownloadableLink}
href={this.state.csvDownloadableLinkHref}
download={this.state.csvDownloadableFileName}
/>
<Panel.Body>
<p>
<FormGroup>
<ButtonToolbar>
<Button
bsStyle="success"
onClick={() =>
this.setState({
importCsvModalShow: true,
csvToUpload: null,
csvUploadError: null,
isCsvUploadReplace: false
})}
type="button"
>
<Glyphicon glyph="upload" /> &nbsp; Import from CSV
</Button>
<Modal show={this.state.importCsvModalShow} onHide={() => this.setState({ importCsvModalShow: false })}>
<Modal.Header closeButton>
<Modal.Title>Import CSV</Modal.Title>
</Modal.Header>
<Modal.Body>
{this.state.csvUploadError && (
<Alert bsStyle="danger" onDismiss={() => this.setState({ csvUploadError: null })}>
<p>{this.state.csvUploadError}</p>
</Alert>
)}
<form>
<FormGroup>
<ControlLabel>CSV file</ControlLabel>
<FormControl
type="file"
accept=".csv"
onChange={e => this.setState({ csvToUpload: e.target.files[0] })}
/>
<HelpBlock>CSV should be formatted &quot;question,answer_type,answer&quot;</HelpBlock>
</FormGroup>
<FormGroup>
<Checkbox
checked={this.state.isCsvUploadReplace}
onChange={e => this.setState({ isCsvUploadReplace: e.target.checked })}
>
Replace existing FAQs
</Checkbox>
<HelpBlock>Deletes existing FAQs and then uploads new ones from the file</HelpBlock>
</FormGroup>
</form>
</Modal.Body>
<Modal.Footer>
<Button bsStyle="primary" onClick={this.uploadCsv} disabled={!Boolean(this.state.csvToUpload)}>
Upload
</Button>
</Modal.Footer>
</Modal>

<Button bsStyle="success" onClick={this.downloadCsv} type="button">
<Glyphicon glyph="download" />&nbsp; Export to CSV
</Button>
</ButtonToolbar>
</FormGroup>
<FormGroup>
<InputGroup>
<FormControl placeholder="Filter questions" value={this.state.filter} onChange={this.onFilterChange} />
<InputGroup.Addon>
<Glyphicon glyph="search" />
</InputGroup.Addon>
</InputGroup>
</p>
</FormGroup>

<ArrayEditor
items={this.state.items}
Expand Down

0 comments on commit 394a922

Please sign in to comment.