Version 1.0.0

This commit is contained in:
joe 2020-07-03 23:10:10 -04:00
parent 8d4b31bfd2
commit 9198ab6b37
10 changed files with 237243 additions and 0 deletions

376
asset/client.js Normal file
View File

@ -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);
}
}

22
asset/color.html Normal file
View File

@ -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>

93
asset/main.html Normal file
View File

@ -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>

235970
asset/pool.words Normal file

File diff suppressed because it is too large Load Diff

160
asset/style.css Normal file
View File

@ -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; }

63
docset_server.js Executable file
View File

@ -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();

23
index.js Normal file
View File

@ -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;

253
lib/httpserve.js Normal file
View File

@ -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;

256
lib/searchdex.js Normal file
View File

@ -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;

27
package.json Normal file
View File

@ -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"
}