metamaps--metamaps/frontend/src/Metamaps/Import.js
Connor Turland 7ee96bf6c6 Into master: two finger pan/zoom, map and topic follows (for internal testing) on the UI, map activity emails (#1084)
* fix topic spec

* fix synapse/mapping spec

* brakeman csrf warning suppressed :|

* follows for maps in the ui for internal testing only still (#1072)

* follows for maps in the ui for testers

* require user for these actions

* match how map follow works

* include ability to unfollow from email

* fixup templates

* add unfollow_from_email to the policies

* Update _cheatsheet.html.erb

Clean up text, clarify, and bring in line with current functionality

* topicsRegex and synapsesRegex should allow commas (#1073)

* even better import csv regexes

* prevent double prompt on file drop import

* topic card in react (#1031)

* its coming along

* links bar

* scssify a bunch

* metacode image working a bit better

* metacode selector in react topic card

* riek editing for name field on topic card

* riek submit on enter

* factor out Title and Links from Topic Card component, but not the listeners

* create working Desc editor

* styling is much better now

* textarea min height for desc

* disallow images in topic card markdown

* shift enter is linebreak, enter is save

* attachments split out, but it's pretty buggy

* move listeners into Links.js

* slightly wider metacodeTitle

* fix positioning on metacode selector

* fix metacode selection

* move metacode and permissions into subcomponents

* fixes

* prevent editing on desc/title if not authorized to edit

* fix topic card draggability

* fix embedly

* fix md test

* remove the removed link card manually with jquery

* fix test syntax

* eslint

* more eslin

* reuse authorizedToEdit

* convert metacode sets to a json object for react

* add the html in react whoop

* fix metacode styling

* sort wasn't working

* finishing metacode select

* readd the above link input border

* fix syntax

* multiline title editable textarea

* more portable metacode selector component

* factor out #metacodeOptions into one react component with a callback :D:D:D

* render metacodeOptions in right click menu with react

* render metacodeOptions in right click menu with react

* fix up right click menu's metacode editing

* fix topic card title character counter

* ignore metamaps secret bundle in ag

* simplify Attachments props

* factor out embedly card into its own component; it seems to help

* link resetter

* fix edit icon on title in topic card

* move mapCount and synapseCount hover/click logic to react

* fix up the showMore control

* metacode selection tweaks

* tweak links bar spacing in topic card

* rubocop

* remove TODOs

* more badass permissions selector

* close permission selector when you click outside

* fix overeager metacode selector

* more modular attachments component

* fix bug in Desc.js

* fix right click styling

* permission changes are different than edit rights

* bad module ref

* ensure maxLength on topic titles

* hellz yeah (#1074)

* fix drop from two touches to one

* don't commit activity service

* ability to select/unselect all metacodes in custom set with keyboard shortcut (fix #390) (#1078)

* ability to select/unselect all metacodes in custom set with keyboard shortcut

* select all button

* nicer all/none buttons

* set up react testing (#1080)

* install mocha-webpack. also switch hark to npm version instead of github version

* well, mocha-webpack runs

* add jsdom for tests

* upgrade to webpack 2

* fix npm run test errors

* ImportDialogBox component tests

* Fixes bug where pressing delete key while editing text will suggest... (#1083)

* Fixes bug where pressing delete key while editing text will suggest the deletion of selected map entities

* Changed the DEL key to remove entities instead of delete them

* temporarily disable code climate duplication engine

* add topic following for internal testing

* daily map activity emails (#1081)

* data prepared, task setup

* add the basics of the email template

* cover granular permissions

* unfollow this map

* break out permissions tests better

* rename so test runs
2017-03-06 22:49:46 -05:00

428 lines
13 KiB
JavaScript

/* global $ */
import parse from 'csv-parse'
import _ from 'lodash'
import Active from './Active'
import AutoLayout from './AutoLayout'
import DataModel from './DataModel'
import GlobalUI from './GlobalUI'
import Map from './Map'
import Synapse from './Synapse'
import Topic from './Topic'
const Import = {
// note that user is not imported
topicWhitelist: [
'id', 'name', 'metacode', 'x', 'y', 'description', 'link', 'permission'
],
synapseWhitelist: [
'topic1', 'topic2', 'category', 'direction', 'desc', 'description', 'permission'
],
cidMappings: {}, // to be filled by importId => cid mappings
handleTSV: function(text) {
const results = Import.parseTabbedString(text)
Import.handle(results)
},
handleCSV: function(text, parserOpts = {}) {
const self = Import
const topicsRegex = /("?Topics"?[, \t"]*)([\s\S]*)/mi
const synapsesRegex = /("?Synapses"?[, \t"]*)([\s\S]*)/mi
let topicsText = text.match(topicsRegex) || ''
if (topicsText) topicsText = topicsText[2].replace(synapsesRegex, '')
let synapsesText = text.match(synapsesRegex) || ''
if (synapsesText) synapsesText = synapsesText[2].replace(topicsRegex, '')
// merge default options and extra options passed in parserOpts argument
const csvParserOptions = Object.assign({
columns: true, // get headers
relax_column_count: true,
skip_empty_lines: true
}, parserOpts)
const topicsPromise = $.Deferred()
parse(topicsText, csvParserOptions, (err, data) => {
if (err) {
console.warn(err)
return topicsPromise.resolve([])
}
topicsPromise.resolve(data)
})
const synapsesPromise = $.Deferred()
parse(synapsesText, csvParserOptions, (err, data) => {
if (err) {
console.warn(err)
return synapsesPromise.resolve([])
}
synapsesPromise.resolve(data)
})
$.when(topicsPromise, synapsesPromise).done((topics, synapses) => {
self.handle({ topics, synapses })
})
},
handleJSON: function(text) {
const results = JSON.parse(text)
Import.handle(results)
},
handle: function(results) {
var self = Import
var topics = results.topics.map(topic => self.normalizeKeys(topic))
var synapses = results.synapses.map(synapse => self.normalizeKeys(synapse))
if (topics.length > 0 || synapses.length > 0) {
if (window.confirm('Are you sure you want to create ' + topics.length +
' new topics and ' + synapses.length + ' new synapses?')) {
self.importTopics(topics)
// window.setTimeout(() => self.importSynapses(synapses), 5000)
self.importSynapses(synapses)
} // if
} // if
},
parseTabbedString: function(text) {
var self = Import
// determine line ending and split lines
var delim = '\n'
if (text.indexOf('\r\n') !== -1) {
delim = '\r\n'
} else if (text.indexOf('\r') !== -1) {
delim = '\r'
} // if
var STATES = {
ABORT: -1,
UNKNOWN: 0,
TOPICS_NEED_HEADERS: 1,
SYNAPSES_NEED_HEADERS: 2,
TOPICS: 3,
SYNAPSES: 4
}
// state & lines determine parser behaviour
var state = STATES.UNKNOWN
var lines = text.split(delim)
var results = { topics: [], synapses: [] }
var topicHeaders = []
var synapseHeaders = []
lines.forEach(function(lineRaw, index) {
const line = lineRaw.split('\t')
var noblanks = line.filter(function(elt) {
return elt !== ''
})
switch (state) {
case STATES.UNKNOWN:
if (noblanks.length === 0) {
state = STATES.UNKNOWN
break
} else if (noblanks.length === 1 && self.simplify(line[0]) === 'topics') {
state = STATES.TOPICS_NEED_HEADERS
break
} else if (noblanks.length === 1 && self.simplify(line[0]) === 'synapses') {
state = STATES.SYNAPSES_NEED_HEADERS
break
}
state = STATES.TOPICS_NEED_HEADERS
// FALL THROUGH - if we're not sure what to do, pretend
// we're on the TOPICS_NEED_HEADERS state and parse some headers
case STATES.TOPICS_NEED_HEADERS: // eslint-disable-line no-fallthrough
if (noblanks.length < 2) {
self.abort('Not enough topic headers on line ' + index)
state = STATES.ABORT
}
topicHeaders = line.map(function(header, index) {
return self.normalizeKey(header)
})
state = STATES.TOPICS
break
case STATES.SYNAPSES_NEED_HEADERS:
if (noblanks.length < 2) {
self.abort('Not enough synapse headers on line ' + index)
state = STATES.ABORT
}
synapseHeaders = line.map(function(header, index) {
return self.normalizeKey(header)
})
state = STATES.SYNAPSES
break
case STATES.TOPICS:
if (noblanks.length === 0) {
state = STATES.UNKNOWN
} else if (noblanks.length === 1 && line[0].toLowerCase() === 'topics') {
state = STATES.TOPICS_NEED_HEADERS
} else if (noblanks.length === 1 && line[0].toLowerCase() === 'synapses') {
state = STATES.SYNAPSES_NEED_HEADERS
} else {
var topic = {}
line.forEach(function(field, index) {
var header = topicHeaders[index]
if (self.topicWhitelist.indexOf(header) === -1) return
topic[header] = field
if (['id', 'x', 'y'].indexOf(header) !== -1) {
topic[header] = parseInt(topic[header])
} // if
})
results.topics.push(topic)
}
break
case STATES.SYNAPSES:
if (noblanks.length === 0) {
state = STATES.UNKNOWN
} else if (noblanks.length === 1 && line[0].toLowerCase() === 'topics') {
state = STATES.TOPICS_NEED_HEADERS
} else if (noblanks.length === 1 && line[0].toLowerCase() === 'synapses') {
state = STATES.SYNAPSES_NEED_HEADERS
} else {
var synapse = {}
line.forEach(function(field, index) {
var header = synapseHeaders[index]
if (self.synapseWhitelist.indexOf(header) === -1) return
synapse[header] = field
if (['id', 'topic1', 'topic2'].indexOf(header) !== -1) {
synapse[header] = parseInt(synapse[header])
} // if
})
results.synapses.push(synapse)
}
break
case STATES.ABORT:
// FALL THROUGH
default: // eslint-disable-line no-fallthrough
self.abort('Invalid state while parsing import data. Check code.')
state = STATES.ABORT
}
})
if (state === STATES.ABORT) {
return false
} else {
return results
}
},
importTopics: function(parsedTopics) {
var self = Import
parsedTopics.forEach(topic => {
let coords = { x: topic.x, y: topic.y }
if (!coords.x || !coords.y) {
coords = AutoLayout.getNextCoord({ mappings: DataModel.Mappings })
}
if (!topic.name && topic.link ||
topic.name && topic.link && !topic.metacode) {
self.handleURL(topic.link, {
coords,
name: topic.name,
permission: topic.permission,
importId: topic.id
})
return // "continue"
}
self.createTopicWithParameters(
topic.name, topic.metacode, topic.permission,
topic.desc, topic.link, coords.x, coords.y, topic.id
)
})
},
importSynapses: function(parsedSynapses) {
var self = Import
parsedSynapses.forEach(function(synapse) {
// only createSynapseWithParameters once both topics are persisted
// if there isn't a cidMapping, check by topic name instead
var topic1 = DataModel.Topics.get(self.cidMappings[synapse.topic1])
if (!topic1) topic1 = DataModel.Topics.findWhere({ name: synapse.topic1 })
var topic2 = DataModel.Topics.get(self.cidMappings[synapse.topic2])
if (!topic2) topic2 = DataModel.Topics.findWhere({ name: synapse.topic2 })
if (!topic1 || !topic2) {
console.error("One of the two topics doesn't exist!")
console.error(synapse)
return // next
}
const topic1Promise = $.Deferred()
if (topic1.id) {
topic1Promise.resolve()
} else {
topic1.on('sync', () => topic1Promise.resolve())
}
const topic2Promise = $.Deferred()
if (topic2.id) {
topic2Promise.resolve()
} else {
topic2.on('sync', () => topic2Promise.resolve())
}
$.when(topic1Promise, topic2Promise).done(() => {
self.createSynapseWithParameters(
synapse.desc, synapse.category, synapse.permission,
topic1, topic2
)
})
})
},
createTopicWithParameters: function(name, metacodeName, permission, desc,
link, xloc, yloc, importId, opts = {}) {
var self = Import
$(document).trigger(Map.events.editedByActiveMapper)
var metacode = DataModel.Metacodes.where({name: metacodeName})[0] || null
if (metacode === null) {
metacode = DataModel.Metacodes.where({ name: 'Wildcard' })[0]
console.warn("Couldn't find metacode " + metacodeName + ' so used Wildcard instead.')
}
const topicPermision = permission || Active.Map.get('permission')
var deferToMapId = permission === topicPermision ? Active.Map.get('id') : null
var topic = new DataModel.Topic({
name: name,
metacode_id: metacode.id,
permission: topicPermision,
defer_to_map_id: deferToMapId,
desc: desc || '',
link: link || ''
})
DataModel.Topics.add(topic)
if (importId !== null && importId !== undefined) {
self.cidMappings[importId] = topic.cid
}
var mapping = new DataModel.Mapping({
xloc: xloc,
yloc: yloc,
mappable_id: topic.cid,
mappable_type: 'Topic'
})
DataModel.Mappings.add(mapping)
// this function also includes the creation of the topic in the database
Topic.renderTopic(mapping, topic, true, true, {
success: opts.success
})
GlobalUI.hideDiv('#instructions')
},
createSynapseWithParameters: function(desc, category, permission,
topic1, topic2) {
var node1 = topic1.get('node')
var node2 = topic2.get('node')
if (!topic1.id || !topic2.id) {
console.error('missing topic id when creating synapse')
return
} // if
var synapse = new DataModel.Synapse({
desc: desc || '',
category: category || 'from-to',
permission: permission,
topic1_id: topic1.id,
topic2_id: topic2.id
})
DataModel.Synapses.add(synapse)
var mapping = new DataModel.Mapping({
mappable_type: 'Synapse',
mappable_id: synapse.cid
})
DataModel.Mappings.add(mapping)
Synapse.renderSynapse(mapping, synapse, node1, node2, true)
},
handleURL: function(url, opts = {}) {
let coords = opts.coords
if (!coords || coords.x === undefined || coords.y === undefined) {
coords = AutoLayout.getNextCoord({ mappings: DataModel.Mappings })
}
const name = opts.name || 'Link'
const metacode = opts.metacode || 'Reference'
const importId = opts.importId || null // don't store a cidMapping
const permission = opts.permission || null // use default
const desc = opts.desc || url
Import.createTopicWithParameters(
name,
metacode,
permission,
desc,
url,
coords.x,
coords.y,
importId,
{
success: function(topic) {
if (topic.get('name') !== 'Link') return
$.get('/hacks/load_url_title', {
url
}, function success(data, textStatus) {
if (typeof data === 'string' && data.trim() === '') return
var selector = '#showcard #topic_' + topic.get('id') + ' .best_in_place'
if ($(selector).find('form').length > 0) {
$(selector).find('textarea, input').val(data.title)
} else {
$(selector).html(data.title)
}
topic.set('name', data.title)
topic.save()
})
}
}
)
},
/*
* helper functions
*/
abort: function(message) {
console.error(message)
},
// TODO investigate replacing with es6 (?) trim()
simplify: function(string) {
return string
.replace(/(^\s*|\s*$)/g, '')
.toLowerCase()
},
normalizeKey: function(key) {
let newKey = key.toLowerCase()
newKey = newKey.replace(/\s/g, '') // remove whitespace
if (newKey === 'url') newKey = 'link'
if (newKey === 'title') newKey = 'name'
if (newKey === 'label') newKey = 'desc'
if (newKey === 'description') newKey = 'desc'
if (newKey === 'direction') newKey = 'category'
return newKey
},
// thanks to http://stackoverflow.com/a/25290114/5332286
normalizeKeys: function(obj) {
return _.transform(obj, (result, val, key) => {
const newKey = Import.normalizeKey(key)
result[newKey] = val
})
}
}
export default Import