wiki-plugin-solo
Version:
Federated Wiki - Solo Plugin
578 lines (526 loc) • 19.4 kB
HTML
<html>
<head>
<title>Solo Super Collaborator</title>
<link id="favicon" href="/favicon.png" rel="icon" type="image/png" />
<link href="./style.css" rel="stylesheet" />
<script src="https://unpkg.com/@hpcc-js/wasm@1.20.1/dist/index.min.js"></script>
<script> var hpccWasm = window["@hpcc-js/wasm"]; </script>
<script src="https://cdn.jsdelivr.net/gh/dash14/svg-pan-zoom/dist/svg-pan-zoom.min.js"></script>
<script type="module">
</script>
</head>
<body>
<main>
<div id="beam" ondrop="drop(event)" ondragover="over(event)" ondragenter="over(event)">
<div id="beamlist" onchange="dochoose(event)"></div>
</div>
<div id="target" ondrop="drop(event)" ondragover="over(event)" ondragenter="over(event)">
</div>
<div id="create" ondrop="drop(event)" ondragover="over(event)" ondragenter="over(event)">
<div style="padding:16px; font-size:large;">More about
<a href=http://search.dojo.fed.wiki/solo-super-collaborator.html target=_blank>Solo</a>
<p>
<button onclick=dogroup(event)>group</button>
<button onclick=dopreview(event)>preview</button>
<button onclick=dobridge(event)>bridge</button>
</p>
<p><input id=search type=text placeholder="Search"></p>
</div>
</main>
<script type="module">
const beam = []
let pageKey
// window.postMessage({type:'batch',graphs:(await want('wards-favorites')).slice(0,15)})
window.want = async file =>
fetch(`http://found.ward.fed.wiki/assets/pages/aspects-of-recent-changes/${file}.jsonl`)
.then(res => res.text())
.then(text => text.trim().split(/\n/)
.map(line => JSON.parse(line)))
window.addEventListener("message",event => {
const data = event.data
console.log({data})
pageKey = data.pageKey
beam.splice(0)
window.beamlist.querySelectorAll('input[type=checkbox]:checked')
.forEach(elem => elem.checked = false)
window.target.innerHTML = ''
for (const source of data.sources){
source.aspects.forEach(aspect => aspect.label = source.source)
beam.push(...source.aspects)}
console.log({data,beam})
refreshBeam()
})
document.getElementById('search')
.addEventListener('keydown', event => {
if (event.keyCode === 13) {
event.preventDefault()
event.target.value = ''
} else {
const term = (event.target.value+event.key).toLowerCase()
const want = text => text?.toLowerCase().includes(term)
const nodes = beam
.map(aspect => aspect.graph.nodes)
.flat()
.filter(node => want(node.type) || want(node.props.name))
console.log({term,nodes})
recommend(nodes)
}
})
function refreshBeam() {
const want = [...window.beamlist.querySelectorAll('input[type=checkbox]:checked')]
.map(e => +e.value)
console.log('refreshBeam',want)
// if (!want.length) window.dochoose({})
const names = beam.map(poem => poem.name || poem.graph.nodes[0].type)
// let then = 1
window.beamlist.innerHTML = names
.map((n,i) => {
const poem = beam[i]
let sep = i && beam[i-1].label == beam[i].label ? '' : beam[i].label
// for (const section of sections) {
// if (then && poem.date && then<section.date && poem.date>=section.date) {
// sep = `<div><font color=gray>Within ${section.period}</font></div>`
// }
// }
// then = poem.date || 1
const hover = poem.date ? `title="${new Date(poem.date).toLocaleString()}"` : ''
const number = `<sup>${poem.graph.nodes.length}</sup>`
return `${sep}<div><font color=gray size=1>${i}</font>
<input type=checkbox value=${i} id=n${i} ${want.includes(i)?'checked':''}>
<label for=n${i}>${n}${number}</label></div>`
})
.join("\n")
const last = window.beamlist.querySelector('div:last-of-type')
if(last) last.scrollIntoView({behavior: "smooth", block: "end", inline: "nearest"})
}
window.dochoose = function (event) {
const checked = [...window.beam.querySelectorAll('input[type=checkbox]:checked')]
const chosen = checked.map(e => Object.assign({index:e.value},beam[e.value]))
console.log({chosen})
display(chosen)
recommend(chosen.map(poem => poem.graph.nodes).flat())
}
function recommend(like) {
const vocabulary = new Set()
const hash = node => `${node.type}: ${node.props.name || ''}`
for (const node of like) {
vocabulary.add(hash(node))
}
const similar = graph => {
for (const node of graph.nodes) {
if (vocabulary.has(hash(node))) return hash(node).replaceAll(/\n/g,' ')
}
return null
}
showwhen(similar)
}
window.dobridge = event => {
const checked = [...window.beam.querySelectorAll('input[type=checkbox]:checked')]
const chosen = checked.map(e => Object.assign({index:e.value},beam[e.value]))
const complex = composite(chosen)
const parts = partitions(complex.graph)
bridge(parts)
}
function bridge(graphs) {
// for every unselected aspect
// for every selected aspect
// if first match remember
// if second match recommend unselected
const hash = node => `${node.type}: ${node.props.name || ''}`
const touch = ((a,b) => {
for (const anode of a.nodes)
for (const bnode of b.nodes)
if(hash(anode) == hash(bnode)) return true
return false
})
const wanted = unselected => {
let any = false
for (const selected of graphs) {
if (touch(unselected, selected)) {
if (any) return true
any = true
}
}
}
showwhen(wanted)
}
function showwhen(predicate) {
window.beam.querySelectorAll('input[type=checkbox]').forEach(elem => {
const color = !elem.checked && predicate(beam[elem.value].graph) ? 'darkorange' : 'black'
elem.nextElementSibling.style.color = color
// const title = !elem.checked && predicate(beam[elem.value].graph) ? JSON.stringify(similar(beam[elem.value].graph)) : ''
// elem.nextElementSibling.setAttribute('title', title)
})
}
let drawing = false
let panSVG = null
const panZoom = {}
async function display(chosen) {
if(!drawing){
drawing = true
const complex = composite(chosen)
try {
if (document.querySelector('#target svg')) {
panZoom.pan = panSVG.getPan()
panZoom.zoom = panSVG.getZoom()
panZoom.size = {
width: document.querySelector('#target svg').width.baseVal.valueInSpecifiedUnits,
height: document.querySelector('#target svg').height.baseVal.valueInSpecifiedUnits
}
}
const dot = dotify(complex)
window.dot = dot
hpccWasm.graphviz.layout(window.dot, "svg", "dot").then(svg => {
target.innerHTML = svg;
drawing = false
hoverbold(target)
clickready(target,complex.graph)
const targetBounds = { width: document.querySelector('#target').clientWidth, height: document.querySelector('#target').clientHeight }
const svgBounds = { width: document.querySelector('#target svg').clientWidth, height: document.querySelector('#target svg').clientHeight }
let svgElement = document.querySelector('#target svg')
panSVG = svgPanZoom(svgElement)
document.querySelector('#target svg').style.height = "100%"
document.querySelector('#target svg').style.width = "100%"
if (targetBounds.width < svgBounds.width || targetBounds.height < svgBounds.height) {
panSVG.resize()
}
panSVG.fit()
panSVG.center()
if (panZoom.size &&
panZoom.size.width == document.querySelector('#target svg').width.baseVal.valueInSpecifiedUnits &&
panZoom.size.height == document.querySelector('#target svg').height.baseVal.valueInSpecifiedUnits) {
panSVG.zoom(panZoom.zoom)
panSVG.pan(panZoom.pan)
}
})
} catch (err) {
console.log('display error', err)
drawing = false
}
} else {
console.log('display: skipping', chosen)
}
}
const uniq = (value, index, self) => self.indexOf(value) === index
class Graph {
constructor(nodes=[], rels=[]) {
this.nodes = nodes;
this.rels = rels;
}
addNode(type, props={}){
const obj = {type, in:[], out:[], props};
this.nodes.push(obj);
return this.nodes.length-1;
}
addRel(type, from, to, props={}) {
const obj = {type, from, to, props};
this.rels.push(obj);
const rid = this.rels.length-1;
this.nodes[from].out.push(rid)
this.nodes[to].in.push(rid);
return rid;
}
tally(){
const tally = list => list.reduce((s,e)=>{s[e.type] = s[e.type] ? s[e.type]+1 : 1; return s}, {});
return { nodes:tally(this.nodes), rels:tally(this.rels)};
}
size(){
return this.nodes.length + this.rels.length;
}
static load(obj) {
// let obj = await fetch(url).then(res => res.json())
return new Graph(obj.nodes, obj.rels)
}
static async fetch(url) {
const obj = await fetch(url).then(res => res.json())
return Graph.load(obj)
}
static async read(path) {
const json = await Deno.readTextFile(path);
const obj = JSON.parse(json);
return Graph.load(obj)
}
stringify(...args) {
const obj = { nodes: this.nodes, rels: this.rels }
return JSON.stringify(obj, ...args)
}
}
function partitions(graph) {
const input = graph
const output = [] // graphs
let doing = {} // nid => new nid
const nodes = input.nodes
const rels = input.rels
const todo = [...Array(nodes.length).keys()]
.map(n => [n,Math.random()])
.sort((a,b)=>a[1]-b[1])
.map(v=>v[0])
const copy = nid => {
if(nid in doing) return
todo.splice(todo.indexOf(nid),1)
const node = nodes[nid]
doing[nid] = output[0].addNode(node.type,node.props)
for (const rid of node.out) copy(rels[rid].to)
for (const rid of node.in) copy(rels[rid].from)
for (const rid of node.out) output[0].addRel('',doing[nid],doing[rels[rid].to],{})
}
while(todo.length) {
const nid = todo.shift()
if (nid in doing) continue
const node = nodes[nid]
if (node.in.length + node.out.length) {
output.unshift(new Graph())
doing = {}
copy(nid)
}
}
return output
}
function composite(concepts) {
const merged = {nids:[]}
const comp = new Graph()
for (const concept of concepts) {
const {name,graph} = concept
merge(comp,graph,name)
}
return {graph:comp, merged}
function merge(comp,incr,source) {
function mergeprops(into,from) {
const keys = Object.keys(into)
.concat(Object.keys(from))
.filter(uniq)
for (const key of keys) {
if (into[key]) {
// if (from[key] && into[key] != from[key]) {
// window.result.innerHTML +=
// `<div style="font-size:small; padding:4px; background-color:#fee; border-radius:4px; border:1px solid #aaa;">
// conflict for "${key}" property<br>
// choosing "${into[key]}" over "${from[key]}"</div>`
// }
}
else {
if(from[key]) {
into[key] = from[key]
}
}
}
}
const nids = {} // incr => comp
incr.nodes.forEach((node,id) => {
const match = comp.nodes.find(each =>
each.type == node.type &&
each.props.name == node.props.name)
if(match) {
nids[id] = comp.nodes.findIndex(node => node === match)
merged.nids.push(nids[id])
mergeprops(match.props, node.props)
} else {
nids[id] = comp.addNode(node.type,node.props)
}
})
incr.rels.forEach(rel => {
const match = comp.rels.find(each =>
each.type == rel.type &&
each.from == nids[rel.from] &&
each.to == nids[rel.to]
)
if(match) {
mergeprops(match.props, rel.props)
} else {
rel.props.source = source
comp.addRel(rel.type, nids[rel.from], nids[rel.to], rel.props)
}
})
}
}
// render graph as dot input to graphviz
function dotify(complex) {
const inputs = ["read", "get", "first"]
const safe = string => string.replaceAll ? string.replaceAll(/"/g,'') : string
const epoch = number => number.toString().match(/^\d{13}$/) ?
new Date(number).toLocaleDateString() :
number.toString().match(/\n/) ?
number.replaceAll(/\n/g,' ') :
number
const {graph, merged} = complex
const tip = props => Object.entries(props||{})
.filter(e => e[1])
.map(e => `${e[0]}: ${safe(epoch(e[1]))}`)
.join("\\n")
const layout = graph.nodes
.filter(node => node.type == 'Graphviz' && node.props.layout)
.map(node => node.props.layout)
?.reverse()[0]
const emphasis = graph.nodes
.filter(node => node.type == 'Graphviz' && node.props.emphasis)
.map(node => node.props.emphasis)
.reduce((sum,each) => Object.assign(sum,each), {})
console.log('dotify',emphasis)
window.graph = graph
const nodes = graph.nodes.map((node,id) => {
const icon = node.props.url ? " 🔗" : node.props.tick ? " ☐" : ""
const label = `${node.type}\\n${safe(node.props.name)}${icon}`
const color = emphasis[node.type] || (node.props.color ? `fillcolor="${node.props.color}"` : '')
return `${id} [label="${label}" ${color} ${(node.props.url||node.props.tick)?`URL="${node.props.url||'#'}" target="_blank"`:''} tooltip="${tip(node.props)}"]`
})
// if inputs.include?r['type']
// file.puts "#{r['to']} -> #{r['from']} [#{props} dir=back]"
// else
// file.puts "#{r['from']} -> #{r['to']} [#{props}]"
// end
const omits = new Set()
const edges = graph.rels.map(rel => {
const color = emphasis[rel.type] || ''
if (graph.rels.find(rev => rev.from==rel.to && rev.to==rel.from)) {
if (omits.has(`${rel.to} -> ${rel.from}`)) return ''
omits.add(`${rel.from} -> ${rel.to}`)
return `${rel.from}->${rel.to} [label="${rel.type}" ${color} labeltooltip="${tip(rel.props)}" dir=both]`
}
if (inputs.includes(rel.type))
return `${rel.to}->${rel.from} [label="${rel.type}" ${color} labeltooltip="${tip(rel.props)}" dir=back]`
else
return `${rel.from}->${rel.to} [label="${rel.type}" ${color} labeltooltip="${tip(rel.props)}"]`
})
return [
'strict digraph {',
'overlap = false; splines=true',
`layout = ${layout||'dot'}`,
'node [shape=box style=filled fillcolor=gold penwidth=2]',
...merged.nids,
'node [fillcolor=palegreen penwidth=1]',
...nodes,
...edges,
'}'].join("\n")
}
// embolden lines from graphviz svg nodes on hover
function hoverbold(div,select='both') {
let rels = [] // [[edge, from, to], ...]
const title = target => target.querySelector('title').textContent.replace(/\\n/g,' ')
div.querySelectorAll('.edge').forEach(edge => rels.push([edge, ...title(edge).split('->')]))
const trace = (node,doit) => {
const related = io => rels.filter(row => row[io]==node)
switch (select) {
case 'output':
related(1).map(row => doit(row[0]))
break
case 'input':
related(2).map(row => doit(row[0]))
break
case 'both':
related(1).map(row => doit(row[0]))
related(2).map(row => doit(row[0]))
break
case 'most':
let o = related(1).length
let i = related(2).length
related(o>i?1:2).map(row => doit(row[0]))
break
}
}
const feature = (event,width) => {
if(width==3) console.log([...event.target.querySelectorAll('text')].map(e => e.innerHTML).join(" "))
}
const stroke = width => {
const adjust = edge => edge.setAttribute('stroke-width',width)
return event => {feature(event,width); return trace(title(event.target), adjust)}
}
div.querySelectorAll('.node').forEach(node => {
node.addEventListener('mouseenter',stroke(3))
node.addEventListener('mouseleave',stroke(1))
})
}
function clickready(div,graph) {
div.querySelectorAll('.node').forEach(node => {
node.addEventListener('click', event => {
const target = event.target
const node = target.closest('.node')
const nid = +node.querySelector('title').textContent
const props = graph.nodes[nid].props
console.log({event,target,node,nid,props})
if (event.altKey) {
recommend([graph.nodes[nid]])
} else
if('site' in props) {
const site = props.site
const title = props.title || props.name.replaceAll(/\n/g,' ')
const message = {
action: 'doInternalLink',
keepLineup:event.shiftKey,
pageKey,
context: [site],
title
}
console.log(message)
window.opener.postMessage(message)
}
})
})
}
window.dogroup = event => {
const s=window.target.getBoundingClientRect();
const nids = [...window.target.getElementsByClassName('node')]
.filter(node=>{
const r=node.getBoundingClientRect();
return r.x>s.x && r.x+r.width<s.x+s.width &&
r.y>s.y && r.y+r.height<s.y+s.height})
.map(node=>+node.querySelector('title').textContent)
console.log('nids',...nids)
const rids = window.graph.rels
.map((rel,i) => nids.includes(rel.from) && nids.includes(rel.to) ? i : null)
.filter(rid => rid !== null)
console.log('rids',...rids)
const nodes = nids.map(nid => {
const node = window.graph.nodes[nid]
node.in = node.in.map(rid => rids.indexOf(rid)).filter(rid => rid >= 0)
node.out = node.out.map(rid => rids.indexOf(rid)).filter(rid => rid >= 0)
return node
})
const rels = rids.map(rid => {
const rel = window.graph.rels[rid]
rel.from = nids.indexOf(rel.from)
rel.to = nids.indexOf(rel.to)
return rel
})
const graph = new Graph(nodes,rels)
const name = new Date().toLocaleTimeString()
console.log(name,graph)
beam.push({name,graph,label:'groups'})
refreshBeam()
}
window.dopreview = event => {
const aspects = [...window.beamlist.querySelectorAll('input[type=checkbox]:checked')]
.map(e => beam[+e.value])
console.log(aspects)
const title = 'New Aspects for Solo'
const story = [
{type:'paragraph',text:'This page saved from previous page in wiki.'},
{type:'graphviz',text:window.dot},
{type:'solo',text:'INCLUDED',aspects}]
const page = {title,story}
const date = Date.now()
const asCopy = obj => JSON.parse(JSON.stringify(obj))
for (const item of page.story) item.id ||= (Math.random()*10**20).toFixed(0)
page.journal ||= [{type:'create', date, item:asCopy(page)}]
const message = {
action: 'showResult',
keepLineup:event.shiftKey,
pageKey,
page
}
console.log(message)
window.opener.postMessage(message)
}
window.dodownload = event => {
const poems = [...window.beamlist.querySelectorAll('input[type=checkbox]:checked')]
.map(e => beam[+e.value])
const complex = composite(poems)
const filename = poems
.map(poem => poem.name.replace(/[^a-zA-Z0-9]/g,''))
.filter(uniq).sort().join('-') + '.graph.json'
console.log({poems,complex,filename})
// download(complex.graph.stringify(null,2),filename,'application/json')
}
</script>
</body>
</html>