Version 1.0.0
This commit is contained in:
parent
8d4b31bfd2
commit
9198ab6b37
|
@ -0,0 +1,376 @@
|
|||
/**
|
||||
* This is the browser-side javascript class for the DocSet viewer.
|
||||
*
|
||||
* It's implemented as a monolothic class to minimize any name-space pollution.
|
||||
*
|
||||
* All the entry points are static methods toward the end. The actual stuff is done
|
||||
* on a node server process, using sqlite3.
|
||||
*
|
||||
*/
|
||||
class DClient {
|
||||
static client = new DClient({
|
||||
url: 'ws://localhost:8080/ws'
|
||||
});
|
||||
constructor(opt){
|
||||
this.prefix = opt.prefix || "";
|
||||
this.url = opt.url || 'ws://localhost:8080' + this.prefix + '/ws';
|
||||
this.is_closed = false;
|
||||
this.setup_websocket();
|
||||
}
|
||||
setup_websocket(){
|
||||
let me = this;
|
||||
let sock = new WebSocket(this.url);
|
||||
sock.addEventListener('open', (event) => {
|
||||
me.is_closed = false;
|
||||
});
|
||||
sock.addEventListener("error", err => {
|
||||
console.log("OOPS: ",err);
|
||||
sock.close();
|
||||
me.is_closed = true;
|
||||
me.clear_options();
|
||||
});
|
||||
sock.addEventListener("close", ev => {
|
||||
me.is_closed = true;
|
||||
me.clear_options();
|
||||
});
|
||||
sock.addEventListener('message', (event) => {
|
||||
let rec = JSON.parse(event.data);
|
||||
switch(rec.data_type){
|
||||
case('hit'):
|
||||
this.rcv_hit(rec);
|
||||
break;
|
||||
case('id.type'):
|
||||
this.rcv_type(rec);
|
||||
break;
|
||||
case('id.collection'):
|
||||
this.rcv_collection(rec);
|
||||
break;
|
||||
case('hit-max'):
|
||||
alert(rec.title);
|
||||
break;
|
||||
default:
|
||||
console.log(`unknown ${rec.data_type}`);
|
||||
}
|
||||
});
|
||||
this.sock = sock;
|
||||
}
|
||||
|
||||
// make the outer box for a group of hits. Example: "Vim" from vim docset.
|
||||
// the id of the UL will be "hit.list.Vim"
|
||||
create_hit_collection(hit){
|
||||
let list_id = "hit.list." + hit.collection;
|
||||
let collection_id = "hit.group." + hit.collection;
|
||||
// where we're putting this collection
|
||||
let disp = document.getElementById("search.hits");
|
||||
|
||||
// the outline of the collection.
|
||||
let tmpl = document.getElementById("hit.collection");
|
||||
let clone = tmpl.content.cloneNode(true);
|
||||
|
||||
// label it.
|
||||
let a = clone.querySelector("a");
|
||||
a.innerHTML = "";
|
||||
a.appendChild(document.createTextNode(hit.collection));
|
||||
|
||||
let q = clone.querySelector(".hit-collection-group");
|
||||
q.id = collection_id;
|
||||
|
||||
let qr = clone.querySelector("ul.hide");
|
||||
qr.id = list_id;
|
||||
|
||||
disp.appendChild(clone);
|
||||
return(qr);
|
||||
}
|
||||
rcv_type(hit){
|
||||
let tmpl = document.getElementById("input.type");
|
||||
let clone = tmpl.content.cloneNode(true);
|
||||
|
||||
let input = clone.querySelector("input");
|
||||
input.value = hit.type;
|
||||
let label = clone.querySelector("label");
|
||||
label.appendChild(document.createTextNode(hit.type));
|
||||
|
||||
let disp = document.getElementById("search.params.box.type.list");
|
||||
disp.append(clone);
|
||||
|
||||
}
|
||||
clear_options(){
|
||||
let el = document.getElementById("search.params.box.collection.list");
|
||||
el.innerHTML = "";
|
||||
el = document.getElementById("search.params.box.type.list");
|
||||
el.innerHTML = "";
|
||||
}
|
||||
|
||||
rcv_collection(row){
|
||||
let tmpl = document.getElementById("input.collection");
|
||||
let disp = document.getElementById("search.params.box.collection.list");
|
||||
row.collection.forEach( cname => {
|
||||
let clone = tmpl.content.cloneNode(true);
|
||||
let input = clone.querySelector("input");
|
||||
input.value = cname;
|
||||
let label = clone.querySelector("label");
|
||||
label.appendChild(document.createTextNode(cname));
|
||||
|
||||
disp.append(clone);
|
||||
});
|
||||
}
|
||||
/*
|
||||
* Resolve all the ../../ stuff and THEN make sure it is under
|
||||
* the specified path.
|
||||
*
|
||||
* Example:
|
||||
* "C","../../foo/bar/../car" -> "C/foo/car"
|
||||
*/
|
||||
grounded_path(gnd, p){
|
||||
// in case it's a <dash_embed junk>/path/to/file
|
||||
let ix = p.lastIndexOf(">");
|
||||
if(ix > -1){
|
||||
++ix;
|
||||
p = p.substr(ix);
|
||||
}
|
||||
let frag = null;
|
||||
ix = p.indexOf('#');
|
||||
if(ix > -1){
|
||||
frag = p.substr(ix);
|
||||
p = p.substr(0,ix);
|
||||
}
|
||||
let ar = p.split("/");
|
||||
let op = [];
|
||||
ar.forEach( el => {
|
||||
switch(el){
|
||||
case '..':
|
||||
op.shift();
|
||||
break;
|
||||
case '':
|
||||
case '.':
|
||||
break;
|
||||
default:
|
||||
op.push(el);
|
||||
}
|
||||
});
|
||||
if(frag){
|
||||
p = gnd + "/" + op.join("/") + frag;
|
||||
}else{
|
||||
p = gnd + "/" + op.join("/");
|
||||
}
|
||||
return(p);
|
||||
}
|
||||
/**
|
||||
* Called when a hit is received for the search results.
|
||||
*/
|
||||
rcv_hit(hit){
|
||||
let collection_id = "hit.list." + hit.collection;
|
||||
let disp = document.getElementById(collection_id);
|
||||
if(! disp){
|
||||
disp = this.create_hit_collection(hit);
|
||||
}
|
||||
let tmpl = document.getElementById("hit.entry");
|
||||
let clone = tmpl.content.cloneNode(true);
|
||||
let anchor = clone.querySelector("a.hit-entry");
|
||||
anchor.innerHTML = "";
|
||||
anchor.href="#" + this.grounded_path(hit.collection, hit.path);
|
||||
anchor.title = hit.collection + " :: " + hit.name + " (" + hit.type + ")";
|
||||
anchor.appendChild(document.createTextNode(hit.name));
|
||||
disp.append(clone);
|
||||
}
|
||||
|
||||
do_toggle_collection_result(al){
|
||||
let id = "hit.list." + al.innerText;
|
||||
|
||||
// Hide them all.
|
||||
let cel = document.getElementById("search.hits");
|
||||
let cl = cel.querySelectorAll("ul");
|
||||
cl.forEach( ul => {
|
||||
if(ul.id != id){
|
||||
ul.className = "hide";
|
||||
}
|
||||
});
|
||||
// then open the one we wanted.
|
||||
let el = document.getElementById(id);
|
||||
if(el){
|
||||
if(el.className == "hide"){
|
||||
el.className = "hit-item";
|
||||
}else{
|
||||
el.className = "hide";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do_fetch_doc(el){
|
||||
let ix = el.href.indexOf("#");
|
||||
if(ix < 0){
|
||||
return; // shouldn't happen.
|
||||
}
|
||||
let path = null;
|
||||
path = this.prefix + "/page/" + el.href.substr(ix + 1);
|
||||
let frame = document.getElementById("doc.box.frame");
|
||||
frame.src = path;
|
||||
let sel = document.getElementById("doc.box.select");
|
||||
|
||||
let found = false;
|
||||
for( let ix = 0; ix < sel.length; ++ix){
|
||||
let op = sel.options[ix];
|
||||
if(op.value == path){
|
||||
sel.selectedIndex = ix;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if(! found){
|
||||
let ix = sel.length;
|
||||
let op = document.createElement("option");
|
||||
op.text = el.title;
|
||||
op.value = path;
|
||||
sel.add(op);
|
||||
sel.selectedIndex = ix;
|
||||
}
|
||||
this.do_toggle_params("hide");
|
||||
}
|
||||
do_select_tab(sel){
|
||||
if(sel.options.length == 0){
|
||||
this.do_toggle_params(true);
|
||||
return;
|
||||
}
|
||||
let ix = sel.selectedIndex;
|
||||
let op = sel.options[ix];
|
||||
let frame = document.getElementById("doc.box.frame");
|
||||
frame.src = op.value;
|
||||
this.do_toggle_params("hide");
|
||||
}
|
||||
do_close_tab(el){
|
||||
let sel = document.getElementById("doc.box.select");
|
||||
let sel_ix = sel.selectedIndex;
|
||||
sel.remove(sel_ix);
|
||||
this.do_select_tab(sel);
|
||||
}
|
||||
do_toggle_params(state){
|
||||
let el = document.getElementById("search.params.box");
|
||||
let mb = document.getElementById("doc.box.main");
|
||||
if(state == "hide"){
|
||||
el.className = "hide";
|
||||
mb.className = "app-doc";
|
||||
return;
|
||||
}
|
||||
if(el.className == "hide"){
|
||||
// el.className = "search-params";
|
||||
el.className = "app-doc";
|
||||
mb.className = "hide";
|
||||
}else{
|
||||
el.className = "hide";
|
||||
mb.className = "app-doc";
|
||||
}
|
||||
}
|
||||
gather_params(){
|
||||
let all = document.getElementsByTagName("input");
|
||||
let collection = [];
|
||||
let htype = [];
|
||||
for(let ix = 0; ix < all.length; ++ix){
|
||||
let inp = all[ix];
|
||||
if(inp.type.toLowerCase() == "checkbox" && inp.checked ){
|
||||
if(inp.name == "collection") {
|
||||
collection.push(inp.value);
|
||||
}
|
||||
if(inp.name == "type"){
|
||||
htype.push(inp.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return({ "collection": collection, "type": htype } );
|
||||
}
|
||||
|
||||
submit_search(){
|
||||
let el = document.getElementById("search.hits");
|
||||
el.innerHTML = "";
|
||||
let params = this.gather_params();
|
||||
|
||||
let sb = document.getElementById('search.box');
|
||||
let obj = {
|
||||
call: 'search',
|
||||
collection: params.collection,
|
||||
type: params.type,
|
||||
term: sb.value
|
||||
};
|
||||
if(this.is_closed){
|
||||
this.setup_websocket();
|
||||
}
|
||||
this.sock.send(JSON.stringify(obj));
|
||||
}
|
||||
do_type_invert(id){
|
||||
let el = document.getElementById(id);
|
||||
let cl = el.querySelectorAll("div input");
|
||||
cl.forEach( inp => {
|
||||
inp.checked = ! inp.checked;
|
||||
});
|
||||
}
|
||||
|
||||
// allow text based type selection.
|
||||
do_filter_type(tog){
|
||||
let qin = document.getElementById("filter.type");
|
||||
let rx = new RegExp(qin.value,'i');
|
||||
|
||||
let el = document.getElementById("search.params.box.type.list");
|
||||
let cl = el.querySelectorAll("div input");
|
||||
cl.forEach( inp => {
|
||||
let str = inp.value;
|
||||
if(str.match(rx)){
|
||||
inp.checked = tog;
|
||||
}else{
|
||||
inp.checked = !tog;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
do_filter_collection(tog){
|
||||
let qin = document.getElementById("filter.collection");
|
||||
let rx = new RegExp(qin.value,'i');
|
||||
let el = document.getElementById("search.params.box.collection.list");
|
||||
let cl = el.querySelectorAll("div input");
|
||||
cl.forEach( inp => {
|
||||
let str = inp.value;
|
||||
if(str.match(rx)){
|
||||
inp.checked = tog;
|
||||
}else{
|
||||
inp.checked = !tog;
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Begin entry points:
|
||||
*
|
||||
* the main.html file calls these functions via buttons and links.
|
||||
*/
|
||||
static filter_collection(tog){
|
||||
DClient.client.do_filter_collection(tog);
|
||||
}
|
||||
static filter_type(tog){
|
||||
DClient.client.do_filter_type(tog);
|
||||
}
|
||||
static invert(what){
|
||||
switch(what) {
|
||||
case 'type':
|
||||
DClient.client.do_type_invert('search.params.box.type.list');
|
||||
break;
|
||||
case 'collection':
|
||||
DClient.client.do_type_invert('search.params.box.collection.list');
|
||||
break;
|
||||
}
|
||||
}
|
||||
static search(){
|
||||
DClient.client.submit_search();
|
||||
}
|
||||
static fetch_doc(el){
|
||||
DClient.client.do_fetch_doc(el);
|
||||
}
|
||||
static select_tab(el){
|
||||
DClient.client.do_select_tab(el);
|
||||
}
|
||||
static close_tab(el){
|
||||
DClient.client.do_close_tab(el);
|
||||
}
|
||||
static toggle_params(state){
|
||||
DClient.client.do_toggle_params();
|
||||
}
|
||||
static toggle_collection_result(al){
|
||||
DClient.client.do_toggle_collection_result(al);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<p><span style="background-color: var(--teal-bg)"> --teal-bg </span> | <span style="color: var(--teal-bg)"> --teal-bg </span></p>
|
||||
<p><span style="background-color: var(--teal-light)"> --teal-light </span> | <span style="color: var(--teal-light)"> --teal-light </span></p>
|
||||
<p><span style="background-color: var(--teal-med-light)"> --teal-med-light </span> | <span style="color: var(--teal-med-light)"> --teal-med-light </span></p>
|
||||
<p><span style="background-color: var(--teal-med-dark)"> --teal-med-dark </span> | <span style="color: var(--teal-med-dark)"> --teal-med-dark </span></p>
|
||||
<p><span style="background-color: var(--teal-dark)"> --teal-dark </span> | <span style="color: var(--teal-dark)"> --teal-dark </span></p>
|
||||
<p><span style="background-color: var(--brn-bg)"> --brn-bg </span> | <span style="color: var(--brn-bg)"> --brn-bg </span></p>
|
||||
<p><span style="background-color: var(--brn-light)"> --brn-light </span> | <span style="color: var(--brn-light)"> --brn-light </span></p>
|
||||
<p><span style="background-color: var(--brn-med-light)"> --brn-med-light </span> | <span style="color: var(--brn-med-light)"> --brn-med-light </span></p>
|
||||
<p><span style="background-color: var(--brn-med-dark)"> --brn-med-dark </span> | <span style="color: var(--brn-med-dark)"> --brn-med-dark </span></p>
|
||||
<p><span style="background-color: var(--brn-dark)"> --brn-dark </span> | <span style="color: var(--brn-dark)"> --brn-dark </span></p>
|
||||
<p><span style="background-color: var(--red-bg)"> --red-bg </span> | <span style="color: var(--red-bg)"> --red-bg </span></p>
|
||||
<p><span style="background-color: var(--red-light)"> --red-light </span> | <span style="color: var(--red-light)"> --red-light </span></p>
|
||||
<p><span style="background-color: var(--red-med-light)"> --red-med-light </span> | <span style="color: var(--red-med-light)"> --red-med-light </span></p>
|
||||
<p><span style="background-color: var(--red-med-dark)"> --red-med-dark </span> | <span style="color: var(--red-med-dark)"> --red-med-dark </span></p>
|
||||
<p><span style="background-color: var(--red-dark)"> --red-dark </span> | <span style="color: var(--red-dark)"> --red-dark </span></p>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,93 @@
|
|||
<html>
|
||||
<head>
|
||||
<script src="/static/client.js" ></script>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<template id="hit.collection">
|
||||
<div class="hit-collection-group" id="hit.group.generic">
|
||||
<h2><a href="#" onClick="DClient.toggle_collection_result(this)" >Collection</a></h2>
|
||||
<!-- will be class="hit-item" when shown. -->
|
||||
<ul class="hide">
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<template id="hit.entry">
|
||||
<li class="hit-entry">
|
||||
<a title="" class="hit-entry" onClick="DClient.fetch_doc(this)">Link</a>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<template id="input.type">
|
||||
<div id="search.params.type">
|
||||
<input type="checkbox" name="type" value="generic" checked="yes" />
|
||||
<label ></label>
|
||||
</div>
|
||||
</template>
|
||||
<template id="input.collection">
|
||||
<div id="search.params.collection">
|
||||
<input type="checkbox" name="collection" value="generic" checked="yes" />
|
||||
<label></label>
|
||||
</div>
|
||||
</template>
|
||||
<div class="app-body">
|
||||
<form name="docset" method="post" action="#none" id="form.main" >
|
||||
<div class="top-search">
|
||||
<p>
|
||||
<input name="search" id="search.box" size="35" value="" type="text" onChange="DClient.search()" />
|
||||
<input type="button" value="Search" onClick="DClient.search()" />
|
||||
|
||||
<input type="button" value="Filters" onClick="DClient.toggle_params()" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="app-results">
|
||||
<div id="search.hits" class="search-hits">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-doc" id="search.params.box" >
|
||||
<div id="search.params.box.type" class="types-term">
|
||||
<h2>Term Types</h2>
|
||||
<div class="types-menu">
|
||||
Type: <input id="filter.type" name="filt_type" size="16">
|
||||
<p>
|
||||
<a href="javascript: DClient.filter_type(1)">Check</a>
|
||||
<a href="javascript: DClient.filter_type(0)">Un-Check</a>
|
||||
<a href="javascript: DClient.invert('type')">Invert</a>
|
||||
</p>
|
||||
<hr />
|
||||
</div>
|
||||
<div id="search.params.box.type.list"></div>
|
||||
<div>
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
<div id="search.params.box.collection" class="types-collection">
|
||||
<h2>Collections</h2>
|
||||
Set: <input id="filter.collection" name="filt_collection" size="16">
|
||||
<p>
|
||||
<a href="javascript: DClient.filter_collection(1)">Check</a>
|
||||
<a href="javascript: DClient.filter_collection(0)">Un-Check</a>
|
||||
<a href="javascript: DClient.invert('collection')">Invert</a>
|
||||
</p>
|
||||
<hr />
|
||||
<div id="search.params.box.collection.list"></div>
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
<div class="hide" id="doc.box.main">
|
||||
<div class="app-doc-tabs" id="doc.box.tabs">
|
||||
<p id="doc.box.menu" class="doc-box-menu">
|
||||
<select id="doc.box.select" onChange="DClient.select_tab(this)" ></select>
|
||||
<input type="button" onClick="DClient.close_tab(this)" value="Close">
|
||||
</p>
|
||||
</div>
|
||||
<div class="doc-box-content">
|
||||
<iframe name="doc_box_content" id="doc.box.frame" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
|
||||
<p><span style="background-color: var(--teal-bg)"> --teal-bg </span> | <span style="color: var(--teal-bg)"> --teal-bg </span></p>
|
||||
<p><span style="background-color: var(--teal-light)"> --teal-light </span> | <span style="color: var(--teal-light)"> --teal-light </span></p>
|
||||
<p><span style="background-color: var(--teal-med-light)"> --teal-med-light </span> | <span style="color: var(--teal-med-light)"> --teal-med-light </span></p>
|
||||
<p><span style="background-color: var(--teal-med-dark)"> --teal-med-dark </span> | <span style="color: var(--teal-med-dark)"> --teal-med-dark </span></p>
|
||||
<p><span style="background-color: var(--teal-dark)"> --teal-dark </span> | <span style="color: var(--teal-dark)"> --teal-dark </span></p>
|
||||
<p><span style="background-color: var(--brn-bg)"> --brn-bg </span> | <span style="color: var(--brn-bg)"> --brn-bg </span></p>
|
||||
<p><span style="background-color: var(--brn-light)"> --brn-light </span> | <span style="color: var(--brn-light)"> --brn-light </span></p>
|
||||
<p><span style="background-color: var(--brn-med-light)"> --brn-med-light </span> | <span style="color: var(--brn-med-light)"> --brn-med-light </span></p>
|
||||
<p><span style="background-color: var(--brn-med-dark)"> --brn-med-dark </span> | <span style="color: var(--brn-med-dark)"> --brn-med-dark </span></p>
|
||||
<p><span style="background-color: var(--brn-dark)"> --brn-dark </span> | <span style="color: var(--brn-dark)"> --brn-dark </span></p>
|
||||
<p><span style="background-color: var(--red-bg)"> --red-bg </span> | <span style="color: var(--red-bg)"> --red-bg </span></p>
|
||||
<p><span style="background-color: var(--red-light)"> --red-light </span> | <span style="color: var(--red-light)"> --red-light </span></p>
|
||||
<p><span style="background-color: var(--red-med-light)"> --red-med-light </span> | <span style="color: var(--red-med-light)"> --red-med-light </span></p>
|
||||
<p><span style="background-color: var(--red-med-dark)"> --red-med-dark </span> | <span style="color: var(--red-med-dark)"> --red-med-dark </span></p>
|
||||
<p><span style="background-color: var(--red-dark)"> --red-dark </span> | <span style="color: var(--red-dark)"> --red-dark </span></p>
|
||||
|
||||
Color Pallette:
|
||||
|
||||
|
||||
*/
|
||||
:root {
|
||||
--teal-bg: #246C60;
|
||||
--teal-light: #6CA299;
|
||||
--teal-med-light: #43877C;
|
||||
--teal-med-dark: #0D5146;
|
||||
--teal-dark: #00362D;
|
||||
|
||||
--brn-bg: #AA7F39;
|
||||
--brn-light: #FFDFAA;
|
||||
--brn-med-light: #D4AD6A;
|
||||
--brn-med-dark: #805815;
|
||||
--brn-dark: #553500;
|
||||
|
||||
--red-bg: #A7383E;
|
||||
--red-light: #FBA7AB;
|
||||
--red-med-light: #D1686D;
|
||||
--red-med-dark: #7D151A;
|
||||
--red-dark: #540004;
|
||||
}
|
||||
|
||||
BODY {
|
||||
background-color: var(--teal-bg);
|
||||
color: var(--brn-light);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: "Lucida Sans Unicode", "Lucida Grande", sans-serif;
|
||||
}
|
||||
|
||||
.app-results {
|
||||
float: left;
|
||||
width: 15%;
|
||||
background-color: var(--teal-bg);
|
||||
overflow: auto;
|
||||
}
|
||||
.app-doc {
|
||||
float: right;
|
||||
width: 85%;
|
||||
background-color: #FFFFFF;
|
||||
color: var(--teal-dark);
|
||||
}
|
||||
.top-search {
|
||||
float: right;
|
||||
width: 85%;
|
||||
background-color: var(--teal-bg);
|
||||
}
|
||||
.top-search p {
|
||||
margin-left: 5%;
|
||||
}
|
||||
|
||||
.hit-item a {
|
||||
color: var(--brn-light);
|
||||
}
|
||||
.hit-item :hover {
|
||||
color: var(--teal-dark);
|
||||
background-color: var(--brn-light);
|
||||
}
|
||||
ul.hit-item {
|
||||
list-style-type: none;
|
||||
margin: 3px;
|
||||
padding: 3px;
|
||||
}
|
||||
li.hit-entry {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.search-hits {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hit-collection-group {
|
||||
color: var(--teal-light);
|
||||
margin-top: 0px;
|
||||
}
|
||||
.hit-collection-group h2 a {
|
||||
color: var(--brn-dark);
|
||||
text-decoration: none;
|
||||
}
|
||||
.hit-collection-group h2 {
|
||||
background-color: var(--teal-light);
|
||||
}
|
||||
.hit-collection-group h2:hover {
|
||||
background-color: var(--brn-light);
|
||||
}
|
||||
|
||||
.hit-collection-group h2 {
|
||||
font-weight: bold;
|
||||
font-size: 12pt;
|
||||
text-decoration: none;
|
||||
padding-left: 12pt;
|
||||
font-family: Verdana, monospace;
|
||||
}
|
||||
.app-doc iframe {
|
||||
width: 100%;
|
||||
height: 90%;
|
||||
margin-left: 1%;
|
||||
margin-right: 1%;
|
||||
margin-bottom: 1%;
|
||||
}
|
||||
.doc-box-menu {
|
||||
padding-left: 5%;
|
||||
padding-bottom: 2%;
|
||||
}
|
||||
.doc-box-menu select {
|
||||
width: 80%;
|
||||
}
|
||||
.search-params {
|
||||
float: right;
|
||||
width: 70%;
|
||||
}
|
||||
.types-term {
|
||||
float: left;
|
||||
margin-left: 5%;
|
||||
}
|
||||
.types-term a, .types-collection a {
|
||||
color: var(--red-med-dark);
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.types-collection, .types-term {
|
||||
margin-top: 1%;
|
||||
width: 30%;
|
||||
color: var(--red-med-dark);
|
||||
}
|
||||
.types-collection h2, .types-term h2 {
|
||||
text-decoration: underline;
|
||||
text-align: center;
|
||||
font-weight: normal;
|
||||
}
|
||||
.types-collection {
|
||||
float: right;
|
||||
margin-right: 5%;
|
||||
}
|
||||
.frame-show { }
|
||||
.hide { display: none; }
|
||||
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env node
|
||||
`use strict`;
|
||||
const os = require('os'), path = require('path');
|
||||
const process = require('process');
|
||||
const docserv = require('.');
|
||||
|
||||
|
||||
function main(){
|
||||
function do_help(){
|
||||
let txt = `
|
||||
Usage: docset_server.js --docsets /path/to/docset/dir --port 8080
|
||||
|
||||
This is a very simple docset viewer. Docsets are sqlite3 files
|
||||
containing a search index and a directory structure of HTML
|
||||
files. They are used for reading technical documentation offline.
|
||||
|
||||
You can read more about them here: https://kapeli.com/docsets
|
||||
|
||||
One or more "docset directories" can exist in your ~/.docset
|
||||
directory. If you have the Zeal program installed, the command
|
||||
below will allow you to use the same docset files:
|
||||
|
||||
ln -s .local/share/Zeal/Zeal/docsets .docset
|
||||
|
||||
The parameters --port and --docsets are used to override
|
||||
the defaults.
|
||||
`;
|
||||
console.log(txt);
|
||||
}
|
||||
|
||||
|
||||
let args = process.argv.slice(2);
|
||||
/*
|
||||
port: 8080,
|
||||
docset: path.join(os.homedir(), ".docset")
|
||||
*/
|
||||
|
||||
let opt = { };
|
||||
while(args.length){
|
||||
let arg = args.shift();
|
||||
switch(arg){
|
||||
case '--port':
|
||||
opt.port = parseInt(args.shift());
|
||||
break;
|
||||
case '--docsets':
|
||||
opt.docset = args.shift();
|
||||
break;
|
||||
default:
|
||||
do_help();
|
||||
return(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.log(opt);
|
||||
docserv.start_server(opt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
main();
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
'use strict';
|
||||
|
||||
|
||||
const httpserve = require('./lib/httpserve');
|
||||
/**
|
||||
* start_server(options) -- Start the docset server.
|
||||
*
|
||||
* This is a basic docset server so you can read online documentation
|
||||
* offline. It's not as full featured as "Dash" or "Zeal", but it does
|
||||
* run in a browser and takes up fewer resources.
|
||||
*
|
||||
* You can read more about docsets here:
|
||||
* https://kapeli.com/docsets
|
||||
*
|
||||
* Options are:
|
||||
* - port: Port number to listen to.
|
||||
* - docset: Path to the docset directories.
|
||||
*
|
||||
*
|
||||
* You can run this from the command line via:
|
||||
* bin/docset_server.js
|
||||
*/
|
||||
exports.start_server = httpserve.start;
|
|
@ -0,0 +1,253 @@
|
|||
'use strict';
|
||||
const http = require('http'), url = require('url');
|
||||
const path = require('path'), fs = require('fs');
|
||||
const os = require('os');
|
||||
const WebSocketServer = require('websocket').server;
|
||||
const search = require('./searchdex.js');
|
||||
|
||||
|
||||
function start(opt){
|
||||
opt.port = opt.port || 8080;
|
||||
if(! opt.docset){
|
||||
opt.docset = path.join(os.homedir(),".docset");
|
||||
}
|
||||
console.log("");
|
||||
console.log("Docserve startup ");
|
||||
console.log("HTTP Server listening on port: ",opt.port);
|
||||
console.log("Docset directory: ",opt.docset);
|
||||
console.log("");
|
||||
|
||||
let serv = new Serve(opt);
|
||||
function handler(req, res){
|
||||
try {
|
||||
serv.dispatch(req, res);
|
||||
}catch(err){
|
||||
console.log(err);
|
||||
serv.error(req, res, err);
|
||||
}
|
||||
}
|
||||
let hs = http.createServer( handler );
|
||||
hs.listen(opt.port);
|
||||
let disp = new WSDispatch(opt,hs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function serve_file(res, fn){
|
||||
let p = path.parse(fn);
|
||||
switch(p.ext){
|
||||
case '.css':
|
||||
res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8'} );
|
||||
break;
|
||||
case '.js':
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8'} );
|
||||
break;
|
||||
case '.txt':
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8'} );
|
||||
break;
|
||||
case '.htm':
|
||||
case '.html':
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8'} );
|
||||
break;
|
||||
default:
|
||||
res.writeHead(200, { 'Content-Type': 'application/octet-stream'} );
|
||||
break;
|
||||
}
|
||||
fs.readFile(fn, 'utf8', (err, data) => {
|
||||
if(err){
|
||||
console.log(err);
|
||||
res.end();
|
||||
}else{
|
||||
res.write(data);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
class WSDispatch {
|
||||
constructor(opt,hs){
|
||||
this.hs = hs;
|
||||
this.ds = new search.DocSet(opt.docset);
|
||||
let ws = new WebSocketServer({
|
||||
httpServer: hs,
|
||||
autoAcceptConnections: false
|
||||
});
|
||||
ws.on('request', (req) => {
|
||||
if(this.is_ws(req)){
|
||||
let con = req.accept(null,req.origin);
|
||||
this.setup_connection(con);
|
||||
}
|
||||
});
|
||||
this.ws = ws;
|
||||
}
|
||||
setup_connection(con) {
|
||||
let ws = this;
|
||||
con.on('message', msg => {
|
||||
let data = JSON.parse(msg.utf8Data);
|
||||
if(data.call){
|
||||
try {
|
||||
ws.dispatch(con, data);
|
||||
}catch(err) {
|
||||
console.log(data.call);
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
con.on('close', (code,diz) => {
|
||||
console.log(`${code} - ${diz}`);
|
||||
});
|
||||
this.do_types(con, {});
|
||||
this.do_collection(con, {});
|
||||
}
|
||||
/**
|
||||
* Dispatch the request.
|
||||
*
|
||||
* @return {bool} True if the request was handled.
|
||||
*/
|
||||
dispatch(con, data){
|
||||
switch(data.call){
|
||||
case 'search':
|
||||
this.do_search(con,data);
|
||||
return(true);
|
||||
break;
|
||||
case 'types':
|
||||
this.do_types(con, data);
|
||||
return(true);
|
||||
break;
|
||||
default:
|
||||
console.log("Unknown: " + data.call);
|
||||
return(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
is_ws(req){
|
||||
if(req.resource == "/ws"){
|
||||
return(true);
|
||||
}
|
||||
return(false);
|
||||
}
|
||||
|
||||
do_search(con,data){
|
||||
let qry = {
|
||||
term: '%' + data.term + '%'
|
||||
}
|
||||
if(data.collection){
|
||||
qry.collection = data.collection;
|
||||
}
|
||||
if(data.type){
|
||||
qry.type = data.type;
|
||||
}
|
||||
this.ds.search(qry, m => {
|
||||
con.send(JSON.stringify(m));
|
||||
});
|
||||
}
|
||||
do_types(con, _data){
|
||||
function gather( name ){
|
||||
con.send(JSON.stringify({data_type: 'id.type', type: name}));
|
||||
}
|
||||
this.ds.types(gather).then( r => {
|
||||
// don't really need to finalize a websocket, but this is when
|
||||
// the gather() function is done.
|
||||
}).catch(err => {
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
do_collection(con, _data){
|
||||
this.ds.collection().then( rv => {
|
||||
let rec = {
|
||||
data_type: 'id.collection',
|
||||
collection: []
|
||||
};
|
||||
rv.forEach( el => {
|
||||
rec.collection.push(el.name);
|
||||
});
|
||||
con.send(JSON.stringify(rec));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class Serve {
|
||||
constructor(opt){
|
||||
this.srch = new search.DocSet(opt.docset);
|
||||
}
|
||||
error(req, res, err){
|
||||
res.writeHead(500,{ 'Content-Type': 'application/json' });
|
||||
res.write(JSON.stringify({ resp: 'error', message: 'Check server logs' }));
|
||||
res.end();
|
||||
}
|
||||
dispatch(req, res){
|
||||
let q = url.parse(req.url);
|
||||
let p = q.pathname.split('/');
|
||||
p.shift();
|
||||
let bp = p.shift();
|
||||
switch(bp){
|
||||
case 'static':
|
||||
return(this.do_static(req,res));
|
||||
break;
|
||||
case 'page':
|
||||
return(this.do_fetch_content(req, res));
|
||||
break;
|
||||
default:
|
||||
this.do_index(req,res);
|
||||
return(true);
|
||||
}
|
||||
}
|
||||
|
||||
do_index(req, res){
|
||||
let fn = path.join(__dirname,'..','asset','main.html');
|
||||
serve_file(res,fn);
|
||||
}
|
||||
do_static(req, res){
|
||||
let q = url.parse(req.url);
|
||||
let a_dir = q.pathname.split('/');
|
||||
a_dir.shift(); // remove leading ['','static']
|
||||
a_dir.shift();
|
||||
let fn = path.join(__dirname,'..','asset',a_dir.join(path.sep));
|
||||
serve_file(res, fn);
|
||||
return(true);
|
||||
}
|
||||
do_fetch_content(req, res){
|
||||
let u = url.parse(req.url,true);
|
||||
|
||||
let page_ar = u.path.split("/");
|
||||
// skip ahead to /page/
|
||||
while(page_ar[0] != 'page'){
|
||||
page_ar.shift();
|
||||
}
|
||||
page_ar.shift(); // last 'page'
|
||||
|
||||
// Vim/path/to/vim-doc.html
|
||||
let set = page_ar.shift();
|
||||
let page = page_ar.join("/");
|
||||
this.srch.fetch(set, page).then( prec => {
|
||||
let mtype = prec.mime_type;
|
||||
if( mtype.match(/^text/i) ){
|
||||
mtype += "; charset=utf-8";
|
||||
}
|
||||
|
||||
let buf = prec.buffer;
|
||||
res.writeHead(200, {
|
||||
"Content-Type": mtype,
|
||||
"Content-Length": buf.length
|
||||
});
|
||||
res.write(buf.toString());
|
||||
res.end();
|
||||
}).catch(err => {
|
||||
if(err.code == 'ENOENT'){
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain'} );
|
||||
res.write("Not found");
|
||||
}else{
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain'} );
|
||||
res.write("Server had a boo-boo, please check logs");
|
||||
console.log(err);
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
return(true);
|
||||
}
|
||||
}
|
||||
|
||||
exports.start = start;
|
||||
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
'use strict';
|
||||
const sqlite3 = require('sqlite3');
|
||||
const path = require('path'), fs = require('fs');
|
||||
const mime = require('mime/lite');
|
||||
|
||||
const fsp = require('fs').promises;
|
||||
|
||||
/**
|
||||
* Maximum file size of content files.
|
||||
*/
|
||||
const MAX_FILE_SIZE = 67108864;
|
||||
|
||||
/**
|
||||
* Perform the search in a specific sqlite3 database file.
|
||||
*
|
||||
* @param {String} dbname - sqlite3 database name. (docSet.dsidx file)
|
||||
* @param {String} term - LIKE query (must include the %'s)
|
||||
* @param {Function} cb - Callback function receives each row of hits.
|
||||
* @return {Promise} - resolves to the count of records found.
|
||||
*/
|
||||
function scan_dbf(dbname, type, term, cb){
|
||||
let db = new sqlite3.Database(dbname);
|
||||
let stype = "'" + type.join("','") + "'";
|
||||
|
||||
return new Promise( (pass,fail) => {
|
||||
try {
|
||||
let stmt = db.prepare("SELECT name, type, path FROM searchIndex WHERE type IN (" + stype + ") AND name LIKE ?");
|
||||
stmt.on('error',err => { console.log("oops: ",dbname,"err: ",err); });
|
||||
stmt.each([term], (err, row) => {
|
||||
if(err){
|
||||
fail(err);
|
||||
console.log(err);
|
||||
}else{
|
||||
try {
|
||||
cb(row);
|
||||
}catch(err){
|
||||
fail(err);
|
||||
}
|
||||
}
|
||||
}, (err, count) => {
|
||||
stmt.finalize();
|
||||
if(err) {
|
||||
console.log(err);
|
||||
fail(err);
|
||||
}else{
|
||||
pass(count);
|
||||
}
|
||||
});
|
||||
}catch(err){
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function get_all_types(dbname, cb){
|
||||
let db = new sqlite3.Database(dbname);
|
||||
return new Promise( (pass,fail) => {
|
||||
try {
|
||||
let stmt = db.prepare('SELECT DISTINCT type FROM searchIndex');
|
||||
stmt.on('error',err => { console.log("oops: ",dbname,"err:",err); });
|
||||
stmt.each([], (err, row) => {
|
||||
if(err){
|
||||
fail(err);
|
||||
}else{
|
||||
cb(row.type);
|
||||
}
|
||||
}, (err, count) => {
|
||||
if(err) {
|
||||
fail(err);
|
||||
}else{
|
||||
pass(count);
|
||||
}
|
||||
});
|
||||
stmt.finalize();
|
||||
}catch(err){
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//---- end of normal functions --
|
||||
|
||||
/**
|
||||
* Interface for searching the Zeal/Dash "docsets".
|
||||
*/
|
||||
const DocSet = function(docsets){
|
||||
this.docsets = docsets;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all the types of all the collections.
|
||||
*
|
||||
* @param {Function} gather - Gather results as they come in.
|
||||
*
|
||||
* @return {Set} - (Promise) that Resolves to the Set of types.
|
||||
*/
|
||||
DocSet.prototype.types = async function(gather) {
|
||||
let col = await this.collection();
|
||||
let sent = new Set();
|
||||
if(! gather){
|
||||
gather = function() { };
|
||||
}
|
||||
|
||||
let cb = function(name){
|
||||
if(! sent.has(name)) {
|
||||
sent.add(name);
|
||||
gather(name);
|
||||
}
|
||||
}
|
||||
return new Promise( (pass,fail) => {
|
||||
let prom = [];
|
||||
col.forEach( it => {
|
||||
prom.push( get_all_types(it.dbname, cb) );
|
||||
});
|
||||
Promise.all(prom).then( r => {
|
||||
pass(sent);
|
||||
}).catch(err => {
|
||||
fail(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
DocSet.prototype.dbm_path = function(name){
|
||||
name = name.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
return(path.join(this.docsets, name + ".docset", 'Contents', 'Resources', 'docSet.dsidx'));
|
||||
}
|
||||
DocSet.prototype.file_path = function(col,name){
|
||||
col = col.replace(/[^a-zA-Z0-9_\-\+\=\.]/g, "");
|
||||
let ix = name.lastIndexOf(">");
|
||||
if(ix > -1){
|
||||
++ix;
|
||||
name = name.substr(ix);
|
||||
}
|
||||
ix = name.indexOf("#");
|
||||
if(ix > 0){
|
||||
name = name.substr(0,ix);
|
||||
}
|
||||
return(path.join(this.docsets, col + ".docset", 'Contents', 'Resources', 'Documents', name ));
|
||||
}
|
||||
|
||||
DocSet.prototype.fetch = function(collection, fp){
|
||||
let filename = this.file_path(collection, fp);
|
||||
let mtype = mime.getType(filename);
|
||||
let size = 0;
|
||||
return new Promise( (pass,fail) => {
|
||||
let handle = 0;
|
||||
let rbuf = null;
|
||||
fsp.open(filename).then( fh => {
|
||||
handle = fh; // get stat info.
|
||||
return(handle.stat());
|
||||
}).then(st => {
|
||||
size = st.size;
|
||||
if(size > MAX_FILE_SIZE){
|
||||
fail("File is too large");
|
||||
}
|
||||
rbuf = Buffer.alloc(size);
|
||||
return(handle.read(rbuf,0,rbuf.length,null));
|
||||
}).then( ro => {
|
||||
handle.close();
|
||||
pass( {
|
||||
filename: filename,
|
||||
mime_type: mtype,
|
||||
buffer: rbuf
|
||||
});
|
||||
}).catch(err => {
|
||||
fail(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for records.
|
||||
*
|
||||
* @param {Object} qry - Search info
|
||||
* @param {function} cb - callback receives hits.
|
||||
* @return {Promise}
|
||||
*/
|
||||
DocSet.prototype.search = async function(qry, gather){
|
||||
let all_col = await this.collection();
|
||||
let want_set = null;
|
||||
const max_hits = 2000;
|
||||
let ctr = 0;
|
||||
|
||||
// clean out any "'" characters. (any other non-alpha as well)
|
||||
let types = [];
|
||||
qry.type.forEach( t => {
|
||||
types.push(t.replace(/[^a-zA-Z0-9]/g,""));
|
||||
});
|
||||
|
||||
// collection = ['Vim','C','JavaScript']
|
||||
if(qry.collection){
|
||||
want_set = new Set(qry.collection);
|
||||
}
|
||||
|
||||
// returns a function that has access to 'col'
|
||||
function make_gather_cb(col){
|
||||
return(function(row) {
|
||||
if(++ctr < max_hits){
|
||||
row.data_type = "hit";
|
||||
row.collection = col.name;
|
||||
gather(row);
|
||||
}else{
|
||||
if(gather){
|
||||
gather( { data_type: 'hit-max', title: 'Exceeded Limit - please narrow search'} );
|
||||
gather = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// want this collection?
|
||||
function wanted(name){
|
||||
if(want_set){
|
||||
return want_set.has(name);
|
||||
}else{
|
||||
return(true); // unspecified, search all.
|
||||
}
|
||||
}
|
||||
let prom = [];
|
||||
|
||||
all_col.forEach( c => {
|
||||
if(wanted(c.name)) {
|
||||
let p = scan_dbf(c.dbname, types, qry.term, make_gather_cb(c) );
|
||||
prom.push(p);
|
||||
}
|
||||
});
|
||||
return(Promise.all(prom));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return an array of docsets
|
||||
*
|
||||
* @return {Promise} - Array of docset objects
|
||||
*/
|
||||
DocSet.prototype.collection = async function(){
|
||||
let dir = await fs.promises.opendir(this.docsets);
|
||||
let dent = null;
|
||||
let rv = [];
|
||||
for await (dent of dir) {
|
||||
let name = path.basename(dent.name,'.docset');
|
||||
if(dent.isDirectory()){
|
||||
let dbname = path.join(this.docsets, dent.name, 'Contents/Resources/docSet.dsidx')
|
||||
let docs = path.join(this.docsets, dent.name, 'Contents/Resources/Documents');
|
||||
rv.push( {documents: docs, dbname: dbname, name: name} );
|
||||
}
|
||||
}
|
||||
return(rv);
|
||||
}
|
||||
|
||||
|
||||
|
||||
exports.DocSet = DocSet;
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "docset-server",
|
||||
"version": "1.0.0",
|
||||
"description": "An extremely simple docset server for browsing docset files.",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"mime": "^2.4.6",
|
||||
"sqlite3": "^4.2.0",
|
||||
"websocket": "^1.0.31"
|
||||
},
|
||||
"bin": {
|
||||
"docset-server": "docset_server.js"
|
||||
},
|
||||
"directories": {
|
||||
"lib": "lib",
|
||||
"asset": "asset"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [
|
||||
"docset",
|
||||
"viewer"
|
||||
],
|
||||
"author": "hoper@tilde.black",
|
||||
"license": "ISC"
|
||||
}
|
Loading…
Reference in New Issue