UNPKG

leo-vue

Version:

Use the Leo Outlining Editor to Create Vue Web Apps

1,601 lines (1,561 loc) 59.6 kB
import Vue from 'vue' import Vuex from 'vuex' import {getLeoJSON, transformLeoXML} from '../services/leo.js' import router from '../router' import axios from 'axios' import _ from 'lodash' import jsyaml from 'js-yaml' // import CSV from 'csv-string' import Papa from 'papaparse' import xsl from '../lib/xsl' import Velocity from 'velocity-animate' import lodashTemplate from '../lib/lodash-template' const parserURI = require('uri-parse-lib') const HTML5Outline = require('h5o') const util = require('../util.js') const md = require('markdown-it')({ html: true, linkify: true, typographer: true }) const lunr = require('lunr') function loadIndex (titles, text) { const docs = loadIndexItems([], titles, text) // return indexItems let idx = lunr(function () { this.ref('id') this.field('text') docs.forEach(function (doc) { this.add(doc) }, this) }) return {idx, docs} } function loadIndexItems (arr, titles, textItems) { if (!titles) { return arr } titles.forEach(item => { arr.push( { id: item.id, name: item.name.replace(/^@(.*?)\s/, '').replace(/^set(.*?)\s/, ''), text: textItems[item.t] } ) loadIndexItems(arr, item.children, textItems) }) return arr } function goToAdjacentItem (context, item) { if (item && item[0] && item[0].id) { const id = item[0].id context.dispatch('setCurrentItem', {id}) return true } else { return false } } Vue.use(Vuex) const spinnerHTML = `<div class="spin-box"><div class="single10"></div></div>` function showText (context, text, id, nowrapper, params) { if (params && params.displayType === 'board') { context.commit('CONTENT_PANE', {type: 'board'}) nowrapper = true } else { context.commit('CONTENT_PANE', {type: 'text'}) } if (!text) { text = '' context.commit('CURRENT_ITEM_CONTENT', { text }) return } text = util.formatText(text, nowrapper) // current (user selected) content item context.commit('CURRENT_ITEM_CONTENT', { text }) // hash of all content items const newItem = { id, t: text } context.commit('CONTENT_ITEM', {item: newItem}) context.commit('CONTENT_ITEM_UPDATE') } /** * Get subtree names for preloading bookmarked nodes * @param acc {array} - accumulator * @param p {string} - path, e.g. 28-2-10-5 * @param startIndex {integer} - start with O to get all */ function getRoots (acc, p, startIndex) { const i = p.indexOf('-', startIndex) if (i < 0) { return acc } acc.push(p.substring(0, i)) return getRoots(acc, p, i + 1) // TODO: move to util } /** * Is url relative * @param url {string} * @returns {boolean} - if is relative * TODO: move to util */ function isRelative (url) { let ok = true if (/^[xh]ttp/.test(url)) { // xhttp is to indicate xframe header should be ignored return false } if (/^\//.test(url)) { // return false } if (window.lconfig.filename) { return false } return ok } // load subtree - separate leo file loaded into node function loadLeoNode (context, item) { console.log('LOADING SUBTREE') const p = new Promise((resolve, reject) => { const title = item.name const id = item.id let {url, label} = getUrlFromTitle(title) if (!url) { return } // this will fetch the leo file and convert to JSON with // the id prepended to the id in the fetched data getLeoJSON(url, id).then(data => { let text = data.textItems data = data.data context.commit('ADDTEXT', {text}) if (data.length === 1) { context.commit('RESET') // content item has not been drawn console.log('SUBTREE RESET', id) data = data[0] item.children = data.children // item.t = data.t item.name = label // convert to regular title so won't reload (remove the directive) context.dispatch('setCurrentItem', {id, reset: true}) } else { // TODO: trunkless load logic incomplete for subtrees item.name = label item.children = data } resolve(true) }) }).catch(e => console.log('Error: ', e)) return p } /** * clean up title for display, get url * @param title {String} This is an md format link, unless dataType is passed in * @param dataType {String} e.g. 'rgarticle', host will be in lconfig.dataSources, not extracted from title * @returns { url, label } */ function getUrlFromTitle (title, dataType) { let url = '' let label = '' title = title.replace(/^@[a-zA-Z-]*? /, '') let dataParams = null const re = /^\[(.*?)\]\((.*?)\)$/ const match = re.exec(title) if (dataType) { dataType = dataType.replace('-', '') dataParams = window.lconfig.dataSources[dataType] if (!dataParams) { console.log('No match for dataType:', dataType) return { url, label } } url = dataParams.host + title if (match) { label = match[1] url = dataParams.host + match[2] } else { label = title.replace(/_/g, ' ').replace(/^\d+/, '') // remove leading numbers } return { url, label } } if (!match) { return { url, label } } url = match[2] label = match[1] if (!url) { return null } if (isRelative(url)) { // url = 'static/' + url } // absolute urls require no further processing if (/^[xh]ttp/.test(url)) { // xttp will result in http call via proxy return {url, label} } // add doc file host if docfile is not on current host // const hostname = window.location.hostname let cname = window.lconfig.filename // const cnameUrl = new URL(cname) // const leoFileHostname = cnameUrl.host if (cname.indexOf('/') < 0) { cname = '' } if (cname && (cname.indexOf('http') > -1)) { let u = window.lconfig.filename u = util.chop(u, '#') u = util.chop(u, '?') u = util.chop(u, '/') url = u + '/' + url } return {url, label} } function showPresentation (context, title, id) { const dummy = Math.random() const iframeHTML = ` <div style="width:100%"> <iframe src="about:blank" height="100%" width="100%" marginwidth="0" marginheight="0" hspace="0" vspace="0" dummy="${dummy}" frameBorder="0" /> </div> ` context.commit('IFRAME_HTML', { iframeHTML }) context.commit('CONTENT_PANE', { type: 'site' }) } function showKanban (context, title, id) { let text = `<kanban/>` context.commit('CURRENT_ITEM_CONTENT', { text }) const newItem = { id, t: text } context.commit('CONTENT_ITEM', {item: newItem}) context.commit('CONTENT_ITEM_UPDATE') context.commit('CONTENT_PANE', { type: 'board' }) } function showMermaid (context, title, id) { let text = `<mermaid-board/>` context.commit('CURRENT_ITEM_CONTENT', { text }) const newItem = { id, t: text } context.commit('CONTENT_ITEM', {item: newItem}) context.commit('CONTENT_ITEM_UPDATE') context.commit('CONTENT_PANE', { type: 'board' }) } function checkCache (context, item, url) { // eslint-disable-line if (item.loaded) { console.log('Retrieving', url, ' content from cache.') let text = context.state.leotext[item.t] context.commit('CURRENT_ITEM_CONTENT', { text }) return true } return false } /** * Get JSON or XML from url, format and display * @param context * @param id * @param url{String} where to retrieve the data * @param xslType {String} xsl template name (in xsl.js or lodash_templates.js) * @param dataType {String} 'xsl' or 'json' * @param params {json} Extra data that will be added before template rendering * @param t {String} The text id (the node text should be yaml, will be converted to JSON and merged with incoming data) */ function showFormattedData (context, id, url, xslType, dataType, params, t) { let query = url // xttp means, route it through YQL if (/^xttp/.test(url)) { url = url.replace(/^xttp/, 'http') query = 'https://query.yahooapis.com/v1/public/yql?q=' + encodeURIComponent('select * from xml where url="' + url + '"') + '&format=xml' } /* const templateEngines = { 'xml': xsl, 'json': lodashTemplate } */ context.commit('CURRENT_ITEM_CONTENT', { text: spinnerHTML }) return axios.get(query) .then((response) => { let data = response.data if (dataType === 'json') { data.params = params if (params.nodeList) { data.params.displayType = 'board' } } // const currentText = context.state.leotext[t] // const text = hljs.highlight('javascript', JSON.stringify(data)).value let text = JSON.stringify(data, null, 2) context.state.leotext[t] = text if (dataType === 'xml') { return xsl.render(data, xslType).then(html => { showText(context, html, id, null, params) }) } const html = lodashTemplate.render(data, xslType) showText(context, html, id, null, params) if (params && params.nodeList) { const nodeList = params.nodeList let dataArray = data[nodeList.listKey] if (params.filter) { const filter = params.filter dataArray = _.filter(dataArray, o => _.get(o, filter.key, '') === filter.value) } const template = nodeList.template || '' addChildNodes(context.state, id, dataArray, template, nodeList.hrefIsQueryString, nodeList.hrefKey, nodeList.template, nodeList.titleKey) } }) .catch(function (error) { console.log(error) }) } /** * Given an array of data, convert to child nodes and add to item. * @param context: basically, the data store * @param parentId: id of the item to which child nodes will be appended. * @param data: the array to be converted into child nodes * @param template: template to use, e.g. rgarticle (see index.html for example) * @param urlIsQueryString: the url passed is not the full url (will be passed to template.host) * @returns {boolean} */ function addChildNodes (context, parentId, data, template, urlIsQueryString, hrefKey, childTemplate, titleKey) { if (!_.isArray(data)) { return } const item = JSON.search(context.leodata, '//*[id="' + parentId + '"]')[0] const children = [] if (template) { template = '-' + template } data.forEach((n, index) => { _.set(n, 'params.template', childTemplate) const id = parentId + '-' + index titleKey = titleKey || 'title.text' let titleText = _.get(n, titleKey, 'NO TITLE') let name = titleText let vtitle = titleText if (n.title) { vtitle = n.title.text } // urlTitle means the title of the list item will become a JSON url instead of a dataSet. if (hrefKey) { let url = _.get(n, hrefKey) if (urlIsQueryString) { url = '/' + url url = url.substring(url.lastIndexOf('/') + 1) } // e.g. @json-rgarticle [Article Title](Article URL) name = `@json${template} [${titleText}](${url})` } else { name = `@dataSet set${id} ${titleText}`// label } vtitle = vtitle.replace(/<</g, '\u00AB').replace(/>>/g, '\u00BB') const t = id context.leotext[t] = JSON.stringify(n) // TODO: does this need to be same format as other t (timestamp) children.push( { name, id, vtitle, t } ) }) // TODO: CURRENT load articles with proper template item.children = children return true } /** * * @param context * @param item * @param url {string} ISBN number * @param params {json} Additional info from node content that will get added to template compile. */ function showBook (context, item, url, params) { const id = item.id url = 'https://openlibrary.org/api/books?format=json&jscmd=data&bibkeys=ISBN:' + url context.commit('CURRENT_ITEM_CONTENT', { text: spinnerHTML }) axios.get(url) .then((response) => { let data = response.data const bookData = data[Object.keys(data)[0]] item.name = bookData.title.split(' ').map(w => _.capitalize(w)).join(' ') item.loaded = true data.params = params const html = lodashTemplate.render(data, 'openbooks') showText(context, html, id, null, params) context.commit('CONTENT_ITEM_UPDATE') context.state.leotext[item.t] = html }) .catch(function (error) { console.log(error) }) } /** * Create Leo outline from target url * @param context * @param item * @param id * @param subpath {String} If a literate url has been used, this is the subpath, e.g Dinosaur@Eytomology, 'Eytomology' will be thee subpath * @returns {Promise<any>} */ // TODO: move outline functionality into separate module function showPageOutline (context, item, id, subpath) { if (!id) { id = item.id } return new Promise((resolve, reject) => { let {url, label} = getUrlFromTitle(item.name) // eslint-disable-line if (!url) { return } let site = url const t = parserURI(url) let host = t.host let yql = "select * from htmlstring where url='" + site + "' AND xpath='//*'" let resturl = "https://query.yahooapis.com/v1/public/yql?q=" + encodeURIComponent(yql) + "&format=json&env=store%3A%2F%2Fdatatables.org%2Falltableswithkeys" //eslint-disable-line context.commit('CURRENT_ITEM_CONTENT', { text: spinnerHTML }) axios.get(resturl) .then((response) => { let dummy = document.getElementById('dummy') if (dummy) { dummy.outerHTML = '' } dummy = document.createElement('section') dummy.setAttribute('id', 'dummy') dummy.style.display = 'none' document.body.appendChild(dummy) let html = response.data.query.results.result // nasty hack to fix some pages on wikipedia (evolution chart is missing a tag) TODO: replace this with a parser let bad1 = html.indexOf('<div class="toccolours searchaux"') if (bad1) { let bad2 = html.indexOf('<div class="hatnote navigation-not-searchable"', bad1) html = html.substring(0, bad1) + html.substring(bad2) } html = cleanHTML(html, host) dummy.innerHTML = html let contentHTML = html // HACK select subnode if wikipedia let wikiContentEl = dummy.getElementsByClassName('mw-content-ltr')[0] if (wikiContentEl) { contentHTML = wikiContentEl.innerHTML } // HACK Gitbooks let gbContentEl = dummy.getElementsByClassName('markdown-section')[0] if (gbContentEl) { contentHTML = gbContentEl.innerHTML dummy = gbContentEl } contentHTML = '<div class="outline-pane">' + '<div class="note-box">' + 'Downloaded from ' + site + '</div>' + contentHTML + '</div>' const outline = HTML5Outline(dummy) const outlineItem = {} const textItems = {} counter = 0 // TODO: refactor this to remove external var outlineToItem(outline.sections[0], outlineItem, item.id, textItems, host) _.remove(outlineItem.children, c => c.name === 'Contents') const fullContentItem = { id: item.id + '-0', name: 'Full Page Content', t: item.id + '-0' } outlineItem.children.unshift(fullContentItem) const lines = [] // wikipedia hack const toc = document.getElementById('toc') if (toc && toc.previousElementSibling) { getPriorContent(toc.previousElementSibling, lines, host) } let priorContent = lines.reverse().join('') textItems[item.id + '-' + 1] = '<div class="fp-pane">' + priorContent + textItems[item.id + '-' + 1] + '</div>' textItems[item.id] = textItems[id + '-' + 1] // contentHTML textItems[item.id + '-0'] = contentHTML item.t = item.id context.commit('ADDTEXT', {text: textItems}) item.children[0] = outlineItem context.commit('RESET') // content item has not been drawn if (window.lconfig.path) { subpath = window.lconfig.path } if (subpath) { let pathObj = translatePath(subpath, context.state.leodata) id = pathObj.npath } context.commit('CURRENT_ITEM', {id}) context.commit('CURRENT_ITEM_CONTENT', { text: textItems[id] }) item.children = outlineItem.children resolve(true) }) .catch(function (error) { console.log(error) reject(error) }) }) } // TODO: possibly replace this with util.replaceRelUrls function replaceRelLinks (host, content) { if (!host || !content) { return content } // content = content.replace(/href="http/g, 'target="_blank" href="http') // content = content.replace(/href="\//g, 'target="_blank" href="//' + host + '/') content = content.replace(/href="([a-zA-Z])/g, 'target="_blank" href="//' + host + '/$1') content = content.replace(/src="\/([a-zA-Z])/g, 'src="//' + host + '/$1') content = content.replace(/srcset="\//g, 'srcset="//' + host + '/') content = content.replace(/, \/static\/images/g, ', ' + '//' + host + '/static/images') return content } /** * Used by outlines * @param startNode * @param lines */ function getLeadContent (startNode, lines, host) { const sectionTags = ['H1', 'H2', 'H3', 'H4', 'H5'] if (sectionTags.indexOf(startNode.tagName) > -1) { return } const nextSibling = startNode.nextElementSibling if (!nextSibling) { return } const cleanedHTML = cleanHTML(startNode.outerHTML, host) lines.push(cleanedHTML) getLeadContent(nextSibling, lines, host) } /** * Need this to fix html after retrieve from DOM * @param html * @returns {*} */ function cleanHTML (html, host) { // remove script tags html = html.replace(/<script(?:(?!\/\/)(?!\/\*)[^'"]|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\/\/.*(?:\n)|\/\*(?:(?:.|\s))*?\*\/)*?<\/script>/g, '') html = html.replace(/>\s+?,/g, '>,') html = html.replace(/>\s+?\./g, '>.') html = html.replace(/>\s+?["]/g, '>,"') html = html.replace(/&amp;#27;/g, "'") // eslint-disable-line html = html.replace(/\( "/g, '("') html = html.replace(/" \)/g, '")') html = replaceRelLinks(host, html) // html = html.replace(/>\s+?[.]/g, '.') return html } /** * For outlines: section prior to 2nd heading (for sites like Wikipedia * that don't have siblings to first heading). * @param startNode * @param lines */ function getPriorContent (startNode, lines, host) { const sectionTags = ['H1', 'H2', 'H3', 'H4', 'H5'] if (sectionTags.indexOf(startNode.tagName) > -1) { return } const prevSibling = startNode.previousElementSibling if (!prevSibling) { return } lines.push(cleanHTML(startNode.outerHTML, host)) getPriorContent(prevSibling, lines) } // TODO: do this without external var let counter = 0 function outlineToItem (outline, item, idBase, textItems, host) { if (!outline.heading) { return } counter = +counter + 1 item.id = idBase + '-' + counter item.name = getHeadingText(outline.heading.innerText) item.t = item.id const sections = outline.sections const html = getHTMLFromSection(outline, host) textItems[item.id] = html // if (!sections || !sections.length) { return } item.children = [] sections.forEach((section, i) => { let childItem = {} outlineToItem(section, childItem, +idBase, textItems, host) item.children.push(childItem) }) return item } function getHeadingText (h) { if (!h) { h = '' } try { h = h.replace(/\n/g, '').replace(/\[.*?\]/i, '').trim() } catch (e) { console.log('Error in outline parse heading:', e) } return h } function getHTMLFromSection (outline, host) { const html = [] const sections = outline.sections let content = '' const nextSibling = outline.startingNode.nextElementSibling if (nextSibling) { const contentArray = [] getLeadContent(nextSibling, contentArray, host) content = contentArray.join('') } if (content) { html.push(content) } sections.forEach(section => { let heading = getHeadingText(section.heading.innerText) if (heading === 'Contents') { return } // wikipedia specific heading = `<sectionlink :title="'${heading}'"/>` if (heading) { html.push(heading) } }) return '<div class="outline-pane">' + html.join('<br>') + '</div>' } function showD3Board (context, title, id) { let text = `<d3-board/>` context.commit('CURRENT_ITEM_CONTENT', { text }) const newItem = { id, t: text } context.commit('CONTENT_ITEM', {item: newItem}) context.commit('CONTENT_ITEM_UPDATE') context.commit('CONTENT_PANE', { type: 'board' }) } function showSite (context, title, id) { let {url, label} = getUrlFromTitle(title) // eslint-disable-line if (!url) { return } const ext = util.getFileExtension(url) const base = url.substring(0, url.lastIndexOf('/')) if (ext === 'md') { axios.get(url) .then((response) => { let html = md.render(response.data) html = '@language md\n<div class="md">' + html + '</div>' html = util.replaceRelUrls(html, base) showText(context, html, id) context.commit('CONTENT_PANE', {type: 'text'}) }) .catch(function (error) { console.log(error) }) } if (ext === 'xml') { showFormattedData(context, id, url, null, 'xml') } const iframeHTML = ` <div class=iframe-${ext} style="width:100%"> <iframe src="${url}" height="100%" width="100%" marginwidth="0" marginheight="0" hspace="0" vspace="0" frameBorder="0" /> </div> ` context.commit('IFRAME_HTML', {iframeHTML}) context.commit('CONTENT_PANE', {type: 'site'}) // context.commit('CONTENT_ITEM_UPDATE') } function setSiteItem (context, title, id) { let {url, label} = getUrlFromTitle(title) // eslint-disable-line if (!url) { return } const ext = util.getFileExtension(url) const base = url.substring(0, url.lastIndexOf('/')) context.commit('CURRENT_ITEM_CONTENT', { text: '<div class="spin-box"><div class="single10"></div></div>' }) if (ext === 'md') { axios.get(url) .then((response) => { let html = md.render(response.data) html = '@language md\n<div class="md">' + html + '</div>' html = util.replaceRelUrls(html, base) // html = util.formatText(html) const newItem = { id: id, t: html } context.commit('CONTENT_ITEM_UPDATE') context.commit('CONTENT_ITEM', {item: newItem}) }) .catch(function (error) { console.log('Unable to get MD file for processing!', error) }) return } const iframeHTML = ` <div class="vinline"> <iframe src="${url}" height="100%" width="100%" marginwidth="0" marginheight="0" hspace="0" vspace="0" frameBorder="0" /> </div> ` const newItem = { id: id, t: iframeHTML } // context.commit('IFRAME_HTML', {iframeHTML}) context.commit('CONTENT_ITEM', {item: newItem}) context.commit('CONTENT_ITEM_UPDATE') } /** * If the first node has a @cover directive, pop it off the data and return the node text content * @param ldata * @returns {string} */ function extractCover (ldata) { let cover = '' if (ldata.data[0].name.indexOf('@cover') > -1) { cover = ldata.textItems[ldata.data[0].t] ldata.data.shift() } return cover.replace('@language html', '') } /** * setData Set data loaded from the Leo file, get content for open items (from path) * (open items may need to loaded * from external sources, e.g. md files. Also process special nodes like @presentation and @page * @param context * @param ldata * @param filename * @param route */ function setData (context, ldata, filename, route) { context.commit('RESET') // content item has not been drawn context.commit('INIT_DATA') // loaded the leo data let cover = extractCover(ldata) // cover page, pull out any nodes with @cover directive const text = ldata.textItems context.commit('LEO', { data: ldata.data, text, filename: filename, cover: cover }) loadDataSets(context, ldata) loadDataTables(context, ldata) if (_.isArray(ldata.data)) { ldata.data.forEach(d => { loadPresentations(d) }) } else { loadPresentations(ldata.data) } setLanguageNodes(context, ldata) setChildDirectives(context, ldata) if (_.isArray(ldata.data)) { ldata.data.forEach(d => { loadPages(d, text) }) } else { loadPages(ldata.data, text) } getTagList(context, ldata) cleanText(text) // remove metadata from text const parentTable = {} buildParentTable(ldata.data, text, parentTable) // for each text item, get an array of parents (if not clone array will have one member) context.commit('PARENTTABLE', { parentTable }) // TODO: refactor use of id vs route.path let id = route.params.id // check if path is a literate path, translate to number (look up matching node name) const pathObj = translatePath(id, ldata.data) id = pathObj.npath if (!id) { id = '1' } // TODO: use vuex-router const match = route.path.match(/\/(\w)\//) let pathType = 't' if (match) { pathType = match[0] } pathType = pathType.replace(/\//g, '') context.commit('VIEW_TYPE', {type: pathType}) let path = route.path // see if the path includes a subtree (a child leo file) let npath = null if (path) { npath = path.substring(path.indexOf('/', 2) + 1) } let subtrees = [] let subpath = '' // case of literate path in subtree if (npath) { // translate a literate path to number, TODO: remove duplicate, this is called above ({ npath, subpath } = translatePath(npath, ldata.data)) // a subtree is a leo file loaded at a node subtrees = getRoots([], npath) } loadSubtrees(context, subtrees, ldata.data, id, subpath, npath).then(() => { console.log('SUBPATH', subpath) context.commit('SUBPATH', {subpath}) const openItems = JSON.search(ldata.data, '//*[id="' + id + '"]/ancestor::*') if (!openItems) { return } if (!openItems.length) { return } const openItemIds = openItems.reduce((acc, o) => { if (o.id) { acc.push(o.id + '') } return acc }, []) openItemIds.push(id + '') context.commit('OPEN_ITEMS', {openItemIds}) const ids = openItemIds context.dispatch('setContentItems', {ids}) context.dispatch('setCurrentItem', {id}) }) loadLocalMD(context, ldata) } /** * Load local markdown files * @param data */ function loadLocalMD (context, ldata) { const titles = ldata.data const textItems = ldata.textItems const paths = getLocalMDPaths(titles, []) paths.forEach(path => { const base = path.url.substring(0, path.url.lastIndexOf('/')) axios.get(path.url) .then((response) => { let html = md.render(response.data) html = '@language md\n<div class="md">' + html + '</div>' html = util.replaceRelUrls(html, base) path.item.name = path.label textItems[path.item.t] = html indexPaths(context, paths, path) }) .catch(error => { indexPaths(context, paths, path) console.log('Cache load error:', error) }) }) } function indexPaths (context, paths, path) { path.complete = true let complete = true paths.forEach(p => { if (!p.complete) { complete = false } }) if (complete) { console.log('local md files loaded, resetting index.') context.commit('RESETINDEX') } } function getLocalMDPaths (data, arr) { if (arr.length > 20) { return arr } data.name = data.name || '' const {url, label} = getUrlFromTitle(data.name) if (/\.md$/.test(url) && !/http/.test(url)) { const id = data.id const item = data arr.push({url, id, label, item}) } let children = data.children if (_.isArray(data)) { children = data } if (!children) { return arr } children.forEach(d => { getLocalMDPaths(d, arr) }) return arr } /** * Given a word path, find the matching node * @param p * @param d * @returns {{npath: *, subpath: string}} */ function translatePath (p, d) { let item = null let subpath = '' if (/^[A-Za-z]/.test(p)) { let uArray = p.split('*') if (uArray.length > 1) { p = uArray[0] subpath = _.last(uArray) } let pArray = p.split('~') let p2 = '' if (pArray.length === 2) { p = _.last(pArray) p2 = pArray[0] } if (p2) { item = JSON.search(d, '//*[name="' + p + '" and boolean(ancestor::*[name="' + p2 + '"])]') } else { // item = JSON.search(d, '//*[name="' + p + '"]') item = JSON.search(d, '//*[name[contains(.,"' + p + '")]]') } if (item && item[0]) { p = item[0].id } else { p = '1' } } return { npath: p, subpath } } /** * Mark all of the @page item children so that when selected they * will navigate to page/anchor, not load new item * @param data * @param loadSections */ function loadPages (item, textItems) { let p = /^@page /.test(item.name) if (p) { console.log('loading paged/inline items', item.name) loadPage(item, textItems) } item.children.forEach(d => loadPages(d, textItems)) } function loadPage (item, textItems) { const id = item.id const t = item.t const pages = item.children item.page = { pid: id, id, index: 0 } if (!pages) { return } // create the page content by combining the child content, and mark each child as a page child const title = `<h1 id="x${id}-${id}" class="x-section">${item.vtitle}</h1>` let content = util.formatText(textItems[t], true, title) const arr = [] pages.forEach((page, index) => { // add to page content const title = `<h2 id="x${id}-${page.id}" class="x-section">${page.name}</h2>\n` arr.push(util.formatText(textItems[page.t], true, title)) page.page = { pid: id, id: page.id, index: index + 1 } // pid is the id of the @page item, id is item id, index is offset of child }) // set the page content content = content + arr.join('') textItems[t] = content // `<div class='content'>${content}</div>` // console.log(content) } /** * Mark all of the presentation pages/items so that when selected they * will be displayed in presentation, not as basic content * @param data * @param loadSections */ function loadPresentations (data, loadSections) { let p = /@presentation ([a-zA-Z0-9]*)(.*)$/.test(data.name) if (p) { loadSections = true } let a = /^« /.test(data.name) if (p || (a && loadSections)) { // if (p) { console.log('loading', data.name) loadPresentation(data.id, data.children) } data.children.forEach(d => loadPresentations(d, loadSections)) } function loadPresentation (id, pages) { if (!pages) { return } pages.forEach((page, index) => { page.presentation = { pid: id, index } }) } /** * Used to load subtrees when navigating directly to a node. * @param context * @param trees {Array} Array of nodes to be preloaded. * @param data * @param topId * @param subpath * @returns {*} */ function loadSubtrees (context, trees, data, topId, subpath) { if (!trees.length) { return Promise.resolve() } let item = JSON.search(data, '//*[id="' + trees[0] + '"]')[0] let {url, label} = getUrlFromTitle(item.name) if (url.match(/\.json$/)) { let itemText = context.state.leotext[item.t].replace(/^@.*?\n/, '') let params = jsyaml.load(itemText) || {} const template = params.template || '' if (params.params) { params = params.params } // change directive from @json to @dataSet so that data will not be reloaded // and can be accessed by other nodes item.name = `@dataSet set${item.id} ${label}` return showFormattedData(context, item.id, url, template, 'json', params, item.t, topId) } context.commit('CURRENT_ITEM_CONTENT', { text: '<div class="spin-box"><div class="single10"></div></div>' }) // special case of outlining a page, e.g. wikipedia if (/^@outline/.test(item.name)) { return showPageOutline(context, item, topId, subpath) } const p = new Promise((resolve, reject) => { // TODO: this just loads the first subtree, need to load all in trees array for case of nested subtrees // TODO: implement subPat in leo subtree loadLeoNode(context, item).then(res => { resolve(res) // const item = JSON.search(data, '//*[id="' + topId + '"]')[0] console.log('ITEM', topId, item) }) }) return p } function extractTags (textItems, textIndex) { const text = textItems[textIndex] let tags = [] const startIndex = text.indexOf('@t\n') if (startIndex < 1) { return tags } let endIndex = text.indexOf('\n\n', startIndex) let mText = null // metatext // let cText = null // clean text if (endIndex === -1) { mText = text.substring(startIndex + 3) // cText = text.substring(0, startIndex) } else { mText = text.substring(startIndex + 3, endIndex) // cText = text.substring(0, startIndex) + text.substring(endIndex) } tags = mText.split('\n') // textItems[textIndex] = cText return tags } function extractMetaData (textItems, textIndex) { const text = textItems[textIndex] let metadata = {} const startIndex = text.indexOf('@m\n') if (startIndex < 0) { return null } const endIndex = (text + '\n\n').indexOf('\n\n', startIndex) const mText = text.substring(startIndex + 3, endIndex) const cText = text.substring(0, startIndex) + text.substring(endIndex) textItems[textIndex] = cText // clean text try { if (mText.indexOf('}') === -1) { metadata = jsyaml.load(mText) || {} } else { metadata = JSON.parse(mText) } } catch (e) { console.log('Bad metadata:', mText, startIndex, endIndex, e) } // console.log('metadata:', metadata) return metadata } function getTagList (context, data) { const tags = {} data.data.forEach(d => { pushTags(d, tags) }) context.commit('SETTAGLIST', {tags}) } function pushTags (d, tags) { d.tags && d.tags.forEach(tag => { tags[tag.text] = 1 }) d.children.forEach(child => pushTags(child, tags)) } function cleanText (textItems) { const keys = Object.keys(textItems) for (let i = 0; i < keys.length; i++) { const key = keys[i] textItems[key] = removeMetadata(textItems[key]) } } function buildParentTable (item, textItems, table) { _.isArray(item) ? item.forEach(i => buildParentTable(i, textItems, table)) : item.children.forEach(child => buildParentTable(child, textItems, table)) const t = item.t if (table[t]) { } table[t] = table[t] ? table[t].push(item.id) && table[t] : [item.id] } function removeMetadata (text) { const lines = text.split(/\n/) const cleanLines = [] let flag = true let commentFlag = false for (let i = 0; i < lines.length; i++) { const line = lines[i] if (/^```/.test(line)) { commentFlag = !commentFlag } if (/^@[tm]/.test(line)) { flag = false } if (!flag && !line) { flag = true } if (flag || commentFlag) { cleanLines.push(line) } } return cleanLines.join('\n') } /** Recursively preprocess tree e.g add language directives to subtrees of existing language directives, extract metadata, extract group info for each node */ function setChildDirectives (context, data) { const textItems = data.textItems data.data.forEach(d => { setChildDirective(context, d, textItems) }) } function setChildDirective (context, d, textItems, parentDirective) { d.metadata = extractMetaData(textItems, d.t) d.tags = extractTags(textItems, d.t).map(t => { return { 'text': t } }) let text = textItems[d.t] // check for @group and @mgroup, add if found add param const title = d.name let rex = /@group-(.*?) / let match = rex.exec(title) if (match && match[1]) { d.group = match[1] } rex = /@mgroup-(.*?) / match = rex.exec(title) if (match && match[1]) { d.mgroup = match[1] } // language directive const re = /^(@language \w+)/ let languageDirective = re.exec(text) if (languageDirective) { languageDirective = languageDirective[1] } if (parentDirective && !languageDirective) { if (/^{/.test(textItems[d.t])) { return // skip if the text item is JSON data } textItems[d.t] = parentDirective + '\n' + textItems[d.t] languageDirective = parentDirective } d.children.forEach(child => { setChildDirective(context, child, textItems, languageDirective) }) } // for @clean nodes, set children with @language of extension function setLanguageNodes (context, data) { const textItems = data.textItems data.data.forEach(d => { setLanguageNode(context, d, textItems) }) // window.lconfig.dataSets = context.state.dataSets } function setLanguageNode (context, d, textItems) { const title = d.name let language = '' if (/^\s*@clean/.test(title)) { var re = /(?:\.([^.]+))?$/ var ext = re.exec(title)[1] var ng = ['txt', 'md', 'html'] if (ng.indexOf(ext) === -1) { language = ext } const langs = { js: 'javascript', ts: 'typescript', py: 'python', java: 'java', c: 'c' } if (langs[ext]) { language = langs[ext] } } if (language) { return addDirectiveToSubTree(d, '@language ' + language, textItems) } d.children.forEach(child => { setLanguageNode(context, child, textItems) }) } /** * e.g. add '@language javascript' to this item and all below * @param subtree * @param directive * @param textItems */ function addDirectiveToSubTree (subtree, directive, textItems) { const text = textItems[subtree.t] if (!/^@/.test(text)) { textItems[subtree.t] = directive + '\n' + text } subtree.children.forEach(child => { addDirectiveToSubTree(child, directive, textItems) }) } /** * Load all of the dataSet nodes (e.g. the JSON in the node) into a hash. * @param context * @param data */ function loadDataSets (context, data) { const textItems = data.textItems data.data.forEach(d => { loadDataSet(context, d, textItems) }) window.lconfig.dataSets = context.state.dataSets } function loadDataSet (context, item, textItems) { const text = textItems[item.t] const matches = item.name.match(/@dataSet ([a-zA-Z0-9-]*)(.*)$/) if (matches) { const k = _.trim(matches[1]) let v = text.replace(/^@language (\w+)/, '') // get rid of language directive try { v = JSON.parse(v) } catch (e) { console.log('Unable to parse data for: ' + item.name + ' ' + e) } context.commit('ADDDATASET', {k, v}) } item.children.forEach(child => { loadDataSet(context, child, textItems) }) } /** * Datatables are nodes with csv. Parse and load these into storage for use by charts etc. * @param context * @param data */ function loadDataTables (context, data) { const textItems = data.textItems data.data.forEach(d => { loadDataTable(context, d, textItems) }) window.lconfig.dataTables = context.state.dataTables } function loadDataTable (context, item, textItems) { const text = textItems[item.t] const matches = item.name.match(/@dataTable ([a-zA-Z0-9]*)(.*)$/i) if (matches) { const k = _.trim(matches[1]) const title = matches[2] || '' let v = text.replace(/^@language (\w+)/, '') // get rid of language directive let arr = null try { arr = Papa.parse(v).data } catch (e) { arr = [] console.log('Unable to parse dataTable for: ' + item.name + ' ' + e) } arr.forEach(r => { r.forEach((c, i) => { r[i] = _.trim(c) }) }) const objArr = [] const cols = arr[0] for (let r = 1; r < arr.length; r++) { let row = arr[r] let rowObj = {} for (let c = 0; c < row.length; c++) { let colName = cols[c] if (colName.indexOf('$') === -1) { rowObj[colName] = row[c] } } // let obj = arr[r].reduce((acc, v, i) => { acc[cols[i]] = v; return acc }, {}) objArr.push(rowObj) } v = {title, arr, objArr} context.commit('ADDDATATABLE', {k, v}) textItems[item.t] = `<div class="hcode"><pre>${text}</pre></div>` } item.children.forEach(child => { loadDataTable(context, child, textItems) }) } // ========= The Store =============== export default new Vuex.Store({ state: { leotext: {}, // hash of text (content) items leodata: {}, // the nodes (items) parentTable: {}, // lookup table of node ids for each text item filename: '', initialized: false, initializedData: false, contentPane: 'text', viewType: 't', cover: '', currentItem: { id: 0, next: 0, prev: 0, type: 'item' }, currentItemContent: '', contentItems: {}, currentPage: { id: 0 }, dataSets: {}, dataTables: {}, openItemIds: [], history: [0], historyIndex: 0, iframeHTML: '', contentItemsUpdateCount: 0, idx: null, accordion: false, accordionPrev: false, searchFlag: false, selecting: false, // e.g. in search dialog using arrow keys subpath: '' }, mutations: { ADDDATASET (state, o) { state.dataSets[o.k] = o.v }, ADDDATATABLE (state, o) { if (state.dataTables[o.k]) { return } // duplicate names are ignored (could be a clone node) state.dataTables[o.k] = o.v }, TOGGLEACCORDION (state) { state.accordion = !state.accordion }, SETSEARCHFLAG (state) { state.searchFlag = true }, SELECTINGON (state) { if (!state.searchFlag) { state.accordionPrev = state.accordion } state.accordion = true }, SELECTINGOFF (state) { state.searchFlag = false state.accordion = state.accordionPrev }, LEO (state, o) { state.leodata = o.data state.leotext = o.text const c = loadIndex(o.data, o.text) state.idx = c.idx state.idxDocs = c.docs state.cover = o.cover state.filename = o.filename window.lconfig.leodata = o.data window.lconfig.leotext = o.text }, RESETCOVER (state, o) { // set the cover page content state.cover = o.cover }, // lunr search index RESETINDEX (state, o) { const c = loadIndex(state.leodata, state.leotext) state.idx = c.idx state.idxDocs = c.docs // state.filename = o.filename }, ADDTEXT (state, o) { const text = o.text for (let k in text) { state.leotext[k] = text[k] } }, SETTAGLIST (state, o) { state.tags = Object.keys(o.tags) }, INIT (state) { state.initialized = true }, INIT_DATA (state) { state.initializedData = true }, RESET (state) { state.initialized = false }, CONTENT_PANE (state, o) { state.contentPane = o.type }, IFRAME_HTML (state, o) { state.iframeHTML = o.iframeHTML }, VIEW_TYPE (state, o) { state.viewType = o.type }, CURRENT_ITEM_CONTENT (state, o) { state.currentItemContent = o.text }, CURRENT_PAGE (state, o) { const id = o.id state.currentPage.id = id if (+id === 0) { return } let routeName = state.route.name if (routeName === 'Top') { routeName = 'Node' } router.replace({name: routeName, params: { id }}) }, // for inline content, keep hash of content items CONTENT_ITEM (state, o) { const item = o.item state.contentItems[item.id] = item.t }, CONTENT_ITEM_UPDATE (state, o) { state.contentItemsUpdateCount = state.contentItemsUpdateCount + 1 }, CURRENT_ITEM (state, o) { const id = o.id // check current for identical if (o.id === state.currentItem.id) { return } // TODO: check prev/next for identical before change const nextSibling = JSON.search(state.leodata, '//children[id="' + id + '"]/following-sibling::*') const prevSibling = JSON.search(state.leodata, '//children[id="' + id + '"]/preceding-sibling::children') let next = 0 let prev = 0 if (nextSibling[0]) { next = nextSibling[0].id } if (prevSibling[0]) { prev = prevSibling[prevSibling.length - 1].id } if (id - prev !== 1) { prev = 0 } if (next - id !== 1) { next = 0 } state.currentItem.id = id state.currentItem.prev = prev state.currentItem.next = next let routeName = state.route.name if (routeName === 'Top') { routeName = 'Node' } router.replace({name: routeName, params: { id }}) if (typeof o.historyIndex !== 'undefined') { state.historyIndex = o.historyIndex } else { state.history.push(id) state.historyIndex = state.historyIndex + 1 } state.initialized = false }, OPEN_ITEMS (state, o) { const ids = state.openItemIds ids.splice(0, ids.length) ids.push(...o.openItemIds) }, SUBPATH (state, o) { state.subpath = o.subpath }, PARENTTABLE (state, o) { state.parentTable = o.parentTable } }, actions: { setMessages (context) { window.addEventListener('message', function (event) { if (!event.data) { return } if (!Object.keys(event.data).length) { return } let data = {} if (_.isObject(event.data)) { data = event.data } else { try { data = JSON.parse(event.data) } catch (e) { console.log('msg:', event.data) } } // a message from main app telling us to scroll content if (data.namespace === 'leovue' && data.eventName === 'setcurrentsection') { const id = data.state.indexh context.dispatch('setCurrentPageSection', {id}) } // a message from main app telling us to switch items if (data.namespace === 'leovue' && data.eventName === 'setcurrentitem') { const id = data.state.id context.dispatch('setCurrentItem', {id}) } // a message from a presentation telling us page has changed if (data.namespace === 'reveal' && data.eventName === 'slidechanged') { const id = data.state.indexh context.dispatch('setCurrentPage', {id}) // Slide changed, see data.state for slide number } }) }, loadLeo (context, o) { getLeoJSON(o.filename, o.id).then(ldata => { setData(context, ldata, o.filename, o.route) }) }, loadLeoFromXML (context, o) { transformLeoXML(o.xml).then(ldata => { setData(context, ldata, 'dnd', o.route) }) }, // Given a list of ids, get the content. Needed for // inline mode and displaying content in a path. setContentItems (context, o) { const ids = o.ids ids.forEach(id => { let item = JSON.search(context.state.leodata, '//children[id="' + id + '"]') if (item && item[0]) { item = item[0] // if it starts with a bracket it is a link in markdown syntax if (/^\[/.test(item.name)) { setSiteItem(context, item.name, item.id) } else { let text = context.state.leotext[item.t] text = util.formatText(text) const newItem = { t: text, id: id } context.commit('CONTENT_ITEM', {item: newItem}) context.commit('CONTENT_ITEM_UPDATE') } } }) }, // TODO refactor, too slow. May need to replace XPath with refs in item changeCurrentItem (context, o) { // const prevSibling = JSON.search(state.leodata, '//children[id="' + id + '"]/preceding-sibling::children') let id = context.state.currentItem.id const currentItem = JSON.search(context.state.leodata, '//*[id="' + id + '"]')[0] if (!currentItem) { console.log('No current item!') return } switch (o.direction) { case 'down': if (currentItem.children && currentItem.children.length) { id = currentItem.children[0].id return context.dispatch('setCurrentItem', {id}) } const nextSibling = JSON.search(context.state.leodata, '//children[id="' + id + '"]/following-sibling::*') if (!goToAdjacentItem(context, nextSibling)) { const parent = JSON.search(context.state.leodata, '//*[id="' + id + '"]/parent::*') if (parent && parent[0] && parent[0].id) { const parentSibling = JSON.search(context.state.leodata, '//children[id="' + parent[0].id + '"]/following-sibling::*') goToAdjacentItem(context, parentSibling) } } break case 'up': const prevSibling = JSON.search(context.state.leodata, '//children[id="' + id + '"]/preceding-sibling::children') if (!prevSibling || !prevSibling.length) { const parent = JSON.search(context.state.leodata, '//*[id="' + id + '"]/parent::*') if (parent && parent[0]) { id = parent[0].id context.dispatch('setCurrentItem', {id}) } console.log('PARENT', parent) } if (prevSibling && prevSibling.length) { id = prevSibling[prevSibling.length - 1].id console.log('new id', id) context.dispatch('setCurrentItem', {id}) } break default: } }, setCurrentPageSection (context, o) { let id = o.id context.commit('CURRENT_PAGE', {id}) }, setCurrentPage (context, o) { let page = +o.id let id = context.state.currentItem.id // if we're setting a page, we're displaying a presentation const presentationNode = JSON.search(context.state.leodata, '//*[id="' + id + '"]')[0] const pageNode = presentationNode.children[page] if (!pageNode) { id = 0 } else { id = pageNode.id // the actual curre