QWiki/index.js

770 lines
24 KiB
JavaScript

#!/usr/bin/env node
var http = require('http');
var fs = require('fs');
var m_path = require('path');
var util = require('util');
/* qwiki
Node.js based Implementation of the proposed qwiki document
*/
/*********************************
* QCore *
*********************************/
var QCore = function() {
var acts = {};
this.acts = acts;
self = this;
this.mimetypes = {};
this.index = null;
this.index_parts = [];
this.last_part = { start: 0, end: 0 };
this.etags = {}; // etags cache, obj: uri { etag: '...', timestamp: }
this.res_cache = {}; // resource cache ('/view.png' => '03xc345');
this.rules = {};
this.http = http.createServer(function(req, res) {
var last = req.url.lastIndexOf("/");
var act = req.url.substr(last+1);
if (acts[act]) {
req.url = req.url.substr(0, last); // rewrite the url without the act
} else {
act = "";
}
req.post = {};
if (req.method == 'POST') {
var chunks = [];
var chunks_len = 0;
req.on('data', function(chunk) {
chunks.push(chunk);
chunks_len += chunk.length;
});
req.on('end', function(){
var data = Buffer.concat(chunks, chunks_len);
var parts = data.toString().split('&');
for (var i = 0; i < parts.length; i++) {
split = parts[i].split('=');
key = split[0];
value = split[1];
req.post[key] = decodeURIComponent(value.replace(/[+]/g, function(c) { return ' '; }));
}
for (var i = 0, keys = Object.keys(acts[act]); i < keys.length; i++) {
acts[act][i](req, res);
}
});
} else {
for (var i = 0, keys = Object.keys(acts[act]); i < keys.length; i++) {
acts[act][i](req, res);
}
}
});
this.act = function(act, callback) {
if (typeof acts[act] === 'undefined') {
acts[act] = [];
}
acts[act].push(callback);
};
this.rule = function(zone, replace, callback) {
if (typeof this.rules[zone] === 'undefined') {
this.rules[zone] = {};
}
this.rules[zone][replace] = callback;
};
var fs_timeout; // timer to bypass multiple fs.watch triggering due to unstable API
this.loadIndex = function(event, filename) {
if (fs_timeout) return;
fs_timeout = setTimeout(function() { fs_timeout = null; }, 100);
var reg = /@@[a-z]+@@/ig;
fs.readFile('index.html', function(err, data) {
if (err) throw err;
self.index = data;
var search;
self.index_parts = [];
while ((search = reg.exec(data)) !== null) {
self.index_parts.push({ replace: search[0], start: search.index, end: reg.lastIndex });
}
});
};
fs.watch('index.html', this.loadIndex);
};
QCore.prototype.defaults = {};
QCore.prototype.listen = function(port) {
this.loadIndex();
this.loadWikiIndex();
try {
this.http.listen(port);
} catch(err) {
console.log('uhoh, could not listen on port '+port);
console.log(err);
}
};
QCore.prototype.setDefault = function(key, value) {
this.defaults[key] = value;
};
QCore.prototype.getDefault = function(key) {
if (key in this.defaults) {
return this.defaults[key];
}
return '';
};
QCore.prototype.addMIMEtype = function(ext, mimetype) {
if (typeof this.mimetypes[ext] === 'undefined') this.mimetypes[ext] = [];
this.mimetypes[ext].push(mimetype);
};
QCore.prototype.getMIMEtype = function(ext) {
if (this.mimetypes[ext]) return this.mimetypes[ext][0];
return '';
};
QCore.prototype.loadWikiIndex = function() {
try {
this.wiki_index = JSON.parse(fs.readFileSync('index.json'));
} catch (e) {
this.wiki_index = { pages: {} };
console.log(e);
}
var wiki_index = this.wiki_index;
console.log('loaded indices for ' + Object.keys(this.wiki_index.pages).length + ' pages');
readFiles('wiki', function(err, file, is_dir) {
var wiki_file = file.replace('wiki/', '');
if (is_dir) {
if (wiki_file in wiki_index.pages) {
if (wiki_index.pages[wiki_file].dir != true) wiki_index.pages[wiki_file].dir = true;
} else {
wiki_index.pages[wiki_file] = { dir: true };
}
} else {
if (getExt(file) == 'qwk') {
if (wiki_file in wiki_index.pages) {
} else {
var name = wiki_file.slice(0, -4);
wiki_index.pages[name] = { format: 'md' }; // TODO: this.config.default_type
}
} else {
if (wiki_file in wiki_index.pages) {
} else {
wiki_index.pages[wiki_file] = { format: 'file' };
}
}
}
});
}
QCore.prototype.parsePage = function(zone, fallback_zone, req, res) {
var qparse = new QParse();
qparse.indices = this.index_parts.slice(); // clone it TODO: just make an indices (number-only) array
var context = this;
var cb = function(qparse, callback) {
res.write(context.index.slice(qparse.offset, qparse.current.start));
if (typeof context.rules[zone] === 'undefined') { zone = fallback_zone; }
if (typeof context.rules[zone] === 'undefined') { zone = ''; }
if (typeof context.rules[zone] !== 'undefined') {
if (typeof context.rules[zone][qparse.current.replace] !== 'undefined') {
context.rules[zone][qparse.current.replace](req, res, qparse, callback);
} else {
// fallback action
if (typeof context.rules[fallback_zone][qparse.current.replace] !== 'undefined') {
context.rules[fallback_zone][qparse.current.replace](req, res, qparse, callback);
// default action
} else if (typeof context.rules[''][qparse.current.replace] !== 'undefined') {
context.rules[''][qparse.current.replace](req, res, qparse, callback);
} else {
callback();
}
}
}
};
var series = function(qparse) {
if (qparse.current) {
cb(qparse, function(next) {
if (qparse.next) {
res.write(context.index.slice(qparse.current.end, qparse.next.start));
qparse.offset = qparse.next.end;
qparse.current = qparse.indices.shift();
qparse.next = qparse.indices[0];
series(qparse);
} else {
res.write(context.index.slice(qparse.current.end));
qparse.current = qparse.indices.shift();
qparse.next = qparse.indices[0];
series(qparse);
}
});
} else {
res.end();
}
};
qparse.current = qparse.indices.shift();
qparse.next = qparse.indices[0];
series(qparse);
};
/*********************************
* QParse *
*********************************/
var QParse = function() {};
QParse.prototype.indices = {};
QParse.prototype.offset = 0;
QParse.prototype.current = null;
QParse.prototype.next = null;
/*********************************
* QWiki *
*********************************/
var QWiki = function() {
QCore.call(this);
try {
var stats = fs.statSync('cache');
if (!stats.isDirectory()) {
console.log('error, "cache" is not a directory');
}
} catch (err) {
if (err.code == 'ENOENT') {
fs.mkdirSync('cache');
console.log('Created "cache/" -- this is where processed wiki pages are stored as HTML');
} else {
console.log('error while loading cache');
}
}
}; util.inherits(QWiki, QCore);
QWiki.prototype.formats = {
raw: {
name: 'raw',
fullname: 'Raw',
convert: function(source) {
return source;
},
},
html: {
name: 'html',
fullname: 'HTML',
convert: function(source) {
return source;
}
}
};
QWiki.prototype.addFormat = function(name, fullname, convertor_cb, pre_cb, post_cb) {
this.formats[name] = {
'name': name,
'fullname': fullname,
'convert': convertor_cb,
'pre': pre_cb,
'post': post_cb
};
};
QWiki.prototype.convertAndSave = function(path, name, source, cb) {
if (typeof cb === 'undefined') cb = function() {};
if (!(name in this.formats)) {
name = 'raw';
}
console.log('converting to ' + name);
var converted = this.formats[name].convert(source);
r_mkdir(m_path.dirname('cache'+path+'.html'), 0777, function() {
fs.writeFile('cache'+path+'.html', converted, function(err) {
if (err) {
console.log('error writing "cache'+ path + '.html"');
} else {
console.log('wrote "cache' + path + '.html"');
}
cb();
});
});
};
QWiki.prototype.deleteCache = function(wiki_page, cb) {
fs.unlink('cache/'+wiki_page+'.html', cb);
};
QWiki.prototype.createCache = function(wiki_page, source, cb) {
if (typeof cb === 'undefined') cb = function() {};
var format = this.defaults.format;
if (typeof this.wiki_index.pages[wiki_page] !== 'undefined') {
format = this.wiki_index.pages[wiki_page].format;
}
if (!(format in this.formats)) {
format = 'raw';
}
console.log('createCache: caching ' + wiki_page + ' as ' + format);
var converted = this.formats[format].convert(source);
var path = 'cache/' + wiki_page + '.html';
r_mkdir(m_path.dirname(path), 0777, function() {
fs.writeFile(path, converted, function(err) {
if (err) {
console.log('createCache: error writing "' + path + '"');
} else {
console.log('createCache: created "' + path + '"');
}
cb(err);
});
});
};
QWiki.prototype.deletePage = function(wiki_page, cb) {
fs.unlink('wiki/'+wiki_page+'.qwk', cb);
};
QWiki.prototype.savePage = function(wiki_page, content, cb) {
var path = 'wiki/' + wiki_page + '.qwk';
r_mkdir(m_path.dirname(path), 0777, function() {
console.log('savePage: writing ' + path);
qwiki.deleteCache(wiki_page, function() {
fs.writeFile(path, content, function(err) {
console.log('savePage: wrote ' + path);
cb(err);
});
});
});
};
/*********************************
* qwiki instance *
*********************************/
var qwiki = new QWiki();
qwiki.addMIMEtype('png', 'image/png');
qwiki.addMIMEtype('svg', 'image/svg+xml');
qwiki.addMIMEtype('css', 'text/css');
var m_markdown = require("markdown").markdown;
qwiki.addFormat('md', 'Markdown', function(source) {
// parse the markdown into a tree and grab the link references
var tree = m_markdown.parse( source.toString() );
if (!tree[1] || !tree[1].references) {
tree.splice(1, 0, { 'references' : {} });
}
var refs = tree[1].references;
// iterate through the tree finding link references
( function find_link_refs( jsonml ) {
if (jsonml[0] === "link_ref") {
var ref = jsonml[1].ref;
if (!refs[ref]) {
refs[ref] = {
href: ref.replace(/\s+/, "_" )
};
}
} else {
for (var item in jsonml) {
if (Array.isArray(jsonml[item])) {
find_link_refs(jsonml[item]);
}
}
}
} )( tree );
// convert the tree into html
return m_markdown.renderJsonML(m_markdown.toHTMLTree(tree));
});
qwiki.setDefault('format', 'md');
// **** DEFAULT
qwiki.rule('', '@@CONTENT@@', function(req, res, instance, next) {
var area = req.url;
if (area == '') {
area = 'front';
}
// instance.pos, instance.offset, instance.indices
var cache_path = 'cache/'+area+'.html';
readFile(res, cache_path, function(type, err) {
if (type == 'FNF') {
// cache does not exist - is there a wiki source?
var wiki_path = 'wiki/'+area+'.qwk';
fs.stat(wiki_path, function(err, stats) {
// TODO: stats.isFile()
if (err && err.code == 'ENOENT') {
// the wiki entry does not exist
res.write(area + ' does not exist yet');
next();
} else if (err) {
// error while statting wiki entry
res.write(area + ': ' + err.code);
next();
} else {
// wiki entry exists!
fs.readFile(wiki_path, function(err, data) {
if (err) {
res.write(area + ': ' + err.code);
next();
} else {
qwiki.convertAndSave(area, (req.url in qwiki.wiki_index.pages ? qwiki.wiki_index.pages[req.url].format : qwiki.getDefault('format')), data, function() {
readFile(res, wiki_path, function(type, err) {
if (type == 'FNF') {
res.write('error while creating cache');
next();
} else if (err) {
res.write(area + ': ' + err.code);
next();
} else {
next();
}
});
});
}
});
}
});
} else if (err) {
res.write(area + ': ' + err.code);
next();
} else {
next();
}
});
});
qwiki.rule('', '@@TITLE@@', function(req, res, instance, next) {
res.write('qwiki ' + req.url);
next();
});
qwiki.rule('', '@@PAGE@@', function(req, res, instance, next) {
res.write(req.url);
next();
});
qwiki.rule('', '@@CONTROLS@@', function(req, res, instance, next) {
res.write('<li><a href="'+req.url+'/edit"><img src="/edit.png">Edit</a></li><li><a href="'+req.url+'/new"><img src="/new.png">New Page</a></li>');
next();
});
qwiki.rule('', '@@CRUMBS@@', function(req, res, instance, next) {
var parts = req.url.split('/');
var path = '';
parts[0] = '>';
for (var i = 0; i < parts.length; i++) {
path += (i == 0 ? '' : parts[i])+(i == parts.length-1 ? '' : '/');
res.write('<li><a href="'+path+'">'+parts[i]+'</a></li>');
}
next();
});
qwiki.rule('', '@@FOOTER@@', function(req, res, instance, next) {
res.write('<a href="http://kettek.net/qwiki">qwiki</a> Copyright 2015 <a href="http://kettek.net">kts of kettek</a>');
next();
});
qwiki.act('', function(req, res) {
var mimetype = qwiki.getMIMEtype(getExt(req.url));
if (mimetype != '') { // write file on disk directly
var path = 'wiki/'+req.url;
fs.stat(path, function(err, stat) {
if (err == null) {
// TODO: etags should actually be based on binary data(CRC32, etc.), not last modified
// TODO: files should not be stat()`d each call, but should be cached and updated when the file updates (how? browser-based upload interface?)
var mtime = stat.mtime.getTime();
if (!(path in qwiki.etags) || qwiki.etags[path] != mtime) {
qwiki.etags[path] = mtime;
}
if ('if-none-match' in req.headers && req.headers['if-none-match'] == qwiki.etags[path]) {
res.writeHead(304, "Not Modified");
res.end();
} else {
res.writeHead(200, "OK", {
"Content-Type": mimetype,
"Content-Length": stat.size,
"ETag": mtime
});
var rs = fs.createReadStream(path);
rs.on('data', function(chunk) {
res.write(chunk);
});
rs.on('end', function() {
res.end();
});
}
} else if (err.code == 'ENOENT') {
res.writeHead(404);
res.end();
} else {
console.log(err);
res.write(err);
res.end();
}
});
} else { // no mimetype/ext, it must be a wiki entry
res.writeHead(200, "OK", {
"Content-Type": "text/html",
});
qwiki.parsePage('', '', req, res);
}
});
// **** INDEX
qwiki.rule('index', '@@CONTENT@@', function(req, res, instance, next) {
var path = 'wiki/'+req.url;
console.log('reading path' + path);
fs.readdir(path, function(err, files) {
for (var file in files) {
if (getExt(files[file]) == 'qwk') {
name = files[file].slice(0, -4);
res.write('<li><a href="'+req.url+'/'+name+'">'+name+'</a></li>');
} else {
res.write('<li><a href="'+req.url+'/'+files[file]+'">'+files[file]+'</a></li>');
}
}
next();
});
});
qwiki.act('index', function(req, res) {
res.writeHead(200, "OK", {
"Content-Type": "text/html",
});
qwiki.parsePage('index', '', req, res);
});
// **** UPLOAD
qwiki.act('upload', function(req, res) {
res.writeHead(200, "OK", {
"Content-Type": "text/html",
});
qwiki.parsePage('upload', '', req, res);
});
// **** VIEW
qwiki.act('view', function(req, res) {
if (req.url == '') {
req.url = 'index';
}
var path = 'wiki/'+req.url+'.qwk';
readFile(res, path, function(type, err) {
if (type == 'FNF') {
} else if (err) {
res.write(err);
}
res.end();
});
});
// **** NEW
qwiki.rule('new', '@@CONTENT@@', function(req, res, instance, next) {
var area = req.url;
if (area == '') {
area = 'new_page';
} else {
area = req.url.substr(1) + '/new_page';
}
var path = 'wiki/'+req.url+'.qwk';
res.write('<form action="" method="POST"><div class="edit"><div><label for="page"><span>This is the page name that corresponds to the wiki URL, e.g., my_page => kettek.net/qwiki/my_page</span>Page</label> <input type="text" name="page" value="'+area+'"></div>');
res.write('</div><div class="prompt"><input type="submit" name="submit" value="edit"></div></form>');
next();
});
qwiki.act('new', function(req, res) {
// handle POST
if ('submit' in req.post && req.post['submit'] == 'edit') {
if ('page' in req.post) {
res.writeHead(302, {'Location': '/'+req.post['page']+'/edit'});
res.end();
return;
}
}
res.writeHead(200, "OK", {
"Content-Type": "text/html",
});
qwiki.parsePage('new', 'edit', req, res);
});
// **** EDIT
qwiki.rule('edit', '@@CONTENT@@', function(req, res, instance, next) {
var area = (req.url == '' ? 'front' : req.url.substr(1));
var path = 'wiki/'+area+'.qwk';
res.write('<form action="" method="POST"><div class="edit"><div><label for="page"><span>This is the page name that corresponds to the wiki URL, e.g., my_page => kettek.net/qwiki/my_page</span>Page</label> <input type="text" name="page" value="'+area+'"></div>');
res.write('<div><label for="format"><span>This is the data format of content source, such as HTML or raw.</span>Format</label> <select name="format">');
for (var format in qwiki.formats) {
var page_format = (typeof qwiki.wiki_index.pages[area] !== 'undefined' ? qwiki.wiki_index.pages[area].format : qwiki.getDefault('format'));
res.write('<option ' + (qwiki.formats[format].name == page_format ? 'selected ' : '') + 'value="'+qwiki.formats[format].name+'">'+qwiki.formats[format].fullname+'</option>');
}
res.write('</select></div>');
res.write('</div><div id="edit_content"><label for="content">Content<span>This is the content of the wiki page</span></label> <textarea name="content">');
readFile(res, path, function(type, err) {
if (type == 'FNF') {
} else if (err) {
res.write(err);
}
res.write('</textarea></div><div class="prompt"><input type="submit" name="submit" value="cancel"><input type="submit" name="submit" value="save"></div></form>');
next();
});
});
qwiki.rule('edit', '@@CONTROLS@@', function(req, res, instance, next) {
res.write('<li><a href="'+(req.url == '' ? '/' : req.url)+'"><img src="/view.png">View</a></li><li><a href="'+req.url+'/revisions"><img src="/revisions.png">Revisions</a></li><li><a href="'+req.url+'/delete"><img src="/delete.png">Delete</a></li>');
next();
});
qwiki.act('edit', function(req, res) {
var area = (req.url == '' ? 'front' : req.url.substr(1));
// handle POST
if ('submit' in req.post && req.post['submit'] == 'save') {
var old_area = area;
if ('page' in req.post) {
area = req.post['page'];
}
if (typeof qwiki.wiki_index.pages[area] == 'undefined') {
qwiki.wiki_index.pages[area] = {
format: qwiki.getDefault('format')
};
}
// get our format
if ('format' in req.post) {
qwiki.wiki_index.pages[area].format = req.post['format'];
}
//
console.log(old_area + ' vs ' + area);
if (old_area != area) {
fs.rename('wiki/'+old_area+'.qwk', 'wiki/'+area+'.qwk', function() {
qwiki.deleteCache(old_area, function() {
if ('content' in req.post) {
qwiki.savePage(area, req.post['content'], function(err) {
if (err) {
res.write(err.code);
res.end();
} else {
qwiki.createCache(area, req.post['content'], function(err) {
if (err) {
res.write(err.code);
res.end();
} else {
res.writeHead(302, {'Location': '/'+area+'/edit'});
res.end();
}
});
}
});
}
});
});
} else {
if ('content' in req.post) {
qwiki.savePage(area, req.post['content'], function(err) {
if (err) {
res.write(err.code);
res.end();
} else {
qwiki.createCache(area, req.post['content'], function(err) {
if (err) {
res.write(err.code);
res.end();
} else {
res.writeHead(302, {'Location': '/'+area+'/edit'});
res.end();
}
});
}
});
}
}
return;
}
res.writeHead(200, "OK", {
"Content-Type": "text/html",
});
qwiki.parsePage('edit', '', req, res);
});
// **** DELETE
qwiki.rule('delete', '@@CONTENT@@', function(req, res, instance, next) {
res.write('<form action="" method="POST"> Deleting this page will also delete all page revisions! Are you sure you wish to do this?<div class="prompt"><input type="submit" name="submit" value="Yes"><input type="submit" name="submit" value="No"></div></form>');
next()
});
qwiki.act('delete', function(req, res) {
var area = (req.url == '' ? 'front' : req.url.substr(1));
// handle POST
if ('submit' in req.post) {
if (req.post['submit'] == 'Yes') {
qwiki.deletePage(area, function() {
qwiki.deleteCache(area, function() {
console.log('redirecting to '+req.url);
res.writeHead(302, {'Location': req.url});
res.end();
});
});
return;
} else {
res.writeHead(302, {'Location': req.url+'/edit'});
res.end();
}
}
qwiki.parsePage('delete', 'edit', req, res);
});
// **** REVISIONS
qwiki.rule('revisions', '@@CONTENT@@', function(req, res, instance, next) {
var path = 'wiki/'+req.url+'.qwk';
res.write('revisions for ' + req.url.substr(1));
next()
});
qwiki.act('revisions', function(req, res) {
console.log("revisions action");
qwiki.parsePage('revisions', 'edit', req, res);
});
// **** SEARCH
qwiki.rule('search', '@@CONTENT@@', function(req, res, instance, next) {
if ('submit' in req.post) {
if ('search' in req.post) {
res.write('Results for: ' + req.post['search']);
}
}
next();
});
qwiki.act('search', function(req, res) {
res.writeHead(200, "OK", {
"Content-Type": "text/html",
});
qwiki.parsePage('search', '', req, res);
});
var readFile = function(stream, path, cb) {
fs.exists(path, function(exists) {
if (exists) {
var rs = fs.createReadStream(path);
rs.on('data', function(chunk) {
stream.write(chunk);
});
rs.on('end', function() {
cb('EOF');
});
rs.on('error', function(err) {
cb('ERR', err);
});
} else {
cb('FNF'); // file not found
}
});
};
var readFiles = function(dir, callback) {
fs.readdir(dir, function(err, files) {
files.forEach(function(file) {
var stat_file = dir+'/'+file
fs.stat(stat_file, function(err, stats) {
if (stats.isDirectory()) {
readFiles(stat_file, callback);
callback(err, stat_file, true);
} else if (stats.isFile()) {
callback(err, stat_file, false);
}
});
});
});
};
var getExt = function(file) {
var last = file.lastIndexOf(".");
var ext = file.substr(last+1);
return ext;
};
var r_mkdir = function(dir, mode, cb) {
console.log('creating '+dir);
fs.mkdir(dir, mode, function(err) {
if (err && err.code == 'ENOENT') {
r_mkdir(m_path.dirname(dir), mode, r_mkdir.bind(this, dir, mode, cb));
} else if (typeof cb !== 'undefined') {
cb(err);
}
});
};
var port = 8080;
process.argv.forEach(function (val, index, array) {
var parts = val.split('=');
var key = '';
var value = '';
if (parts[0].substr(0, 2) == '--') {
key = parts[0].substr(2);
value = parts[1];
}
if (key == 'port') {
if (value == '') {
console.log(key + ' needs a value');
} else {
port = Number(value);
}
}
});
qwiki.listen(port);