diff --git a/.gitignore b/.gitignore index 38d8759..f3bf04b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules npm-debug.log status +coverage diff --git a/ReadMe.md b/ReadMe.md index 5a62fbb..576a0b0 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -18,6 +18,16 @@ This plugin, type: graphviz, extends the markup of the federated wiki. # visit http://localhost:3000 to test the plugin revisions +## Test development workflow + + # install github code spaces utilities + npm i -g c8 + npm i -g http-server + + # run tests with coverage details + c8 -r 'lcov' npx mocha + (cd coverage/lcov-report/; http-server) + ## Release workflow npm version patch diff --git a/client/graphviz.js b/client/graphviz.js index 270f311..27e2b18 100644 --- a/client/graphviz.js +++ b/client/graphviz.js @@ -1,5 +1,6 @@ (function() { let moduleLoaded; + const asSlug = (title) => title.replace(/\s/g, '-').replace(/[^A-Za-z0-9-]/g, '').toLowerCase() // https://github.com/hpcc-systems/hpcc-js-wasm // https://github.com/fedwiki/wiki/issues/63 @@ -46,70 +47,40 @@ ${item.dot??''}` return {...item, text}; } - async function makedot($item, item) { - const {asSlug} = wiki; - let text = item.text; - let m; - if (m = text.match(/^DOT FROM ([a-z0-9-]+)($|\n)/)) { - let site = $item.parents('.page').data('site')||location.host - let slug = m[1] - let page = $item.parents('.page').data('data') - let poly = await polyget({name: slug, site, page}) - if (page = poly.page) { - let redirect = page.story.find(each => each.type == 'graphviz') - if (redirect) { - text = redirect.text - } - } - if (text == item.text) { - return trouble("can't do", item.text) - } - } - if (m = text.match(/^DOT ((strict )?(di)?graph)\n/)) { - var root = tree(text.split(/\r?\n/), [], 0) - root.shift() - var $page = $item.parents('.page') - var here = $page.data('data') - var context = { - graph: m[1], - name: here.title, - site: $page.data('site')||location.host, - page: here, - want: here.story.slice() + // A L G O R I T H M I C D R A W I N G + + function tree(lines, here, indent) { + while (lines.length) { + let m = lines[0].match(/( *)(.*)/) + let spaces = m[1].length + let command = m[2] + if (spaces == indent) { + here.push(command) + lines.shift() + } else if (spaces > indent) { + var more = [] + here.push(more) + tree(lines, more, spaces) + } else { + return here } - var dot = await evalTree(root, context, []) - return `${context.graph} {${dot.join("\n")}}` - } else { - return text } + return here + } - function tree(lines, here, indent) { - while (lines.length) { - let m = lines[0].match(/( *)(.*)/) - let spaces = m[1].length - let command = m[2] - if (spaces == indent) { - here.push(command) - lines.shift() - } else if (spaces > indent) { - var more = [] - here.push(more) - tree(lines, more, spaces) - } else { - return here + async function polyget (context) { + + async function probe (site, slug) { + if (site === 'local') { + const localPage = localStorage.getItem(slug) + if (!localPage) { + throw new Error('404 not found') } + return JSON.parse(localPage) + } else { + // get returns a promise from $.ajax for relevant site adapters + return wiki.site(site).get(`${slug}.json`, () => null) } - return here - } - - function quote (string) { - const quoted = string.replace(/ +/g,'\n').replace(/"/g,'\\"') - return `"${quoted}"` - } - - function trouble (text, detail) { - // console.log(text,detail) - throw new Error(text + "\n" + detail) } function collaborators (journal, implicit) { @@ -123,35 +94,40 @@ ${item.dot??''}` .filter((site,pos)=>sites.indexOf(site)==pos) } - async function probe (site, slug) { - if (site === 'local') { - const localPage = localStorage.getItem(slug) - if (!localPage) { - throw new Error('404 not found') + if (context.name == context.page.title) { + return {site: context.site, page: context.page} + } else { + let slug = asSlug(context.name) + + + let origin = 'localhost' + if (typeof location !== 'undefined') origin = location.host + let sites = collaborators(context.page.journal, [context.site, origin, 'local']) + + for (let site of sites) { + try { + const page = await (context.probe || probe)(site,slug) + return {site, page} + } catch (err) { + // 404 not found errors expected } - return JSON.parse(localPage) - } else { - // get returns a promise from $.ajax for relevant site adapters - return wiki.site(site).get(`${slug}.json`, () => null) } + return null } + } - async function polyget (context) { - if (context.name == context.page.title) { - return {site: context.site, page: context.page} - } else { - let slug = asSlug(context.name) - let sites = collaborators(context.page.journal, [context.site, location.host, 'local']) - for (let site of sites) { - try { - return {site, page: await probe(site,slug)} - } catch (err) { - // 404 not found errors expected - } - } - return null - } + async function evalTree(tree, context, dot) { + console.log('eval',tree) + + function quote (string) { + const quoted = string.replace(/ +/g,'\n').replace(/"/g,'\\"') + return `"${quoted}"` + } + + function trouble (text, detail) { + // console.log(text,detail) + throw new Error(text + "\n" + detail) } function graphData(here, text) { @@ -197,184 +173,227 @@ ${item.dot??''}` return graph; } - async function evalTree(tree, context, dot) { - let deeper = [] - var pc = 0 - while (pc < tree.length) { - let ir = tree[pc++] - const nest = () => (pc < tree.length && Array.isArray(tree[pc])) ? tree[pc++] : [] - const peek = (keyword) => pc < tree.length && tree[pc]==keyword && pc++ - - if (Array.isArray(ir)) { - deeper.push({tree:ir, context}) - - } else if (ir.match(/^[A-Z]/)) { - - if (ir.match(/^LINKS/)) { - let text = context.want.map(p=>p.text).join("\n") - let links = (text.match(/\[\[.*?\]\]/g)||[]).map(l => l.slice(2,-2)) + let deeper = [] + var pc = 0 + while (pc < tree.length) { + let ir = tree[pc++] + let m + const nest = () => (pc < tree.length && Array.isArray(tree[pc])) ? tree[pc++] : [] + const peek = (keyword) => pc < tree.length && tree[pc]==keyword && pc++ + + if (Array.isArray(ir)) { + deeper.push({tree:ir, context}) + + } else if (ir.match(/^[A-Z]/)) { + + if (ir.match(/^LINKS/)) { + let text = context.want.map(p=>p.text).join("\n") + let links = (text.match(/\[\[.*?\]\]/g)||[]).map(l => l.slice(2,-2)) + let tree = nest() + links.map((link) => { + if (m = ir.match(/^LINKS HERE (->|--) NODE/)) { + dot.push(`${quote(context.name)} ${m[1]} ${quote(link)}`) + } else + if (m = ir.match(/^LINKS NODE (->|--) HERE/)) { + dot.push(`${quote(link)} ${m[1]} ${quote(context.name)}`) + } else + if (!ir.match(/^LINKS$/)) { + trouble("can't do link", ir) + } + if (tree.length) { + let new_context = Object.assign({},context,{name:link}) + new_context.promise = polyget(new_context) + deeper.push({tree, context:new_context}) + } + }) + } else + + if (ir.match(/^BACKLINKS/)) { + const backlinks = context.backlinks ?? wiki.neighborhoodObject.backLinks + if (! backlinks) { + console.error("graphviz plugin skipping backlinks because wiki-client is missing backlinks", ir) + } else { + let links = Object.values(backlinks(asSlug(context.name))).map(bl => bl.title) let tree = nest() links.map((link) => { - if (m = ir.match(/^LINKS HERE (->|--) NODE/)) { + if (m = ir.match(/^BACKLINKS HERE (->|--) NODE/)) { dot.push(`${quote(context.name)} ${m[1]} ${quote(link)}`) } else - if (m = ir.match(/^LINKS NODE (->|--) HERE/)) { - dot.push(`${quote(link)} ${m[1]} ${quote(context.name)}`) - } else - if (!ir.match(/^LINKS$/)) { - trouble("can't do link", ir) - } + if (m = ir.match(/^BACKLINKS NODE (->|--) HERE/)) { + dot.push(`${quote(link)} ${m[1]} ${quote(context.name)}`) + } else + if (!ir.match(/^BACKLINKS$/)) { + trouble("can't do backlink", ir) + } if (tree.length) { let new_context = Object.assign({},context,{name:link}) new_context.promise = polyget(new_context) deeper.push({tree, context:new_context}) - } + } }) - } else - - if (ir.match(/^BACKLINKS/)) { - if (! wiki.neighborhoodObject.backLinks) { - console.error("graphviz plugin skipping backlinks because wiki-client is missing backlinks", ir) - } else { - let backlinks = wiki.neighborhoodObject.backLinks(asSlug(context.name)) - let links = Object.values(backlinks).map(bl => bl.title) - let tree = nest() - links.map((link) => { - if (m = ir.match(/^BACKLINKS HERE (->|--) NODE/)) { - dot.push(`${quote(context.name)} ${m[1]} ${quote(link)}`) - } else - if (m = ir.match(/^BACKLINKS NODE (->|--) HERE/)) { - dot.push(`${quote(link)} ${m[1]} ${quote(context.name)}`) - } else - if (!ir.match(/^BACKLINKS$/)) { - trouble("can't do backlink", ir) - } - if (tree.length) { - let new_context = Object.assign({},context,{name:link}) - new_context.promise = polyget(new_context) - deeper.push({tree, context:new_context}) + } + } else + + if (ir.match(/^GRAPH$/)) { + for (let item of context.want) { + if (item.type == 'graph') { + let graph = graphData(context.name, item.text) + let kind = context.graph.match(/digraph/) ? '->' : '--' + for (let here in graph) { + dot.push(`${quote(here)}`) + for (let there of graph[here]) { + dot.push(`${quote(here)} ${kind} ${quote(there)}`) } - }) + } } - } else + } + } else - if (ir.match(/^GRAPH$/)) { - for (let item of context.want) { - if (item.type == 'graph') { - let graph = graphData(context.name, item.text) - let kind = context.graph.match(/digraph/) ? '->' : '--' - for (let here in graph) { - dot.push(`${quote(here)}`) - for (let there of graph[here]) { - dot.push(`${quote(here)} ${kind} ${quote(there)}`) - } - } - } + if (ir.match(/^HERE/)) { + let tree = nest() + let page = null + let site = '' + try { + if(context.promise) { + let poly = await context.promise + site = poly.site + page = poly.page + delete context.promise + } else { + let poly = await polyget(context) + site = poly.site + page = poly.page } - } else + } catch (err) {} - if (ir.match(/^HERE/)) { + let m + if (page) { + if (ir.match(/^HERE NODE$/)) { + dot.push(quote(context.name)) + } else + if (m = ir.match(/^HERE NODE "?([\w\s]+)/)) { + let kind = context.graph.match(/digraph/) ? '->' : '--' + dot.push(`${quote(m[1])} ${kind} ${quote(context.name)} [style=dotted]`) + } else + if (!ir.match(/^HERE$/)) { + trouble("can't do here", ir) + } + deeper.push({tree, context:Object.assign({},context,{site, page, want:page.story})}) + } + if (peek('ELSE')) { let tree = nest() - let page = null - let site = '' - try { - if(context.promise) { - let poly = await context.promise - site = poly.site - page = poly.page - delete context.promise - } else { - let poly = await polyget(context) - site = poly.site - page = poly.page - } - } catch (err) {} - - let m - if (page) { - if (ir.match(/^HERE NODE$/)) { - dot.push(quote(context.name)) - } else - if (m = ir.match(/^HERE NODE "?([\w\s]+)/)) { - let kind = context.graph.match(/digraph/) ? '->' : '--' - dot.push(`${quote(m[1])} ${kind} ${quote(context.name)} [style=dotted]`) - } else - if (!ir.match(/^HERE$/)) { - trouble("can't do here", ir) - } - deeper.push({tree, context:Object.assign({},context,{site, page, want:page.story})}) + if (!page) { + deeper.push({tree, context}) } - if (peek('ELSE')) { - let tree = nest() - if (!page) { - deeper.push({tree, context}) + } + } else + + if (ir.match(/^WHERE/)) { + let tree = nest() + var want = context.want + if (m = ir.match(/\/.*?\//)) { + let regex = new RegExp(m[0].slice(1,-1)) + want = want.filter(item => (item.text||'').match(regex)) + } else if (m = ir.match(/ FOLD ([a-z_-]+)/)) { + var within = false + want = want.filter((item) => { + if (item.type == 'pagefold') { + within = item.text == m[1] } - } + return within + }) + } else if (m = ir.match(/[a-z_]+/)) { + let attr = m[0] + want = want.filter(item => item[attr]) + } else trouble("can't do where", ir) + deeper.push({tree, context:Object.assign({},context,{want})}) + } else + + if (ir.match(/^FAKE/)) { + if (m = ir.match(/^FAKE HERE (->|--) NODE/)) { + dot.push(`${quote(context.name)} ${m[1]} ${quote('post-'+context.name)}`) } else + if (m = ir.match(/^FAKE NODE (->|--) HERE/)) { + dot.push(`${quote('pre-'+context.name)} ${m[1]} ${quote(context.name)}`) + } else trouble("can't do fake", ir) + } else - if (ir.match(/^WHERE/)) { - let tree = nest() - var want = context.want - if (m = ir.match(/\/.*?\//)) { - let regex = new RegExp(m[0].slice(1,-1)) - want = want.filter(item => (item.text||'').match(regex)) - } else if (m = ir.match(/ FOLD ([a-z_-]+)/)) { - var within = false - want = want.filter((item) => { - if (item.type == 'pagefold') { - within = item.text == m[1] - } - return within - }) - } else if (m = ir.match(/[a-z_]+/)) { - let attr = m[0] - want = want.filter(item => item[attr]) - } else trouble("can't do where", ir) - deeper.push({tree, context:Object.assign({},context,{want})}) - } else + if (ir.match(/^LINEUP$/)) { + let tree = nest() + try { + let $page = $item.parents('.page') + let $lineup = $(`.page:lt(${$('.page').index($page)})`) + $lineup.each((i,p) => { + let site = $(p).data('site')||location.host + let name = $(p).data('data').title + deeper.push({tree, context:Object.assign({},context,{site, name})}) + }) + } catch { + throw new Error("can't do LINEUP yet") + } + } else - if (ir.match(/^FAKE/)) { - if (m = ir.match(/^FAKE HERE (->|--) NODE/)) { - dot.push(`${quote(context.name)} ${m[1]} ${quote('post-'+context.name)}`) - } else - if (m = ir.match(/^FAKE NODE (->|--) HERE/)) { - dot.push(`${quote('pre-'+context.name)} ${m[1]} ${quote(context.name)}`) - } else trouble("can't do fake", ir) - } else + if (ir.match(/^STATIC/)) { + break; + } else trouble("can't do", ir) - if (ir.match(/^LINEUP$/)) { - let tree = nest() - try { - let $page = $item.parents('.page') - let $lineup = $(`.page:lt(${$('.page').index($page)})`) - $lineup.each((i,p) => { - let site = $(p).data('site')||location.host - let name = $(p).data('data').title - deeper.push({tree, context:Object.assign({},context,{site, name})}) - }) - } catch { - throw new Error("can't do LINEUP yet") - } - } else + } else { + dot.push(ir) + } + } - if (ir.match(/^STATIC/)) { - break; - } else trouble("can't do", ir) + for (var i=0; i each.type == 'graphviz') + if (redirect) { + text = redirect.text } } - - for (var i=0; i @@ -489,7 +508,7 @@ ${item.dot??''}` // only continue if event is from a graphviz popup. // events from a popup window will have an opener // ensure that the popup window is one of ours - if (!event.source.opener || event.source.location.pathname !== '/plugins/graphviz/dialog/') { + if (!event.source.opener || event.source.location.pathname !== '/plugins/graphviz/dialog/') { if (wiki.debug) {console.log('graphvizListener - not for us', {event})} return } @@ -524,7 +543,7 @@ ${item.dot??''}` } if (typeof module !== "undefined" && module !== null) { - module.exports = {expand, includeStaticDotInText, cleanBeforeMakedot}; + module.exports = {expand, includeStaticDotInText, cleanBeforeMakedot, tree, evalTree}; } }).call(this); diff --git a/package.json b/package.json index f38ba42..a74f4ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wiki-plugin-graphviz", - "version": "0.11.3", + "version": "0.11.4-test1", "description": "Federated Wiki - Graphviz Plugin", "keywords": [ "graphviz", diff --git a/test/algo.js b/test/algo.js new file mode 100644 index 0000000..866f925 --- /dev/null +++ b/test/algo.js @@ -0,0 +1,108 @@ +// build time tests for graphviz plugin +// see http://mochajs.org/ + +(function() { + const graphviz = require('../client/graphviz'), + expect = require('expect.js'); + const federation = { + 'fed.wiki': { + 'this-page': { + title:'This Page', + story:[ + {type:'paragraph',text:'[[That Page]]'}, + {type:'paragraph',text:'[[Missing Page]]'}, + {type:'paragraph',text:'See also [[Special Page]]'}, + {type:'graph',text:'World --> HERE'}, + {type:'pagefold',text:'wanted'}, + {type:'paragraph',text:'[[Want This]]'}, + {type:'paragraph',text:'[[Want Marked]]',mark:true} + ], + journal:[]}, + 'that-page': { + title: 'That Page', + story: [{type:'paragraph',text:'Hello Word'}] + } + } + } + const probe = async (site,slug) => { + // console.log('probe',{site,slug}) + return federation[site][slug] + } + const backlinks = slug => { + // console.log('backlinks',slug) + const pages = { + 'this-page': { + 'from-page': {'title': 'From Page','sites': []} + } + } + return pages[slug] + } + + describe('graphviz algorithmic drawing', () => { + + describe('tree', () => { + it('can nest one line', () => { + var result = JSON.stringify(graphviz.tree(['HERE'],[],0)); + return expect(result).to.be(JSON.stringify(['HERE'])); + }); + it('can nest indented lines', () => { + var result = JSON.stringify(graphviz.tree(['HERE',' THERE'],[],0)); + return expect(result).to.be(JSON.stringify(['HERE',['THERE']])); + }); + it('can nest in and out again', () => { + var result = JSON.stringify(graphviz.tree(['HERE',' THERE','THEN'],[],0)); + return expect(result).to.be(JSON.stringify(['HERE',['THERE'],'THEN'])); + }); + }); + + describe('evalTree', async () => { + const site = 'fed.wiki' + const page = await probe(site,'this-page') + var context = { + probe, + backlinks, + name: page.title, + site, + page, + graph:'digraph', + want: page.story.slice() + } + it('can pass dot markup', async () => { + const result = await graphviz.evalTree(['node [shape=box]'],context,[]) + return expect(result[0]).to.be('node [shape=box]'); + }); + it('can display a node', async () => { + const result = await graphviz.evalTree(['HERE NODE'],context,[]) + return expect(result[0]).to.be('"This\nPage"'); + }); + it('can display links to nodes', async () => { + const result = await graphviz.evalTree(['HERE NODE',['LINKS HERE -> NODE']],context,[]) + return expect(result[1]).to.be('"This\nPage" -> "That\nPage"'); + }); + it('can display linked nodes', async () => { + const result = await graphviz.evalTree(['HERE',['LINKS',['HERE NODE']]],context,[]) + return expect(result[0]).to.be('"That\nPage"'); + }); + it('can display backlinks to nodes', async () => { + const result = await graphviz.evalTree(['HERE NODE',['BACKLINKS NODE -> HERE']],context,[]) + return expect(result[1]).to.be('"From\nPage" -> "This\nPage"'); + }); + it('can display links from Graph plugins', async () => { + const result = await graphviz.evalTree(['GRAPH'],context,[]) + return expect(result[1]).to.be('"World" -> "This\nPage"'); + }); + it('can selectively display one item', async () => { + const result = await graphviz.evalTree(['HERE',['WHERE /^See also/',['LINKS HERE -> NODE']]],context,[]) + return expect(result[0]).to.be('"This\nPage" -> "Special\nPage"'); + }); + it('can selectively display one pagefold', async () => { + const result = await graphviz.evalTree(['HERE',['WHERE FOLD wanted',['LINKS HERE -> NODE']]],context,[]) + return expect(result[0]).to.be('"This\nPage" -> "Want\nThis"'); + }); + it('can selectively display items with mark fields ', async () => { + const result = await graphviz.evalTree(['HERE',['WHERE mark',['LINKS HERE -> NODE']]],context,[]) + return expect(result[0]).to.be('"This\nPage" -> "Want\nMarked"'); + }); + }); + }); +}).call(this); \ No newline at end of file