Merge 268aaebaf0
into 64f5dd942c
This commit is contained in:
commit
b35b3802c1
|
@ -5,6 +5,7 @@
|
|||
tmp/
|
||||
output/
|
||||
node_modules/
|
||||
store/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
The `multi-wiki-support` branch includes some changes that are not intended to be merged into the `master` branch:
|
||||
|
||||
* Readme update (see `editions/tw5.com/tiddlers/readme/ReadMe.tid`)
|
||||
* Remove `multiwikiserver` plugin from `readme-bld.sh` (see `bin/readme-bld.sh`)
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
# test TiddlyWiki5 for tiddlywiki.com
|
||||
|
||||
npm install
|
||||
|
||||
node ./tiddlywiki.js \
|
||||
./editions/test \
|
||||
--verbose \
|
||||
|
|
|
@ -10,6 +10,7 @@ fi
|
|||
|
||||
# tw5.com readmes
|
||||
node $TW5_BUILD_TIDDLYWIKI \
|
||||
+plugins/tiddlywiki/multiwikiserver \
|
||||
editions/tw5.com \
|
||||
--verbose \
|
||||
--output . \
|
||||
|
|
|
@ -38,6 +38,13 @@ Commander.prototype.log = function(str) {
|
|||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Clear pending commands
|
||||
*/
|
||||
Commander.prototype.clearCommands = function() {
|
||||
this.commandTokens = this.commandTokens.slice(0,this.nextToken);
|
||||
};
|
||||
|
||||
/*
|
||||
Write a string if verbose flag is set
|
||||
*/
|
||||
|
|
|
@ -16,7 +16,7 @@ var Server = require("$:/core/modules/server/server.js").Server;
|
|||
|
||||
exports.info = {
|
||||
name: "listen",
|
||||
synchronous: true,
|
||||
synchronous: false,
|
||||
namedParameterMode: true,
|
||||
mandatoryParameters: []
|
||||
};
|
||||
|
@ -38,7 +38,11 @@ Command.prototype.execute = function() {
|
|||
wiki: this.commander.wiki,
|
||||
variables: self.params
|
||||
});
|
||||
var nodeServer = this.server.listen();
|
||||
var nodeServer = this.server.listen(null,null,null,{
|
||||
callback: function() {
|
||||
self.callback();
|
||||
}
|
||||
});
|
||||
$tw.hooks.invokeHook("th-server-command-post-start",this.server,nodeServer,"tiddlywiki");
|
||||
return null;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
/*\
|
||||
title: $:/core/modules/commands/quit.js
|
||||
type: application/javascript
|
||||
module-type: command
|
||||
|
||||
Immediately ends the TiddlyWiki process
|
||||
|
||||
\*/
|
||||
(function(){
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.info = {
|
||||
name: "quit",
|
||||
synchronous: true
|
||||
};
|
||||
|
||||
var Command = function(params,commander,callback) {
|
||||
var self = this;
|
||||
this.params = params;
|
||||
this.commander = commander;
|
||||
this.callback = callback;
|
||||
};
|
||||
|
||||
Command.prototype.execute = function() {
|
||||
// Clear any pending commands
|
||||
this.commander.clearCommands();
|
||||
// We don't actually quit, we just issue the "th-quit" hook to give listeners a chance to exit
|
||||
$tw.hooks.invokeHook("th-quit");
|
||||
return null;
|
||||
};
|
||||
|
||||
exports.Command = Command;
|
||||
|
||||
})();
|
|
@ -364,6 +364,11 @@ Server.prototype.listen = function(port,host,prefix) {
|
|||
}
|
||||
// Display the port number after we've started listening (the port number might have been specified as zero, in which case we will get an assigned port)
|
||||
server.on("listening",function() {
|
||||
// Stop listening when we get the "th-quit" hook
|
||||
$tw.hooks.addHook("th-quit",function() {
|
||||
server.close();
|
||||
});
|
||||
// Log listening details
|
||||
var address = server.address(),
|
||||
url = self.protocol + "://" + (address.family === "IPv6" ? "[" + address.address + "]" : address.address) + ":" + address.port + prefix;
|
||||
$tw.utils.log("Serving on " + url,"brown/orange");
|
||||
|
|
|
@ -257,7 +257,11 @@ Save an incoming tiddler in the store, and updates the associated tiddlerInfo
|
|||
Syncer.prototype.storeTiddler = function(tiddlerFields) {
|
||||
// Save the tiddler
|
||||
var tiddler = new $tw.Tiddler(tiddlerFields);
|
||||
this.wiki.addTiddler(tiddler);
|
||||
// Only save the tiddler if it has changed
|
||||
var existingTiddler = this.wiki.getTiddler(tiddlerFields.title);
|
||||
if(!existingTiddler || !existingTiddler.isEqual(tiddler)) {
|
||||
this.wiki.addTiddler(tiddler);
|
||||
}
|
||||
// Save the tiddler revision and changeCount details
|
||||
this.tiddlerInfo[tiddlerFields.title] = {
|
||||
revision: this.getTiddlerRevision(tiddlerFields.title),
|
||||
|
|
|
@ -163,4 +163,4 @@ ImageWidget.prototype.refresh = function(changedTiddlers) {
|
|||
|
||||
exports.image = ImageWidget;
|
||||
|
||||
})();
|
||||
})();
|
|
@ -0,0 +1,2 @@
|
|||
title: $:/config/MultiWikiServer/Engine
|
||||
text: better
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"description": "Multiple wiki client-server edition",
|
||||
"plugins": [
|
||||
"tiddlywiki/tiddlyweb",
|
||||
"tiddlywiki/filesystem",
|
||||
"tiddlywiki/multiwikiclient",
|
||||
"tiddlywiki/multiwikiserver"
|
||||
],
|
||||
"themes": [
|
||||
"tiddlywiki/vanilla",
|
||||
"tiddlywiki/snowwhite"
|
||||
],
|
||||
"build": {
|
||||
"index": [
|
||||
"--render","$:/plugins/tiddlywiki/tiddlyweb/save/offline","index.html","text/plain"],
|
||||
"static": [
|
||||
"--render","$:/core/templates/static.template.html","static.html","text/plain",
|
||||
"--render","$:/core/templates/alltiddlers.template.html","alltiddlers.html","text/plain",
|
||||
"--render","[!is[system]]","[encodeuricomponent[]addprefix[static/]addsuffix[.html]]","text/plain","$:/core/templates/static.tiddler.html",
|
||||
"--render","$:/core/templates/static.template.css","static/static.css","text/plain"]
|
||||
}
|
||||
}
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"description": "TiddlyWiki core tests",
|
||||
"plugins": [
|
||||
"tiddlywiki/jasmine"
|
||||
"tiddlywiki/jasmine",
|
||||
"tiddlywiki/multiwikiserver"
|
||||
],
|
||||
"themes": [
|
||||
"tiddlywiki/vanilla",
|
||||
|
|
|
@ -6,6 +6,24 @@ type: text/vnd.tiddlywiki
|
|||
\define tv-wikilink-template() https://tiddlywiki.com/static/$uri_doubleencoded$.html
|
||||
\import [subfilter{$:/core/config/GlobalImportFilter}]
|
||||
|
||||
---
|
||||
|
||||
! ~TiddlyWiki ~MultiWikiServer
|
||||
|
||||
UNDER DEVELOPMENT
|
||||
|
||||
This is a branch of TiddlyWiki that adds the ~MultiWikiServer plugin.
|
||||
|
||||
!! Readme
|
||||
|
||||
{{$:/plugins/tiddlywiki/multiwikiserver/readme}}
|
||||
|
||||
!! Docs
|
||||
|
||||
{{$:/plugins/tiddlywiki/multiwikiserver/docs}}
|
||||
|
||||
---
|
||||
|
||||
Welcome to TiddlyWiki, a non-linear personal web notebook that anyone can use and keep forever, independently of any corporation.
|
||||
|
||||
TiddlyWiki is a complete interactive wiki in JavaScript. It can be used as a single HTML file in the browser or as a powerful Node.js application. It is highly customisable: the entire user interface is itself implemented in hackable WikiText.
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -26,15 +26,18 @@
|
|||
"devDependencies": {
|
||||
"eslint": "^7.32.0"
|
||||
},
|
||||
"bundleDependencies": [],
|
||||
"license": "BSD",
|
||||
"engines": {
|
||||
"node": ">=0.8.2"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node ./tiddlywiki.js ./editions/tw5.com-server --listen",
|
||||
"test": "node ./tiddlywiki.js ./editions/test --verbose --version --build index",
|
||||
"start": "node ./tiddlywiki.js ./editions/multiwikiserver --mws-listen",
|
||||
"test": "node ./tiddlywiki.js ./editions/test --verbose --version --build index && node ./tiddlywiki ./editions/multiwikiserver/ --mws-listen --mws-test-server http://127.0.0.1:8080/ --quit",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"node-sqlite3-wasm": "^0.8.10"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
title: GettingStarted
|
||||
tags: $:/tags/GettingStarted
|
||||
caption: Step 1<br>Syncing
|
||||
|
||||
! ~TiddlyWiki ~MultiWikiServer
|
||||
|
||||
Welcome to ~TiddlyWiki and the ~TiddlyWiki community.
|
||||
|
||||
Please note that ~MultiWikiServer is under active development, and may not be fully robust. Do not use it for anything critical.
|
|
@ -0,0 +1,3 @@
|
|||
title: $:/config/SaveWikiButton/Template
|
||||
|
||||
$:/plugins/tiddlywiki/multiwikiclient/save/offline
|
|
@ -0,0 +1,2 @@
|
|||
title: $:/config/multiwikiclient/incoming-updates-filter
|
||||
text: [all[]] -[[$:/isEncrypted]] -[prefix[$:/temp/]] -[prefix[$:/status/]] -[[$:/boot/boot.js]] -[[$:/boot/bootprefix.js]] -[[$:/library/sjcl.js]] -[[$:/core]] -[prefix[$:/StoryList]] -[prefix[$:/HistoryList]]
|
|
@ -0,0 +1,2 @@
|
|||
title: $:/config/Server/ExternalFilters/[all[tiddlers]] -[[$:/isEncrypted]] -[prefix[$:/temp/]] -[prefix[$:/status/]] -[[$:/boot/boot.js]] -[[$:/boot/bootprefix.js]] -[[$:/library/sjcl.js]] -[[$:/core]]
|
||||
text: yes
|
|
@ -0,0 +1,7 @@
|
|||
title: $:/config/OfficialPluginLibrary
|
||||
tags: $:/tags/PluginLibrary
|
||||
url: https://tiddlywiki.com/library/v5.1.23/index.html
|
||||
caption: {{$:/language/OfficialPluginLibrary}}
|
||||
enabled: no
|
||||
|
||||
The official plugin library is disabled when using the client-server configuration. Instead, plugins should be installed via the `tiddlywiki.info` file, as described [[here|https://tiddlywiki.com/#Installing%20a%20plugin%20from%20the%20plugin%20library]].
|
|
@ -0,0 +1,4 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiclient/icon/cloud
|
||||
tags: $:/tags/Image
|
||||
|
||||
<svg class="tc-image-cloud tc-image-button" width="22pt" height="22pt" viewBox="0 0 128 128"><g><path d="M24 103C10.745 103 0 92.255 0 79c0-9.697 5.75-18.05 14.027-21.836A24.787 24.787 0 0114 56c0-13.255 10.745-24 24-24 1.373 0 2.718.115 4.028.337C48.628 24.2 58.707 19 70 19c19.882 0 36 16.118 36 36v.082c12.319 1.016 22 11.336 22 23.918 0 12.239-9.16 22.337-20.999 23.814L107 103H24z"/><path class="tc-image-cloud-idle" d="M57.929 84.698a6 6 0 01-8.485 0L35.302 70.556a6 6 0 118.485-8.485l9.9 9.9L81.97 43.686a6 6 0 018.485 8.486L57.929 84.698z"/><path class="tc-image-cloud-progress tc-animate-rotate-slow" d="M44.8 40a3.6 3.6 0 100 7.2h2.06A23.922 23.922 0 0040 64c0 13.122 10.531 23.785 23.603 23.997L64 88l.001-7.2c-9.171 0-16.626-7.348-16.798-16.477L47.2 64c0-5.165 2.331-9.786 5.999-12.868L53.2 55.6a3.6 3.6 0 107.2 0v-12a3.6 3.6 0 00-3.6-3.6h-12zM64 40v7.2c9.278 0 16.8 7.522 16.8 16.8 0 5.166-2.332 9.787-6 12.869V72.4a3.6 3.6 0 10-7.2 0v12a3.6 3.6 0 003.6 3.6h12a3.6 3.6 0 100-7.2l-2.062.001A23.922 23.922 0 0088 64c0-13.255-10.745-24-24-24z"/></g></svg>
|
|
@ -0,0 +1,8 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiclient/info-segment
|
||||
tags: $:/tags/TiddlerInfoSegment
|
||||
|
||||
<$reveal type="nomatch" state=<<folded-state>> text="hide" tag="div" retain="yes" animate="yes">
|
||||
<div class="tc-subtitle">
|
||||
Bag: <$view tiddler="$:/state/multiwikiclient/tiddlers/bag" index=<<currentTiddler>>>(none)</$view>
|
||||
</div>
|
||||
</$reveal>
|
|
@ -0,0 +1,341 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiclient/multiwikiclientadaptor.js
|
||||
type: application/javascript
|
||||
module-type: syncadaptor
|
||||
|
||||
A sync adaptor module for synchronising with MultiWikiServer-compatible servers
|
||||
|
||||
\*/
|
||||
(function(){
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
var CONFIG_HOST_TIDDLER = "$:/config/multiwikiclient/host",
|
||||
DEFAULT_HOST_TIDDLER = "$protocol$//$host$/",
|
||||
MWC_STATE_TIDDLER_PREFIX = "$:/state/multiwikiclient/",
|
||||
BAG_STATE_TIDDLER = "$:/state/multiwikiclient/tiddlers/bag",
|
||||
REVISION_STATE_TIDDLER = "$:/state/multiwikiclient/tiddlers/revision",
|
||||
CONNECTION_STATE_TIDDLER = "$:/state/multiwikiclient/connection",
|
||||
INCOMING_UPDATES_FILTER_TIDDLER = "$:/config/multiwikiclient/incoming-updates-filter";
|
||||
|
||||
var SERVER_NOT_CONNECTED = "NOT CONNECTED",
|
||||
SERVER_CONNECTING_SSE = "CONNECTING SSE",
|
||||
SERVER_CONNECTED_SSE = "CONNECTED SSE",
|
||||
SERVER_POLLING = "SERVER POLLING";
|
||||
|
||||
function MultiWikiClientAdaptor(options) {
|
||||
this.wiki = options.wiki;
|
||||
this.host = this.getHost();
|
||||
this.recipe = this.wiki.getTiddlerText("$:/config/multiwikiclient/recipe");
|
||||
this.last_known_tiddler_id = $tw.utils.parseNumber(this.wiki.getTiddlerText("$:/state/multiwikiclient/recipe/last_tiddler_id","0"));
|
||||
this.logger = new $tw.utils.Logger("MultiWikiClientAdaptor");
|
||||
this.isLoggedIn = false;
|
||||
this.isReadOnly = false;
|
||||
this.logoutIsAvailable = true;
|
||||
// Compile the dirty tiddler filter
|
||||
this.incomingUpdatesFilterFn = this.wiki.compileFilter(this.wiki.getTiddlerText(INCOMING_UPDATES_FILTER_TIDDLER));
|
||||
this.setUpdateConnectionStatus(SERVER_NOT_CONNECTED);
|
||||
}
|
||||
|
||||
MultiWikiClientAdaptor.prototype.setUpdateConnectionStatus = function(status) {
|
||||
this.serverUpdateConnectionStatus = status;
|
||||
this.wiki.addTiddler({
|
||||
title: CONNECTION_STATE_TIDDLER,
|
||||
text: status
|
||||
});
|
||||
};
|
||||
|
||||
MultiWikiClientAdaptor.prototype.name = "multiwikiclient";
|
||||
|
||||
MultiWikiClientAdaptor.prototype.supportsLazyLoading = true;
|
||||
|
||||
MultiWikiClientAdaptor.prototype.setLoggerSaveBuffer = function(loggerForSaving) {
|
||||
this.logger.setSaveBuffer(loggerForSaving);
|
||||
};
|
||||
|
||||
MultiWikiClientAdaptor.prototype.isReady = function() {
|
||||
return true;
|
||||
};
|
||||
|
||||
MultiWikiClientAdaptor.prototype.getHost = function() {
|
||||
var text = this.wiki.getTiddlerText(CONFIG_HOST_TIDDLER,DEFAULT_HOST_TIDDLER),
|
||||
substitutions = [
|
||||
{name: "protocol", value: document.location.protocol},
|
||||
{name: "host", value: document.location.host},
|
||||
{name: "pathname", value: document.location.pathname}
|
||||
];
|
||||
for(var t=0; t<substitutions.length; t++) {
|
||||
var s = substitutions[t];
|
||||
text = $tw.utils.replaceString(text,new RegExp("\\$" + s.name + "\\$","mg"),s.value);
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
MultiWikiClientAdaptor.prototype.getTiddlerInfo = function(tiddler) {
|
||||
var title = tiddler.fields.title,
|
||||
revision = this.wiki.extractTiddlerDataItem(REVISION_STATE_TIDDLER,title),
|
||||
bag = this.wiki.extractTiddlerDataItem(BAG_STATE_TIDDLER,title);
|
||||
if(revision && bag) {
|
||||
return {
|
||||
title: title,
|
||||
revision: revision,
|
||||
bag: bag
|
||||
};
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
MultiWikiClientAdaptor.prototype.getTiddlerBag = function(title) {
|
||||
return this.wiki.extractTiddlerDataItem(BAG_STATE_TIDDLER,title);
|
||||
};
|
||||
|
||||
MultiWikiClientAdaptor.prototype.getTiddlerRevision = function(title) {
|
||||
return this.wiki.extractTiddlerDataItem(REVISION_STATE_TIDDLER,title);
|
||||
};
|
||||
|
||||
MultiWikiClientAdaptor.prototype.setTiddlerInfo = function(title,revision,bag) {
|
||||
this.wiki.setText(BAG_STATE_TIDDLER,null,title,bag,{suppressTimestamp: true});
|
||||
this.wiki.setText(REVISION_STATE_TIDDLER,null,title,revision,{suppressTimestamp: true});
|
||||
};
|
||||
|
||||
MultiWikiClientAdaptor.prototype.removeTiddlerInfo = function(title) {
|
||||
this.wiki.setText(BAG_STATE_TIDDLER,null,title,undefined,{suppressTimestamp: true});
|
||||
this.wiki.setText(REVISION_STATE_TIDDLER,null,title,undefined,{suppressTimestamp: true});
|
||||
};
|
||||
|
||||
/*
|
||||
Get the current status of the server connection
|
||||
*/
|
||||
MultiWikiClientAdaptor.prototype.getStatus = function(callback) {
|
||||
// Invoke the callback if present
|
||||
if(callback) {
|
||||
callback(
|
||||
null, // Error
|
||||
true, // Is logged in
|
||||
this.username, // Username
|
||||
false, // Is read only
|
||||
true // Is anonymous
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Get details of changed tiddlers from the server
|
||||
*/
|
||||
MultiWikiClientAdaptor.prototype.getUpdatedTiddlers = function(syncer,callback) {
|
||||
var self = this;
|
||||
// Do nothing if there's already a connection in progress.
|
||||
if(this.serverUpdateConnectionStatus !== SERVER_NOT_CONNECTED) {
|
||||
return callback(null,{
|
||||
modifications: [],
|
||||
deletions: []
|
||||
});
|
||||
}
|
||||
// Try to connect a server stream
|
||||
this.setUpdateConnectionStatus(SERVER_CONNECTING_SSE);
|
||||
this.connectServerStream({
|
||||
syncer: syncer,
|
||||
onerror: function(err) {
|
||||
self.logger.log("Error connecting SSE stream",err);
|
||||
// If the stream didn't work, try polling
|
||||
self.setUpdateConnectionStatus(SERVER_POLLING);
|
||||
self.pollServer({
|
||||
callback: function(err,changes) {
|
||||
self.setUpdateConnectionStatus(SERVER_NOT_CONNECTED);
|
||||
callback(null,changes);
|
||||
}
|
||||
});
|
||||
},
|
||||
onopen: function() {
|
||||
self.setUpdateConnectionStatus(SERVER_CONNECTED_SSE);
|
||||
// The syncer is expecting a callback but we don't have any data to send
|
||||
callback(null,{
|
||||
modifications: [],
|
||||
deletions: []
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
Attempt to establish an SSE stream with the server and transfer tiddler changes. Options include:
|
||||
|
||||
syncer: reference to syncer object used for storing data
|
||||
onopen: invoked when the stream is successfully opened
|
||||
onerror: invoked if there is an error
|
||||
*/
|
||||
MultiWikiClientAdaptor.prototype.connectServerStream = function(options) {
|
||||
var self = this;
|
||||
const eventSource = new EventSource("/recipes/" + this.recipe + "/events?last_known_tiddler_id=" + this.last_known_tiddler_id);
|
||||
eventSource.onerror = function(event) {
|
||||
if(options.onerror) {
|
||||
options.onerror(event);
|
||||
}
|
||||
}
|
||||
eventSource.onopen = function(event) {
|
||||
if(options.onopen) {
|
||||
options.onopen(event);
|
||||
}
|
||||
}
|
||||
eventSource.addEventListener("change", function(event) {
|
||||
const data = $tw.utils.parseJSONSafe(event.data);
|
||||
if(data) {
|
||||
console.log("SSE data",data)
|
||||
if(data.tiddler_id > self.last_known_tiddler_id) {
|
||||
self.last_known_tiddler_id = data.tiddler_id;
|
||||
}
|
||||
if(data.is_deleted) {
|
||||
self.removeTiddlerInfo(data.title);
|
||||
delete options.syncer.tiddlerInfo[data.title];
|
||||
options.syncer.logger.log("Deleting tiddler missing from server:",data.title);
|
||||
options.syncer.wiki.deleteTiddler(data.title);
|
||||
options.syncer.processTaskQueue();
|
||||
} else {
|
||||
var result = self.incomingUpdatesFilterFn.call(self.wiki,self.wiki.makeTiddlerIterator([data.title]));
|
||||
if(result.length > 0) {
|
||||
self.setTiddlerInfo(data.title,data.tiddler_id,data.bag_name);
|
||||
options.syncer.storeTiddler(data.tiddler);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
Poll the server for changes. Options include:
|
||||
|
||||
callback: invoked on completion as (err,changes)
|
||||
*/
|
||||
MultiWikiClientAdaptor.prototype.pollServer = function(options) {
|
||||
var self = this;
|
||||
$tw.utils.httpRequest({
|
||||
url: this.host + "recipes/" + this.recipe + "/tiddlers.json",
|
||||
data: {
|
||||
last_known_tiddler_id: this.last_known_tiddler_id,
|
||||
include_deleted: "true"
|
||||
},
|
||||
callback: function(err,data) {
|
||||
// Check for errors
|
||||
if(err) {
|
||||
return options.callback(err);
|
||||
}
|
||||
var modifications = [],
|
||||
deletions = [];
|
||||
var tiddlerInfoArray = $tw.utils.parseJSONSafe(data);
|
||||
$tw.utils.each(tiddlerInfoArray,function(tiddlerInfo) {
|
||||
if(tiddlerInfo.tiddler_id > self.last_known_tiddler_id) {
|
||||
self.last_known_tiddler_id = tiddlerInfo.tiddler_id;
|
||||
}
|
||||
if(tiddlerInfo.is_deleted) {
|
||||
deletions.push(tiddlerInfo.title);
|
||||
} else {
|
||||
modifications.push(tiddlerInfo.title);
|
||||
}
|
||||
});
|
||||
// Invoke the callback with the results
|
||||
options.callback(null,{
|
||||
modifications: modifications,
|
||||
deletions: deletions
|
||||
});
|
||||
// If Browswer Storage tiddlers were cached on reloading the wiki, add them after sync from server completes in the above callback.
|
||||
if($tw.browserStorage && $tw.browserStorage.isEnabled()) {
|
||||
$tw.browserStorage.addCachedTiddlers();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
Save a tiddler and invoke the callback with (err,adaptorInfo,revision)
|
||||
*/
|
||||
MultiWikiClientAdaptor.prototype.saveTiddler = function(tiddler,callback,options) {
|
||||
var self = this;
|
||||
if(this.isReadOnly || tiddler.fields.title.substr(0,MWC_STATE_TIDDLER_PREFIX.length) === MWC_STATE_TIDDLER_PREFIX) {
|
||||
return callback(null);
|
||||
}
|
||||
$tw.utils.httpRequest({
|
||||
url: this.host + "recipes/" + encodeURIComponent(this.recipe) + "/tiddlers/" + encodeURIComponent(tiddler.fields.title),
|
||||
type: "PUT",
|
||||
headers: {
|
||||
"Content-type": "application/json"
|
||||
},
|
||||
data: JSON.stringify(tiddler.getFieldStrings()),
|
||||
callback: function(err,data,request) {
|
||||
if(err) {
|
||||
return callback(err);
|
||||
}
|
||||
//If Browser-Storage plugin is present, remove tiddler from local storage after successful sync to the server
|
||||
if($tw.browserStorage && $tw.browserStorage.isEnabled()) {
|
||||
$tw.browserStorage.removeTiddlerFromLocalStorage(tiddler.fields.title)
|
||||
}
|
||||
// Save the details of the new revision of the tiddler
|
||||
var revision = request.getResponseHeader("X-Revision-Number"),
|
||||
bag_name = request.getResponseHeader("X-Bag-Name");
|
||||
console.log(`Saved ${tiddler.fields.title} with revision ${revision} and bag ${bag_name}`)
|
||||
// Invoke the callback
|
||||
self.setTiddlerInfo(tiddler.fields.title,revision,bag_name);
|
||||
callback(null,{bag: bag_name},revision);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
Load a tiddler and invoke the callback with (err,tiddlerFields)
|
||||
*/
|
||||
MultiWikiClientAdaptor.prototype.loadTiddler = function(title,callback) {
|
||||
var self = this;
|
||||
$tw.utils.httpRequest({
|
||||
url: this.host + "recipes/" + encodeURIComponent(this.recipe) + "/tiddlers/" + encodeURIComponent(title),
|
||||
callback: function(err,data,request) {
|
||||
if(err === 404) {
|
||||
return callback(null,null);
|
||||
} else if(err) {
|
||||
return callback(err);
|
||||
}
|
||||
// Invoke the callback
|
||||
var revision = request.getResponseHeader("X-Revision-Number"),
|
||||
bag_name = request.getResponseHeader("X-Bag-Name");
|
||||
self.setTiddlerInfo(title,revision,bag_name);
|
||||
callback(null,$tw.utils.parseJSONSafe(data));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
Delete a tiddler and invoke the callback with (err)
|
||||
options include:
|
||||
tiddlerInfo: the syncer's tiddlerInfo for this tiddler
|
||||
*/
|
||||
MultiWikiClientAdaptor.prototype.deleteTiddler = function(title,callback,options) {
|
||||
var self = this;
|
||||
if(this.isReadOnly) {
|
||||
return callback(null);
|
||||
}
|
||||
// If we don't have a bag it means that the tiddler hasn't been seen by the server, so we don't need to delete it
|
||||
var bag = this.getTiddlerBag(title);
|
||||
if(!bag) {
|
||||
return callback(null,options.tiddlerInfo.adaptorInfo);
|
||||
}
|
||||
// Issue HTTP request to delete the tiddler
|
||||
$tw.utils.httpRequest({
|
||||
url: this.host + "bags/" + encodeURIComponent(bag) + "/tiddlers/" + encodeURIComponent(title),
|
||||
type: "DELETE",
|
||||
callback: function(err,data,request) {
|
||||
if(err) {
|
||||
return callback(err);
|
||||
}
|
||||
self.removeTiddlerInfo(title);
|
||||
// Invoke the callback & return null adaptorInfo
|
||||
callback(null,null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if($tw.browser && document.location.protocol.substr(0,4) === "http" ) {
|
||||
exports.adaptorClass = MultiWikiClientAdaptor;
|
||||
}
|
||||
|
||||
})();
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"title": "$:/plugins/tiddlywiki/multiwikiclient",
|
||||
"name": "MultiWikiClient",
|
||||
"description": "Synchronise changes from the browser to TiddlyWiki ~MultiWikiServer",
|
||||
"list": "readme",
|
||||
"plugin-priority": 10
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiclient/readme
|
||||
|
||||
This plugin runs in the browser to synchronise tiddler changes to and from a TiddlyWiki server running ~MultiWikiServer.
|
||||
|
||||
|
||||
This plugin is inert when run under Node.js. Disabling this plugin via the browser can not be undone via the browser since this plugin provides the mechanism to synchronize settings with the server.
|
||||
|
||||
Changes made while offline are saved in memory and automatically synchonised with the server when the connection is re-established. However, if the browser tab is closed or another URL is loaded, the in-memory changes will be lost. The [[https://tiddlywiki.com/#BrowserStorage Plugin]] may be added to provide temporary filesystem storage of tiddler changes made while offline and enable them to be synchronised with the server the next time the wiki is loaded in the same browser.
|
|
@ -0,0 +1,27 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiclient/readonly
|
||||
tags: [[$:/tags/Stylesheet]]
|
||||
|
||||
\define button-selector(title)
|
||||
button.$title$, .tc-drop-down button.$title$, div.$title$
|
||||
\end
|
||||
|
||||
\define hide-edit-controls()
|
||||
<$reveal state="$:/status/IsReadOnly" type="match" text="yes" default="yes">
|
||||
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fclone>>`,`
|
||||
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fdelete>>`,`
|
||||
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fedit>>`,`
|
||||
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-here>>`,`
|
||||
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-journal-here>>`,`
|
||||
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fimport>>`,`
|
||||
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fmanager>>`,`
|
||||
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-image>>`,`
|
||||
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-journal>>`,`
|
||||
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-tiddler>> `{
|
||||
display: none;
|
||||
}`
|
||||
</$reveal>
|
||||
\end
|
||||
|
||||
\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline macrocallblock
|
||||
|
||||
<<hide-edit-controls>>
|
|
@ -0,0 +1,7 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiclient/save/offline
|
||||
|
||||
\import [subfilter{$:/core/config/GlobalImportFilter}]
|
||||
\define saveTiddlerFilter()
|
||||
[is[tiddler]] -[[$:/boot/boot.css]] -[prefix[$:/HistoryList]] -[status[pending]plugin-type[import]] -[type[application/javascript]library[yes]] -[[$:/boot/boot.js]] -[[$:/boot/bootprefix.js]] -[[$:/plugins/tiddlywiki/filesystem]] -[[$:/plugins/tiddlywiki/multiwikiclient]] -[prefix[$:/temp/]] +[sort[title]] $(publishFilter)$
|
||||
\end
|
||||
{{$:/core/templates/tiddlywiki5.html}}
|
|
@ -0,0 +1,26 @@
|
|||
title: $:/core/ui/Buttons/save-wiki
|
||||
tags: $:/tags/PageControls
|
||||
caption: {{$:/plugins/tiddlywiki/multiwikiclient/icon/cloud}} Server status
|
||||
description: Status of synchronisation with server
|
||||
|
||||
\whitespace trim
|
||||
\define config-title()
|
||||
$:/config/PageControlButtons/Visibility/$(listItem)$
|
||||
\end
|
||||
<$button popup=<<qualify "$:/state/popup/save-wiki">> tooltip="Status of synchronisation with server" aria-label="Server status" class=<<tv-config-toolbar-class>> selectedClass="tc-selected">
|
||||
<span class="tc-dirty-indicator">
|
||||
<$list filter="[<tv-config-toolbar-icons>match[yes]]">
|
||||
{{$:/plugins/tiddlywiki/multiwikiclient/icon/cloud}}
|
||||
</$list>
|
||||
<$list filter="[<tv-config-toolbar-text>match[yes]]">
|
||||
<span class="tc-btn-text"><$text text="Server status"/></span>
|
||||
</$list>
|
||||
</span>
|
||||
</$button>
|
||||
<$reveal state=<<qualify "$:/state/popup/save-wiki">> type="popup" position="belowleft" animate="yes">
|
||||
<div class="tc-drop-down">
|
||||
<$list filter="[all[shadows+tiddlers]tag[$:/tags/SyncerDropdown]!has[draft.of]]" variable="listItem">
|
||||
<$transclude tiddler=<<listItem>>/>
|
||||
</$list>
|
||||
</div>
|
||||
</$reveal>
|
|
@ -0,0 +1,5 @@
|
|||
title: $:/plugins/multiwikiclient/SideBarSegment
|
||||
tags: $:/tags/SideBarSegment
|
||||
list-before: $:/core/ui/SideBarSegments/page-controls
|
||||
|
||||
MWS Connection Status: {{$:/state/multiwikiclient/connection}}
|
|
@ -0,0 +1,44 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiclient/styles
|
||||
tags: [[$:/tags/Stylesheet]]
|
||||
|
||||
\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline macrocallblock
|
||||
|
||||
body.tc-dirty span.tc-dirty-indicator svg {
|
||||
transition: fill 250ms ease-in-out;
|
||||
}
|
||||
|
||||
body .tc-image-cloud-idle {
|
||||
fill: <<colour background>>;
|
||||
transition: opacity 250ms ease-in-out;
|
||||
opacity: 1;
|
||||
display: unset;
|
||||
}
|
||||
|
||||
body.tc-dirty .tc-image-cloud-idle {
|
||||
opacity: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
body .tc-image-cloud-progress {
|
||||
transition: opacity 250ms ease-in-out;
|
||||
transform-origin: 50% 50%;
|
||||
transform: rotate(359deg);
|
||||
animation: animation-rotate-slow 2s infinite linear;
|
||||
fill: <<colour background>>;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
body.tc-dirty .tc-image-cloud-progress {
|
||||
opacity: 1;
|
||||
display: unset;
|
||||
}
|
||||
|
||||
@keyframes animation-rotate-slow {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: scale(359deg);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/copy-logs
|
||||
tags: $:/tags/SyncerDropdown
|
||||
|
||||
<$button message="tm-copy-syncer-logs-to-clipboard" class="tc-btn-invisible">
|
||||
{{$:/core/images/copy-clipboard}} Copy syncer logs to clipboard
|
||||
</$button>
|
|
@ -0,0 +1,9 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login-status
|
||||
tags: $:/tags/SyncerDropdown
|
||||
|
||||
<$reveal state="$:/status/IsLoggedIn" type="match" text="yes">
|
||||
<div class="tc-drop-down-info">
|
||||
You are logged in<$reveal state="$:/status/UserName" type="nomatch" text="" default=""> as <strong><$text text={{$:/status/UserName}}/></strong></$reveal><$reveal state="$:/status/IsReadOnly" type="match" text="yes" default="no"> (read-only)</$reveal>
|
||||
</div>
|
||||
<hr/>
|
||||
</$reveal>
|
|
@ -0,0 +1,8 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login
|
||||
tags: $:/tags/SyncerDropdown
|
||||
|
||||
<$reveal state="$:/status/IsLoggedIn" type="nomatch" text="yes">
|
||||
<$button message="tm-login" class="tc-btn-invisible">
|
||||
{{$:/core/images/unlocked-padlock}} Login
|
||||
</$button>
|
||||
</$reveal>
|
|
@ -0,0 +1,8 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/logout
|
||||
tags: $:/tags/SyncerDropdown
|
||||
|
||||
<$reveal state="$:/status/IsLoggedIn" type="match" text="yes">
|
||||
<$button message="tm-logout" class="tc-btn-invisible">
|
||||
{{$:/core/images/cancel-button}} Logout
|
||||
</$button>
|
||||
</$reveal>
|
|
@ -0,0 +1,9 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/refresh
|
||||
tags: $:/tags/SyncerDropdown
|
||||
|
||||
<$reveal state="$:/status/IsLoggedIn" type="match" text="yes">
|
||||
<$button tooltip="Get latest changes from the server" aria-label="Refresh from server" class="tc-btn-invisible">
|
||||
<$action-sendmessage $message="tm-server-refresh"/>
|
||||
{{$:/core/images/refresh-button}}<span class="tc-btn-text"><$text text="Get latest changes from the server"/></span>
|
||||
</$button>
|
||||
</$reveal>
|
|
@ -0,0 +1,9 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/save-snapshot
|
||||
tags: $:/tags/SyncerDropdown
|
||||
|
||||
<$button class="tc-btn-invisible">
|
||||
<$wikify name="site-title" text={{$:/config/SaveWikiButton/Filename}}>
|
||||
<$action-sendmessage $message="tm-download-file" $param={{$:/config/SaveWikiButton/Template}} filename=<<site-title>>/>
|
||||
</$wikify>
|
||||
{{$:/core/images/download-button}} Save snapshot for offline use
|
||||
</$button>
|
|
@ -0,0 +1,2 @@
|
|||
title: $:/tags/SyncerDropdown
|
||||
list: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login-status $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/refresh $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/logout $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/save-snapshot $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/copy-logs
|
|
@ -0,0 +1,2 @@
|
|||
title: $:/config/MultiWikiServer/AttachmentSizeLimit
|
||||
text: 204800
|
|
@ -0,0 +1,14 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiserver/docs
|
||||
|
||||
! HTTP API
|
||||
|
||||
The ~MultiWikiServer HTTP API provides access to resources hosted by the MWS store. It is based on [[the API of TiddlyWeb|https://tank.peermore.com/tanks/tiddlyweb/HTTP%20API]], first developed in 2008 by Chris Dent.
|
||||
|
||||
The design goals of the API are:
|
||||
|
||||
* To follow the principles of REST where practical
|
||||
* To present resources as nouns, not verbs
|
||||
|
||||
General points about the design:
|
||||
|
||||
* In MWS there are no resources that end with / (except for the root path which is /)
|
|
@ -0,0 +1,39 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiserver/readme
|
||||
|
||||
This plugin extends the TiddlyWiki 5 server running on Node.js to be able to host multiple wikis that can share content or be independent.
|
||||
|
||||
Installation
|
||||
|
||||
```
|
||||
git clone https://github.com/Jermolene/TiddlyWiki5.git --branch multi-wiki-support
|
||||
|
||||
cd TiddlyWiki5
|
||||
|
||||
npm install
|
||||
```
|
||||
|
||||
To start the server:
|
||||
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
The `npm start` command is a shortcut for the following command:
|
||||
|
||||
```
|
||||
node ./tiddlywiki.js ./editions/multiwikiserver --mws-listen
|
||||
```
|
||||
|
||||
Then visit the administration interface in a browser:
|
||||
|
||||
* http://127.0.0.1:8080/
|
||||
|
||||
Note that changes are written to the topmost bag in a recipe.
|
||||
|
||||
Note that until syncing is improved it is necessary to use "Get latest changes from the server" to speed up propogation of changes.
|
||||
|
||||
To run the tests:
|
||||
|
||||
```
|
||||
./bin/test.sh
|
||||
```
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,49 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-listen.js
|
||||
type: application/javascript
|
||||
module-type: command
|
||||
|
||||
Listen for HTTP requests and serve tiddlers
|
||||
|
||||
\*/
|
||||
(function(){
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.info = {
|
||||
name: "mws-listen",
|
||||
synchronous: false,
|
||||
namedParameterMode: true,
|
||||
mandatoryParameters: []
|
||||
};
|
||||
|
||||
var Command = function(params,commander,callback) {
|
||||
var self = this;
|
||||
this.params = params;
|
||||
this.commander = commander;
|
||||
this.callback = callback;
|
||||
};
|
||||
|
||||
Command.prototype.execute = function() {
|
||||
var self = this;
|
||||
if(!$tw.boot.wikiTiddlersPath) {
|
||||
$tw.utils.warning("Warning: Wiki folder '" + $tw.boot.wikiPath + "' does not exist or is missing a tiddlywiki.info file");
|
||||
}
|
||||
// Set up server
|
||||
this.server = $tw.mws.serverManager.createServer({
|
||||
wiki: $tw.wiki,
|
||||
variables: self.params
|
||||
});
|
||||
this.server.listen(null,null,null,{
|
||||
callback: function() {
|
||||
self.callback();
|
||||
}
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
exports.Command = Command;
|
||||
|
||||
})();
|
|
@ -0,0 +1,93 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-load-archive.js
|
||||
type: application/javascript
|
||||
module-type: command
|
||||
|
||||
Command to load archive of recipes, bags and tiddlers from a directory
|
||||
|
||||
\*/
|
||||
(function(){
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.info = {
|
||||
name: "mws-load-archive",
|
||||
synchronous: true
|
||||
};
|
||||
|
||||
var Command = function(params,commander,callback) {
|
||||
this.params = params;
|
||||
this.commander = commander;
|
||||
this.callback = callback;
|
||||
};
|
||||
|
||||
Command.prototype.execute = function() {
|
||||
var self = this;
|
||||
// Check parameters
|
||||
if(this.params.length < 1) {
|
||||
return "Missing pathname";
|
||||
}
|
||||
var archivePath = this.params[0];
|
||||
loadBackupArchive(archivePath);
|
||||
return null;
|
||||
};
|
||||
|
||||
function loadBackupArchive(archivePath) {
|
||||
const fs = require("fs"),
|
||||
path = require("path");
|
||||
// Iterate the bags
|
||||
const bagNames = fs.readdirSync(path.resolve(archivePath,"bags")).filter(filename => filename !== ".DS_Store");
|
||||
for(const bagFilename of bagNames) {
|
||||
const bagName = decodeURIComponent(bagFilename);
|
||||
console.log(`Reading bag ${bagName}`);
|
||||
const bagInfo = JSON.parse(fs.readFileSync(path.resolve(archivePath,"bags",bagFilename,"meta.json"),"utf8"));
|
||||
$tw.mws.store.createBag(bagName,bagInfo.description,bagInfo.accesscontrol);
|
||||
if(fs.existsSync(path.resolve(archivePath,"bags",bagFilename,"tiddlers"))) {
|
||||
const tiddlerFilenames = fs.readdirSync(path.resolve(archivePath,"bags",bagFilename,"tiddlers"));
|
||||
for(const tiddlerFilename of tiddlerFilenames) {
|
||||
if(tiddlerFilename.endsWith(".json")) {
|
||||
const tiddlerPath = path.resolve(archivePath,"bags",bagFilename,"tiddlers",tiddlerFilename),
|
||||
jsonTiddler = fs.readFileSync(tiddlerPath,"utf8"),
|
||||
tiddler = sanitiseTiddler(JSON.parse(jsonTiddler));
|
||||
if(tiddler && tiddler.title) {
|
||||
$tw.mws.store.saveBagTiddler(tiddler,bagName);
|
||||
} else {
|
||||
console.log(`Malformed JSON tiddler in file ${tiddlerPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Iterate the recipes
|
||||
const recipeNames = fs.readdirSync(path.resolve(archivePath,"recipes"));
|
||||
for(const recipeFilename of recipeNames) {
|
||||
if(recipeFilename.endsWith(".json")) {
|
||||
const recipeName = decodeURIComponent(recipeFilename.substring(0,recipeFilename.length - ".json".length));
|
||||
const jsonInfo = JSON.parse(fs.readFileSync(path.resolve(archivePath,"recipes",recipeFilename),"utf8"));
|
||||
$tw.mws.store.createRecipe(recipeName,jsonInfo.bag_names,jsonInfo.description,jsonInfo.accesscontrol);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function sanitiseTiddler(tiddler) {
|
||||
var sanitisedFields = Object.create(null);
|
||||
for(const fieldName in tiddler) {
|
||||
const fieldValue = tiddler[fieldName];
|
||||
let sanitisedValue = "";
|
||||
if(typeof fieldValue === "string") {
|
||||
sanitisedValue = fieldValue;
|
||||
} else if($tw.utils.isDate(fieldValue)) {
|
||||
sanitisedValue = $tw.utils.stringifyDate(fieldValue);
|
||||
} else if($tw.utils.isArray(fieldValue)) {
|
||||
sanitisedValue = $tw.utils.stringifyList(fieldValue);
|
||||
}
|
||||
sanitisedFields[fieldName] = sanitisedValue;
|
||||
}
|
||||
return sanitisedFields;
|
||||
}
|
||||
|
||||
exports.Command = Command;
|
||||
|
||||
})();
|
|
@ -0,0 +1,40 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-load-tiddlers.js
|
||||
type: application/javascript
|
||||
module-type: command
|
||||
|
||||
Command to recursively load a directory of tiddler files into a bag
|
||||
|
||||
\*/
|
||||
(function(){
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.info = {
|
||||
name: "mws-load-tiddlers",
|
||||
synchronous: true
|
||||
};
|
||||
|
||||
var Command = function(params,commander,callback) {
|
||||
this.params = params;
|
||||
this.commander = commander;
|
||||
this.callback = callback;
|
||||
};
|
||||
|
||||
Command.prototype.execute = function() {
|
||||
var self = this;
|
||||
// Check parameters
|
||||
if(this.params.length < 2) {
|
||||
return "Missing pathname and/or bag name";
|
||||
}
|
||||
var tiddlersPath = this.params[0],
|
||||
bagName = this.params[1];
|
||||
$tw.mws.store.saveTiddlersFromPath(tiddlersPath,bagName);
|
||||
return null;
|
||||
};
|
||||
|
||||
exports.Command = Command;
|
||||
|
||||
})();
|
|
@ -0,0 +1,62 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-save-archive.js
|
||||
type: application/javascript
|
||||
module-type: command
|
||||
|
||||
Command to load an archive of recipes, bags and tiddlers to a directory
|
||||
|
||||
\*/
|
||||
(function(){
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.info = {
|
||||
name: "mws-save-archive",
|
||||
synchronous: true
|
||||
};
|
||||
|
||||
var Command = function(params,commander,callback) {
|
||||
this.params = params;
|
||||
this.commander = commander;
|
||||
this.callback = callback;
|
||||
};
|
||||
|
||||
Command.prototype.execute = function() {
|
||||
var self = this;
|
||||
// Check parameters
|
||||
if(this.params.length < 1) {
|
||||
return "Missing pathname";
|
||||
}
|
||||
var archivePath = this.params[0];
|
||||
saveArchive(archivePath);
|
||||
return null;
|
||||
};
|
||||
|
||||
function saveArchive(archivePath) {
|
||||
const fs = require("fs"),
|
||||
path = require("path");
|
||||
function saveJsonFile(filename,json) {
|
||||
const filepath = path.resolve(archivePath,filename);
|
||||
console.log(filepath);
|
||||
$tw.utils.createFileDirectories(filepath);
|
||||
fs.writeFileSync(filepath,JSON.stringify(json,null,4));
|
||||
}
|
||||
for(const recipeInfo of $tw.mws.store.listRecipes()) {
|
||||
console.log(`Recipe ${recipeInfo.recipe_name}`);
|
||||
saveJsonFile(`recipes/${encodeURIComponent(recipeInfo.recipe_name)}.json`,recipeInfo);
|
||||
}
|
||||
for(const bagInfo of $tw.mws.store.listBags()) {
|
||||
console.log(`Bag ${bagInfo.bag_name}`);
|
||||
saveJsonFile(`bags/${encodeURIComponent(bagInfo.bag_name)}/meta.json`,bagInfo);
|
||||
for(const title of $tw.mws.store.getBagTiddlers(bagInfo.bag_name)) {
|
||||
const tiddlerInfo = $tw.mws.store.getBagTiddler(title,bagInfo.bag_name);
|
||||
saveJsonFile(`bags/${encodeURIComponent(bagInfo.bag_name)}/tiddlers/${encodeURIComponent(title)}.json`,tiddlerInfo.tiddler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.Command = Command;
|
||||
|
||||
})();
|
|
@ -0,0 +1,158 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-test-server.js
|
||||
type: application/javascript
|
||||
module-type: command
|
||||
|
||||
Command to test a local or remote MWS server
|
||||
|
||||
tiddlywiki editions/multiwikiserver/ --listen --mws-test-server http://127.0.0.1:8080/
|
||||
|
||||
\*/
|
||||
(function(){
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.info = {
|
||||
name: "mws-test-server",
|
||||
synchronous: false
|
||||
};
|
||||
|
||||
var Command = function(params,commander,callback) {
|
||||
this.params = params;
|
||||
this.commander = commander;
|
||||
this.callback = callback;
|
||||
};
|
||||
|
||||
Command.prototype.execute = function() {
|
||||
var self = this;
|
||||
// Check parameters
|
||||
if(this.params.length < 1) {
|
||||
return "Missing URL";
|
||||
}
|
||||
// Create the test runner
|
||||
var urlServer = this.params[0];
|
||||
var testRunner = new TestRunner(urlServer);
|
||||
testRunner.runTests(function(failed) {
|
||||
self.callback(failed ? "MWS Server tests failed" : null);
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
function TestRunner(urlServer) {
|
||||
const URL = require("node:url").URL;
|
||||
this.urlServerParsed = new URL(urlServer);
|
||||
this.httpLibrary = require(this.urlServerParsed.protocol === "https:" ? "https" : "http");
|
||||
}
|
||||
|
||||
TestRunner.prototype.runTests = function(callback) {
|
||||
const self = this;
|
||||
let currentTestSpec = 0;
|
||||
let hasFailed = false;
|
||||
function runNextTest() {
|
||||
if(currentTestSpec < testSpecs.length) {
|
||||
const testSpec = testSpecs[currentTestSpec];
|
||||
currentTestSpec += 1;
|
||||
self.runTest(testSpec,function(err) {
|
||||
if(err) {
|
||||
hasFailed = true;
|
||||
console.log(`Failed "${testSpec.description}" with "${err}"`)
|
||||
}
|
||||
runNextTest();
|
||||
});
|
||||
} else {
|
||||
if(hasFailed) {
|
||||
console.log("MWS Server Tests failed");
|
||||
} else {
|
||||
console.log("MWS Server Tests succeeded");
|
||||
}
|
||||
callback(hasFailed);
|
||||
}
|
||||
}
|
||||
runNextTest();
|
||||
};
|
||||
|
||||
TestRunner.prototype.runTest = function(testSpec,callback) {
|
||||
const self = this;
|
||||
console.log(`Running Server Test: ${testSpec.description}`)
|
||||
if(testSpec.method === "GET" || testSpec.method === "POST") {
|
||||
const request = this.httpLibrary.request({
|
||||
protocol: this.urlServerParsed.protocol,
|
||||
host: this.urlServerParsed.hostname,
|
||||
port: this.urlServerParsed.port,
|
||||
path: testSpec.path,
|
||||
method: testSpec.method,
|
||||
headers: testSpec.headers
|
||||
}, function(response) {
|
||||
if (response.statusCode < 200 || response.statusCode >= 400) {
|
||||
return callback(`Request failed to ${self.urlServerParsed.toString()} with status code ${response.statusCode} and ${JSON.stringify(response.headers)}`);
|
||||
}
|
||||
response.setEncoding("utf8");
|
||||
let buffer = "";
|
||||
response.on("data", (chunk) => {
|
||||
buffer = buffer + chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
const jsonData = $tw.utils.parseJSONSafe(buffer,function() {return undefined;});
|
||||
const testResult = testSpec.expectedResult(jsonData,buffer,response.headers);
|
||||
callback(testResult ? null : "Test failed");
|
||||
});
|
||||
});
|
||||
request.on("error", (e) => {
|
||||
console.error(`problem with request: ${e.message}`);
|
||||
});
|
||||
if(testSpec.data) {
|
||||
request.write(testSpec.data);
|
||||
}
|
||||
request.end();
|
||||
} else {
|
||||
callback("Unknown method");
|
||||
}
|
||||
};
|
||||
|
||||
const testSpecs = [
|
||||
{
|
||||
description: "Check index page",
|
||||
method: "GET",
|
||||
path: "/",
|
||||
headers: {
|
||||
accept: "*/*"
|
||||
},
|
||||
expectedResult: (jsonData,data,headers) => {
|
||||
return JSON.stringify(data).slice(1,100) === "\\n<!doctype html>\\n<head>\\n\\t<meta http-equiv=\\\"Content-Type\\\" content=\\\"text/html;charset=utf-8\\\" ";
|
||||
}
|
||||
},
|
||||
{
|
||||
description: "Upload a 1px PNG",
|
||||
method: "POST",
|
||||
path: "/bags/bag-alpha/tiddlers/",
|
||||
headers: {
|
||||
"Accept": 'application/json',
|
||||
"Content-Type": 'multipart/form-data; boundary=----WebKitFormBoundaryVR9zv0PFmx9YtpLL',
|
||||
"User-Agent": 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
|
||||
},
|
||||
data: '------WebKitFormBoundaryVR9zv0PFmx9YtpLL\r\nContent-Disposition: form-data; name="file-to-upload"; filename="one-white-pixel.png"\r\nContent-Type: image/png\r\n\r\n\r\n------WebKitFormBoundaryVR9zv0PFmx9YtpLL\r\nContent-Disposition: form-data; name="tiddler-field-title"\r\n\r\nOne White Pixel\r\n------WebKitFormBoundaryVR9zv0PFmx9YtpLL\r\nContent-Disposition: form-data; name="tiddler-field-tags"\r\n\r\nimage\r\n------WebKitFormBoundaryVR9zv0PFmx9YtpLL--\r\n',
|
||||
expectedResult: (jsonData,data) => {
|
||||
return jsonData["imported-tiddlers"] && $tw.utils.isArray(jsonData["imported-tiddlers"]) && jsonData["imported-tiddlers"][0] === "One White Pixel";
|
||||
}
|
||||
},
|
||||
{
|
||||
description: "Create a recipe",
|
||||
method: "POST",
|
||||
path: "/recipes",
|
||||
headers: {
|
||||
"Accept": '*/*',
|
||||
"Content-Type": 'application/x-www-form-urlencoded',
|
||||
"User-Agent": 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
|
||||
},
|
||||
data: "recipe_name=Elephants3214234&bag_names=one%20two%20three&description=A%20bag%20of%20elephants",
|
||||
expectedResult: (jsonData,data,headers) => {
|
||||
return headers.location === "/";
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
exports.Command = Command;
|
||||
|
||||
})();
|
|
@ -0,0 +1,507 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/mws-server.js
|
||||
type: application/javascript
|
||||
module-type: library
|
||||
|
||||
Serve tiddlers over http
|
||||
|
||||
\*/
|
||||
(function(){
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
if($tw.node) {
|
||||
var util = require("util"),
|
||||
fs = require("fs"),
|
||||
url = require("url"),
|
||||
path = require("path"),
|
||||
querystring = require("querystring"),
|
||||
crypto = require("crypto"),
|
||||
zlib = require("zlib");
|
||||
}
|
||||
|
||||
/*
|
||||
A simple HTTP server with regexp-based routes
|
||||
options: variables - optional hashmap of variables to set (a misnomer - they are really constant parameters)
|
||||
routes - optional array of routes to use
|
||||
wiki - reference to wiki object
|
||||
*/
|
||||
function Server(options) {
|
||||
var self = this;
|
||||
this.routes = options.routes || [];
|
||||
this.authenticators = options.authenticators || [];
|
||||
this.wiki = options.wiki;
|
||||
this.boot = options.boot || $tw.boot;
|
||||
// Initialise the variables
|
||||
this.variables = $tw.utils.extend({},this.defaultVariables);
|
||||
if(options.variables) {
|
||||
for(var variable in options.variables) {
|
||||
if(options.variables[variable]) {
|
||||
this.variables[variable] = options.variables[variable];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Setup the default required plugins
|
||||
this.requiredPlugins = this.get("required-plugins").split(',');
|
||||
// Initialise CSRF
|
||||
this.csrfDisable = this.get("csrf-disable") === "yes";
|
||||
// Initialize Gzip compression
|
||||
this.enableGzip = this.get("gzip") === "yes";
|
||||
// Initialize browser-caching
|
||||
this.enableBrowserCache = this.get("use-browser-cache") === "yes";
|
||||
// Initialise authorization
|
||||
var authorizedUserName;
|
||||
if(this.get("username") && this.get("password")) {
|
||||
authorizedUserName = this.get("username");
|
||||
} else if(this.get("credentials")) {
|
||||
authorizedUserName = "(authenticated)";
|
||||
} else {
|
||||
authorizedUserName = "(anon)";
|
||||
}
|
||||
this.authorizationPrincipals = {
|
||||
readers: (this.get("readers") || authorizedUserName).split(",").map($tw.utils.trim),
|
||||
writers: (this.get("writers") || authorizedUserName).split(",").map($tw.utils.trim)
|
||||
}
|
||||
if(this.get("admin") || authorizedUserName !== "(anon)") {
|
||||
this.authorizationPrincipals["admin"] = (this.get("admin") || authorizedUserName).split(',').map($tw.utils.trim)
|
||||
}
|
||||
// Load and initialise authenticators
|
||||
$tw.modules.forEachModuleOfType("authenticator", function(title,authenticatorDefinition) {
|
||||
// console.log("Loading authenticator " + title);
|
||||
self.addAuthenticator(authenticatorDefinition.AuthenticatorClass);
|
||||
});
|
||||
// Load route handlers
|
||||
$tw.modules.forEachModuleOfType("mws-route", function(title,routeDefinition) {
|
||||
self.addRoute(routeDefinition);
|
||||
});
|
||||
// Initialise the http vs https
|
||||
this.listenOptions = null;
|
||||
this.protocol = "http";
|
||||
var tlsKeyFilepath = this.get("tls-key"),
|
||||
tlsCertFilepath = this.get("tls-cert"),
|
||||
tlsPassphrase = this.get("tls-passphrase");
|
||||
if(tlsCertFilepath && tlsKeyFilepath) {
|
||||
this.listenOptions = {
|
||||
key: fs.readFileSync(path.resolve(this.boot.wikiPath,tlsKeyFilepath),"utf8"),
|
||||
cert: fs.readFileSync(path.resolve(this.boot.wikiPath,tlsCertFilepath),"utf8"),
|
||||
passphrase: tlsPassphrase || ''
|
||||
};
|
||||
this.protocol = "https";
|
||||
}
|
||||
this.transport = require(this.protocol);
|
||||
// Name the server and init the boot state
|
||||
this.servername = $tw.utils.transliterateToSafeASCII(this.get("server-name") || this.wiki.getTiddlerText("$:/SiteTitle") || "TiddlyWiki5");
|
||||
this.boot.origin = this.get("origin")? this.get("origin"): this.protocol+"://"+this.get("host")+":"+this.get("port");
|
||||
this.boot.pathPrefix = this.get("path-prefix") || "";
|
||||
}
|
||||
|
||||
/*
|
||||
Send a response to the client. This method checks if the response must be sent
|
||||
or if the client alrady has the data cached. If that's the case only a 304
|
||||
response will be transmitted and the browser will use the cached data.
|
||||
Only requests with status code 200 are considdered for caching.
|
||||
request: request instance passed to the handler
|
||||
response: response instance passed to the handler
|
||||
statusCode: stauts code to send to the browser
|
||||
headers: response headers (they will be augmented with an `Etag` header)
|
||||
data: the data to send (passed to the end method of the response instance)
|
||||
encoding: the encoding of the data to send (passed to the end method of the response instance)
|
||||
*/
|
||||
function sendResponse(request,response,statusCode,headers,data,encoding) {
|
||||
if(this.enableBrowserCache && (statusCode == 200)) {
|
||||
var hash = crypto.createHash('md5');
|
||||
// Put everything into the hash that could change and invalidate the data that
|
||||
// the browser already stored. The headers the data and the encoding.
|
||||
hash.update(data);
|
||||
hash.update(JSON.stringify(headers));
|
||||
if(encoding) {
|
||||
hash.update(encoding);
|
||||
}
|
||||
var contentDigest = hash.digest("hex");
|
||||
// RFC 7232 section 2.3 mandates for the etag to be enclosed in quotes
|
||||
headers["Etag"] = '"' + contentDigest + '"';
|
||||
headers["Cache-Control"] = "max-age=0, must-revalidate";
|
||||
// Check if any of the hashes contained within the if-none-match header
|
||||
// matches the current hash.
|
||||
// If one matches, do not send the data but tell the browser to use the
|
||||
// cached data.
|
||||
// We do not implement "*" as it makes no sense here.
|
||||
var ifNoneMatch = request.headers["if-none-match"];
|
||||
if(ifNoneMatch) {
|
||||
var matchParts = ifNoneMatch.split(",").map(function(etag) {
|
||||
return etag.replace(/^[ "]+|[ "]+$/g, "");
|
||||
});
|
||||
if(matchParts.indexOf(contentDigest) != -1) {
|
||||
response.writeHead(304,headers);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
If the gzip=yes is set, check if the user agent permits compression. If so,
|
||||
compress our response if the raw data is bigger than 2k. Compressing less
|
||||
data is inefficient. Note that we use the synchronous functions from zlib
|
||||
to stay in the imperative style. The current `Server` doesn't depend on
|
||||
this, and we may just as well use the async versions.
|
||||
*/
|
||||
if(this.enableGzip && (data.length > 2048)) {
|
||||
var acceptEncoding = request.headers["accept-encoding"] || "";
|
||||
if(/\bdeflate\b/.test(acceptEncoding)) {
|
||||
headers["Content-Encoding"] = "deflate";
|
||||
data = zlib.deflateSync(data);
|
||||
} else if(/\bgzip\b/.test(acceptEncoding)) {
|
||||
headers["Content-Encoding"] = "gzip";
|
||||
data = zlib.gzipSync(data);
|
||||
}
|
||||
}
|
||||
|
||||
response.writeHead(statusCode,headers);
|
||||
response.end(data,encoding);
|
||||
}
|
||||
|
||||
function redirect(request,response,statusCode,location) {
|
||||
response.setHeader("Location",location);
|
||||
response.statusCode = statusCode;
|
||||
response.end()
|
||||
}
|
||||
|
||||
/*
|
||||
Options include:
|
||||
cbPartStart(headers,name,filename) - invoked when a file starts being received
|
||||
cbPartChunk(chunk) - invoked when a chunk of a file is received
|
||||
cbPartEnd() - invoked when a file finishes being received
|
||||
cbFinished(err) - invoked when the all the form data has been processed
|
||||
*/
|
||||
function streamMultipartData(request,options) {
|
||||
// Check that the Content-Type is multipart/form-data
|
||||
const contentType = request.headers['content-type'];
|
||||
if(!contentType.startsWith("multipart/form-data")) {
|
||||
return options.cbFinished("Expected multipart/form-data content type");
|
||||
}
|
||||
// Extract the boundary string from the Content-Type header
|
||||
const boundaryMatch = contentType.match(/boundary=(.+)$/);
|
||||
if(!boundaryMatch) {
|
||||
return options.cbFinished("Missing boundary in multipart/form-data");
|
||||
}
|
||||
const boundary = boundaryMatch[1];
|
||||
const boundaryBuffer = Buffer.from("--" + boundary);
|
||||
// Initialise
|
||||
let buffer = Buffer.alloc(0);
|
||||
let processingPart = false;
|
||||
// Process incoming chunks
|
||||
request.on("data", (chunk) => {
|
||||
// Accumulate the incoming data
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
// Loop through any parts within the current buffer
|
||||
while (true) {
|
||||
if(!processingPart) {
|
||||
// If we're not processing a part then we try to find a boundary marker
|
||||
const boundaryIndex = buffer.indexOf(boundaryBuffer);
|
||||
if(boundaryIndex === -1) {
|
||||
// Haven't reached the boundary marker yet, so we should wait for more data
|
||||
break;
|
||||
}
|
||||
// Look for the end of the headers
|
||||
const endOfHeaders = buffer.indexOf("\r\n\r\n",boundaryIndex + boundaryBuffer.length);
|
||||
if(endOfHeaders === -1) {
|
||||
// Haven't reached the end of the headers, so we should wait for more data
|
||||
break;
|
||||
}
|
||||
// Extract and parse headers
|
||||
const headersPart = Uint8Array.prototype.slice.call(buffer,boundaryIndex + boundaryBuffer.length,endOfHeaders).toString();
|
||||
const currentHeaders = {};
|
||||
headersPart.split("\r\n").forEach(headerLine => {
|
||||
const [key, value] = headerLine.split(": ");
|
||||
currentHeaders[key.toLowerCase()] = value;
|
||||
});
|
||||
// Parse the content disposition header
|
||||
const contentDisposition = {
|
||||
name: null,
|
||||
filename: null
|
||||
};
|
||||
if(currentHeaders["content-disposition"]) {
|
||||
// Split the content-disposition header into semicolon-delimited parts
|
||||
const parts = currentHeaders["content-disposition"].split(";").map(part => part.trim());
|
||||
// Iterate over each part to extract name and filename if they exist
|
||||
parts.forEach(part => {
|
||||
if(part.startsWith("name=")) {
|
||||
// Remove "name=" and trim quotes
|
||||
contentDisposition.name = part.substring(6,part.length - 1);
|
||||
} else if(part.startsWith("filename=")) {
|
||||
// Remove "filename=" and trim quotes
|
||||
contentDisposition.filename = part.substring(10,part.length - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
processingPart = true;
|
||||
options.cbPartStart(currentHeaders,contentDisposition.name,contentDisposition.filename);
|
||||
// Slice the buffer to the next part
|
||||
buffer = Uint8Array.prototype.slice.call(buffer,endOfHeaders + 4);
|
||||
} else {
|
||||
const boundaryIndex = buffer.indexOf(boundaryBuffer);
|
||||
if(boundaryIndex >= 0) {
|
||||
// Return the part up to the boundary minus the terminating LF CR
|
||||
options.cbPartChunk(Uint8Array.prototype.slice.call(buffer,0,boundaryIndex - 2));
|
||||
options.cbPartEnd();
|
||||
processingPart = false;
|
||||
buffer = Uint8Array.prototype.slice.call(buffer,boundaryIndex);
|
||||
} else {
|
||||
// Return the rest of the buffer
|
||||
options.cbPartChunk(buffer);
|
||||
// Reset the buffer and wait for more data
|
||||
buffer = Buffer.alloc(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// All done
|
||||
request.on("end", () => {
|
||||
options.cbFinished(null);
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
Make an etag. Options include:
|
||||
bag_name:
|
||||
tiddler_id:
|
||||
*/
|
||||
function makeTiddlerEtag(options) {
|
||||
if(options.bag_name || options.tiddler_id) {
|
||||
return "\"tiddler:" + options.bag_name + "/" + options.tiddler_id + "\"";
|
||||
} else {
|
||||
throw "Missing bag_name or tiddler_id";
|
||||
}
|
||||
}
|
||||
|
||||
Server.prototype.defaultVariables = {
|
||||
port: "8080",
|
||||
host: "127.0.0.1",
|
||||
"required-plugins": "$:/plugins/tiddlywiki/filesystem,$:/plugins/tiddlywiki/tiddlyweb",
|
||||
"root-tiddler": "$:/core/save/all",
|
||||
"root-render-type": "text/plain",
|
||||
"root-serve-type": "text/html",
|
||||
"tiddler-render-type": "text/html",
|
||||
"tiddler-render-template": "$:/core/templates/server/static.tiddler.html",
|
||||
"system-tiddler-render-type": "text/plain",
|
||||
"system-tiddler-render-template": "$:/core/templates/wikified-tiddler",
|
||||
"debug-level": "none",
|
||||
"gzip": "no",
|
||||
"use-browser-cache": "no"
|
||||
};
|
||||
|
||||
Server.prototype.get = function(name) {
|
||||
return this.variables[name];
|
||||
};
|
||||
|
||||
Server.prototype.addRoute = function(route) {
|
||||
this.routes.push(route);
|
||||
};
|
||||
|
||||
Server.prototype.addAuthenticator = function(AuthenticatorClass) {
|
||||
// Instantiate and initialise the authenticator
|
||||
var authenticator = new AuthenticatorClass(this),
|
||||
result = authenticator.init();
|
||||
if(typeof result === "string") {
|
||||
$tw.utils.error("Error: " + result);
|
||||
} else if(result) {
|
||||
// Only use the authenticator if it initialised successfully
|
||||
this.authenticators.push(authenticator);
|
||||
}
|
||||
};
|
||||
|
||||
Server.prototype.findMatchingRoute = function(request,state) {
|
||||
for(var t=0; t<this.routes.length; t++) {
|
||||
var potentialRoute = this.routes[t],
|
||||
pathRegExp = potentialRoute.path,
|
||||
pathname = state.urlInfo.pathname,
|
||||
match;
|
||||
if(state.pathPrefix) {
|
||||
if(pathname.substr(0,state.pathPrefix.length) === state.pathPrefix) {
|
||||
pathname = pathname.substr(state.pathPrefix.length) || "/";
|
||||
match = potentialRoute.path.exec(pathname);
|
||||
} else {
|
||||
match = false;
|
||||
}
|
||||
} else {
|
||||
match = potentialRoute.path.exec(pathname);
|
||||
}
|
||||
// Allow POST as a synonym for PUT because HTML doesn't allow PUT forms
|
||||
if(match && (request.method === potentialRoute.method || (request.method === "POST" && potentialRoute.method === "PUT"))) {
|
||||
state.params = [];
|
||||
for(var p=1; p<match.length; p++) {
|
||||
state.params.push(match[p]);
|
||||
}
|
||||
return potentialRoute;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
Server.prototype.methodMappings = {
|
||||
"GET": "readers",
|
||||
"OPTIONS": "readers",
|
||||
"HEAD": "readers",
|
||||
"PUT": "writers",
|
||||
"POST": "writers",
|
||||
"DELETE": "writers"
|
||||
};
|
||||
|
||||
/*
|
||||
Check whether a given user is authorized for the specified authorizationType ("readers" or "writers"). Pass null or undefined as the username to check for anonymous access
|
||||
*/
|
||||
Server.prototype.isAuthorized = function(authorizationType,username) {
|
||||
var principals = this.authorizationPrincipals[authorizationType] || [];
|
||||
return principals.indexOf("(anon)") !== -1 || (username && (principals.indexOf("(authenticated)") !== -1 || principals.indexOf(username) !== -1));
|
||||
}
|
||||
|
||||
Server.prototype.requestHandler = function(request,response,options) {
|
||||
options = options || {};
|
||||
const queryString = require("querystring");
|
||||
// Compose the state object
|
||||
var self = this;
|
||||
var state = {};
|
||||
state.wiki = options.wiki || self.wiki;
|
||||
state.boot = options.boot || self.boot;
|
||||
state.server = self;
|
||||
state.urlInfo = url.parse(request.url);
|
||||
state.queryParameters = querystring.parse(state.urlInfo.query);
|
||||
state.pathPrefix = options.pathPrefix || this.get("path-prefix") || "";
|
||||
state.sendResponse = sendResponse.bind(self,request,response);
|
||||
state.redirect = redirect.bind(self,request,response);
|
||||
state.streamMultipartData = streamMultipartData.bind(self,request);
|
||||
state.makeTiddlerEtag = makeTiddlerEtag.bind(self);
|
||||
// Get the principals authorized to access this resource
|
||||
state.authorizationType = options.authorizationType || this.methodMappings[request.method] || "readers";
|
||||
// Check whether anonymous access is granted
|
||||
state.allowAnon = this.isAuthorized(state.authorizationType,null);
|
||||
// Authenticate with the first active authenticator
|
||||
if(this.authenticators.length > 0) {
|
||||
if(!this.authenticators[0].authenticateRequest(request,response,state)) {
|
||||
// Bail if we failed (the authenticator will have sent the response)
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Authorize with the authenticated username
|
||||
if(!this.isAuthorized(state.authorizationType,state.authenticatedUsername)) {
|
||||
response.writeHead(401,"'" + state.authenticatedUsername + "' is not authorized to access '" + this.servername + "'");
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
// Find the route that matches this path
|
||||
var route = self.findMatchingRoute(request,state);
|
||||
// Optionally output debug info
|
||||
if(self.get("debug-level") !== "none") {
|
||||
console.log("Request path:",JSON.stringify(state.urlInfo));
|
||||
console.log("Request headers:",JSON.stringify(request.headers));
|
||||
console.log("authenticatedUsername:",state.authenticatedUsername);
|
||||
}
|
||||
// Return a 404 if we didn't find a route
|
||||
if(!route) {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
// If this is a write, check for the CSRF header unless globally disabled, or disabled for this route
|
||||
if(!this.csrfDisable && !route.csrfDisable && state.authorizationType === "writers" && request.headers["x-requested-with"] !== "TiddlyWiki") {
|
||||
response.writeHead(403,"'X-Requested-With' header required to login to '" + this.servername + "'");
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
// Receive the request body if necessary and hand off to the route handler
|
||||
if(route.bodyFormat === "stream" || request.method === "GET" || request.method === "HEAD") {
|
||||
// Let the route handle the request stream itself
|
||||
route.handler(request,response,state);
|
||||
} else if(route.bodyFormat === "string" || route.bodyFormat === "www-form-urlencoded" || !route.bodyFormat) {
|
||||
// Set the encoding for the incoming request
|
||||
request.setEncoding("utf8");
|
||||
var data = "";
|
||||
request.on("data",function(chunk) {
|
||||
data += chunk.toString();
|
||||
});
|
||||
request.on("end",function() {
|
||||
if(route.bodyFormat === "www-form-urlencoded") {
|
||||
data = queryString.parse(data);
|
||||
}
|
||||
state.data = data;
|
||||
route.handler(request,response,state);
|
||||
});
|
||||
} else if(route.bodyFormat === "buffer") {
|
||||
var data = [];
|
||||
request.on("data",function(chunk) {
|
||||
data.push(chunk);
|
||||
});
|
||||
request.on("end",function() {
|
||||
state.data = Buffer.concat(data);
|
||||
route.handler(request,response,state);
|
||||
})
|
||||
} else {
|
||||
response.writeHead(400,"Invalid bodyFormat " + route.bodyFormat + " in route " + route.method + " " + route.path.source);
|
||||
response.end();
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Listen for requests
|
||||
port: optional port number (falls back to value of "port" variable)
|
||||
host: optional host address (falls back to value of "host" variable)
|
||||
prefix: optional prefix (falls back to value of "path-prefix" variable)
|
||||
callback: optional callback(err) to be invoked when the listener is up and running
|
||||
*/
|
||||
Server.prototype.listen = function(port,host,prefix,options) {
|
||||
var self = this;
|
||||
// Handle defaults for port and host
|
||||
port = port || this.get("port");
|
||||
host = host || this.get("host");
|
||||
prefix = prefix || this.get("path-prefix") || "";
|
||||
// Check for the port being a string and look it up as an environment variable
|
||||
if(parseInt(port,10).toString() !== port) {
|
||||
port = process.env[port] || 8080;
|
||||
}
|
||||
// Warn if required plugins are missing
|
||||
var missing = [];
|
||||
for (var index=0; index<this.requiredPlugins.length; index++) {
|
||||
if(!this.wiki.getTiddler(this.requiredPlugins[index])) {
|
||||
missing.push(this.requiredPlugins[index]);
|
||||
}
|
||||
}
|
||||
if(missing.length > 0) {
|
||||
var error = "Warning: Plugin(s) required for client-server operation are missing.\n"+
|
||||
"\""+ missing.join("\", \"")+"\"";
|
||||
$tw.utils.warning(error);
|
||||
}
|
||||
// Create the server
|
||||
var server = this.transport.createServer(this.listenOptions || {},function(request,response,options) {
|
||||
if(self.get("debug-level") !== "none") {
|
||||
var start = $tw.utils.timer();
|
||||
response.on("finish",function() {
|
||||
console.log("Response time:",request.method,request.url,$tw.utils.timer() - start);
|
||||
});
|
||||
}
|
||||
self.requestHandler(request,response,options);
|
||||
});
|
||||
// Display the port number after we've started listening (the port number might have been specified as zero, in which case we will get an assigned port)
|
||||
server.on("listening",function() {
|
||||
// Stop listening when we get the "th-quit" hook
|
||||
$tw.hooks.addHook("th-quit",function() {
|
||||
server.close();
|
||||
});
|
||||
// Log listening details
|
||||
var address = server.address(),
|
||||
url = self.protocol + "://" + (address.family === "IPv6" ? "[" + address.address + "]" : address.address) + ":" + address.port + prefix;
|
||||
$tw.utils.log("Serving on " + url,"brown/orange");
|
||||
$tw.utils.log("(press ctrl-C to exit)","red");
|
||||
if(options.callback) {
|
||||
options.callback(null);
|
||||
}
|
||||
});
|
||||
// Listen
|
||||
return server.listen(port,host);
|
||||
};
|
||||
|
||||
exports.Server = Server;
|
||||
|
||||
})();
|
|
@ -0,0 +1,36 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/delete-bag-tiddler.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
DELETE /bags/:bag_name/tiddler/:title
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "DELETE";
|
||||
|
||||
exports.path = /^\/bags\/([^\/]+)\/tiddlers\/(.+)$/;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
// Get the parameters
|
||||
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
|
||||
title = $tw.utils.decodeURIComponentSafe(state.params[1]);
|
||||
if(bag_name) {
|
||||
var result = $tw.mws.store.deleteTiddler(title,bag_name);
|
||||
response.writeHead(204, "OK", {
|
||||
Etag: state.makeTiddlerEtag(result),
|
||||
"Content-Type": "text/plain"
|
||||
});
|
||||
response.end();
|
||||
} else {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
}
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,38 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-bag-tiddler-blob.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
GET /bags/:bag_name/tiddler/:title/blob
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "GET";
|
||||
|
||||
exports.path = /^\/bags\/([^\/]+)\/tiddlers\/([^\/]+)\/blob$/;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
// Get the parameters
|
||||
const bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
|
||||
title = $tw.utils.decodeURIComponentSafe(state.params[1]);
|
||||
if(bag_name) {
|
||||
const result = $tw.mws.store.getBagTiddlerStream(title,bag_name);
|
||||
if(result) {
|
||||
response.writeHead(200, "OK",{
|
||||
Etag: state.makeTiddlerEtag(result),
|
||||
"Content-Type": result.type,
|
||||
});
|
||||
result.stream.pipe(response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,68 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-bag-tiddler.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
GET /bags/:bag_name/tiddler/:title
|
||||
|
||||
Parameters:
|
||||
|
||||
fallback=<url> // Optional redirect if the tiddler is not found
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "GET";
|
||||
|
||||
exports.path = /^\/bags\/([^\/]+)\/tiddlers\/(.+)$/;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
// Get the parameters
|
||||
const bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
|
||||
title = $tw.utils.decodeURIComponentSafe(state.params[1]),
|
||||
tiddlerInfo = $tw.mws.store.getBagTiddler(title,bag_name);
|
||||
if(tiddlerInfo && tiddlerInfo.tiddler) {
|
||||
// If application/json is requested then this is an API request, and gets the response in JSON
|
||||
if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) {
|
||||
state.sendResponse(200,{
|
||||
Etag: state.makeTiddlerEtag(tiddlerInfo),
|
||||
"Content-Type": "application/json"
|
||||
},JSON.stringify(tiddlerInfo.tiddler),"utf8");
|
||||
return;
|
||||
} else {
|
||||
// This is not a JSON API request, we should return the raw tiddler content
|
||||
const result = $tw.mws.store.getBagTiddlerStream(title,bag_name);
|
||||
if(result) {
|
||||
response.writeHead(200, "OK",{
|
||||
Etag: state.makeTiddlerEtag(result),
|
||||
"Content-Type": result.type
|
||||
});
|
||||
result.stream.pipe(response);
|
||||
return;
|
||||
} else {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Redirect to fallback URL if tiddler not found
|
||||
if(state.queryParameters.fallback) {
|
||||
response.writeHead(302, "OK",{
|
||||
"Location": state.queryParameters.fallback
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
} else {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,55 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-bag.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
GET /bags/:bag_name/
|
||||
GET /bags/:bag_name
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "GET";
|
||||
|
||||
exports.path = /^\/bags\/([^\/]+)(\/?)$/;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
// Redirect if there is no trailing slash. We do this so that the relative URL specified in the upload form works correctly
|
||||
if(state.params[1] !== "/") {
|
||||
state.redirect(301,state.urlInfo.path + "/");
|
||||
return;
|
||||
}
|
||||
// Get the parameters
|
||||
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
|
||||
bagTiddlers = bag_name && $tw.mws.store.getBagTiddlers(bag_name);
|
||||
if(bag_name && bagTiddlers) {
|
||||
// If application/json is requested then this is an API request, and gets the response in JSON
|
||||
if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) {
|
||||
state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(bagTiddlers),"utf8");
|
||||
} else {
|
||||
// This is not a JSON API request, we should return the raw tiddler content
|
||||
response.writeHead(200, "OK",{
|
||||
"Content-Type": "text/html"
|
||||
});
|
||||
var html = $tw.mws.store.adminWiki.renderTiddler("text/plain","$:/plugins/tiddlywiki/multiwikiserver/templates/page",{
|
||||
variables: {
|
||||
"page-content": "$:/plugins/tiddlywiki/multiwikiserver/templates/get-bag",
|
||||
"bag-name": bag_name,
|
||||
"bag-titles": JSON.stringify(bagTiddlers.map(bagTiddler => bagTiddler.title)),
|
||||
"bag-tiddlers": JSON.stringify(bagTiddlers)
|
||||
}
|
||||
});
|
||||
response.write(html);
|
||||
response.end();
|
||||
}
|
||||
} else {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
}
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,45 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-index.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
GET /?show_system=true
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "GET";
|
||||
|
||||
exports.path = /^\/$/;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
// Get the bag and recipe information
|
||||
var bagList = $tw.mws.store.listBags(),
|
||||
recipeList = $tw.mws.store.listRecipes();
|
||||
// If application/json is requested then this is an API request, and gets the response in JSON
|
||||
if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) {
|
||||
state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(recipes),"utf8");
|
||||
} else {
|
||||
// This is not a JSON API request, we should return the raw tiddler content
|
||||
response.writeHead(200, "OK",{
|
||||
"Content-Type": "text/html"
|
||||
});
|
||||
// Render the html
|
||||
var html = $tw.mws.store.adminWiki.renderTiddler("text/plain","$:/plugins/tiddlywiki/multiwikiserver/templates/page",{
|
||||
variables: {
|
||||
"show-system": state.queryParameters.show_system || "off",
|
||||
"page-content": "$:/plugins/tiddlywiki/multiwikiserver/templates/get-index",
|
||||
"bag-list": JSON.stringify(bagList),
|
||||
"recipe-list": JSON.stringify(recipeList)
|
||||
}
|
||||
});
|
||||
response.write(html);
|
||||
response.end();
|
||||
}
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,88 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-recipe-events.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
GET /recipes/:recipe_name/events
|
||||
|
||||
headers:
|
||||
|
||||
Last-Event-ID:
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
const SSE_HEARTBEAT_INTERVAL_MS = 10 * 1000;
|
||||
|
||||
exports.method = "GET";
|
||||
|
||||
exports.path = /^\/recipes\/([^\/]+)\/events$/;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
// Get the parameters
|
||||
const recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]);
|
||||
let last_known_tiddler_id = 0;
|
||||
if(request.headers["Last-Event-ID"]) {
|
||||
last_known_tiddler_id = $tw.utils.parseNumber(request.headers["Last-Event-ID"]);
|
||||
} else if(state.queryParameters.last_known_tiddler_id) {
|
||||
last_known_tiddler_id = $tw.utils.parseNumber(state.queryParameters.last_known_tiddler_id);
|
||||
}
|
||||
if(recipe_name) {
|
||||
// Start streaming the response
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive"
|
||||
});
|
||||
// Setup the heartbeat timer
|
||||
var heartbeatTimer = setInterval(function() {
|
||||
response.write(':keep-alive\n\n');
|
||||
},SSE_HEARTBEAT_INTERVAL_MS);
|
||||
// Method to get changed tiddler events and send to the client
|
||||
function sendUpdates() {
|
||||
// Get the tiddlers in the recipe since the last known tiddler_id
|
||||
var recipeTiddlers = $tw.mws.store.getRecipeTiddlers(recipe_name,{
|
||||
include_deleted: true,
|
||||
last_known_tiddler_id: last_known_tiddler_id
|
||||
});
|
||||
// Send to the client
|
||||
if(recipeTiddlers) {
|
||||
for(let index = recipeTiddlers.length-1; index>=0; index--) {
|
||||
const tiddlerInfo = recipeTiddlers[index];
|
||||
if(tiddlerInfo.tiddler_id > last_known_tiddler_id) {
|
||||
last_known_tiddler_id = tiddlerInfo.tiddler_id;
|
||||
}
|
||||
response.write(`event: change\n`)
|
||||
let data = tiddlerInfo;
|
||||
if(!tiddlerInfo.is_deleted) {
|
||||
const tiddler = $tw.mws.store.getRecipeTiddler(tiddlerInfo.title,recipe_name);
|
||||
if(tiddler) {
|
||||
data = $tw.utils.extend({},data,{tiddler: tiddler.tiddler})
|
||||
}
|
||||
}
|
||||
response.write(`data: ${JSON.stringify(data)}\n`);
|
||||
response.write(`id: ${tiddlerInfo.tiddler_id}\n`)
|
||||
response.write(`\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Send current and future changes
|
||||
sendUpdates();
|
||||
$tw.mws.store.addEventListener("change",sendUpdates);
|
||||
// Clean up when the connection closes
|
||||
response.on("close",function () {
|
||||
clearInterval(heartbeatTimer);
|
||||
$tw.mws.store.removeEventListener("change",sendUpdates);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Fail if something went wrong
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,65 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-recipe-tiddler.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
GET /recipes/:recipe_name/tiddler/:title
|
||||
|
||||
Parameters:
|
||||
|
||||
fallback=<url> // Optional redirect if the tiddler is not found
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "GET";
|
||||
|
||||
exports.path = /^\/recipes\/([^\/]+)\/tiddlers\/(.+)$/;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
// Get the parameters
|
||||
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
|
||||
title = $tw.utils.decodeURIComponentSafe(state.params[1]),
|
||||
tiddlerInfo = $tw.mws.store.getRecipeTiddler(title,recipe_name);
|
||||
if(tiddlerInfo && tiddlerInfo.tiddler) {
|
||||
// If application/json is requested then this is an API request, and gets the response in JSON
|
||||
if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) {
|
||||
state.sendResponse(200,{
|
||||
"X-Revision-Number": tiddlerInfo.tiddler_id,
|
||||
"X-Bag-Name": tiddlerInfo.bag_name,
|
||||
Etag: state.makeTiddlerEtag(tiddlerInfo),
|
||||
"Content-Type": "application/json"
|
||||
},JSON.stringify(tiddlerInfo.tiddler),"utf8");
|
||||
return;
|
||||
} else {
|
||||
// This is not a JSON API request, we should return the raw tiddler content
|
||||
var type = tiddlerInfo.tiddler.type || "text/plain";
|
||||
response.writeHead(200, "OK",{
|
||||
Etag: state.makeTiddlerEtag(tiddlerInfo),
|
||||
"Content-Type": type
|
||||
});
|
||||
response.write(tiddlerInfo.tiddler.text || "",($tw.config.contentTypeInfo[type] ||{encoding: "utf8"}).encoding);
|
||||
response.end();;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Redirect to fallback URL if tiddler not found
|
||||
if(state.queryParameters.fallback) {
|
||||
response.writeHead(302, "OK",{
|
||||
"Location": state.queryParameters.fallback
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
} else {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,39 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-recipe-tiddlers-json.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
GET /recipes/:recipe_name/tiddlers.json?last_known_tiddler_id=:last_known_tiddler_id&include_deleted=true|false
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "GET";
|
||||
|
||||
exports.path = /^\/recipes\/([^\/]+)\/tiddlers.json$/;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
// Get the parameters
|
||||
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]);
|
||||
if(recipe_name) {
|
||||
// Get the tiddlers in the recipe, optionally since the specified last known tiddler_id
|
||||
var recipeTiddlers = $tw.mws.store.getRecipeTiddlers(recipe_name,{
|
||||
include_deleted: state.queryParameters.include_deleted === "true",
|
||||
last_known_tiddler_id: state.queryParameters.last_known_tiddler_id
|
||||
});
|
||||
if(recipeTiddlers) {
|
||||
state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(recipeTiddlers),"utf8");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fail if something went wrong
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,52 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-system.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
Retrieves a system file. System files are stored in configuration tiddlers with the following fields:
|
||||
|
||||
* title: "$:/plugins/tiddlywiki/multiwikiserver/system-files/" suffixed with the name of the file
|
||||
* tags: tagged $:/tags/MWS/SystemFile or $:/tags/MWS/SystemFileWikified
|
||||
* system-file-type: optionally specify the MIME type that should be returned for the file
|
||||
|
||||
GET /.system/:filename
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "GET";
|
||||
|
||||
exports.path = /^\/\.system\/(.+)$/;
|
||||
|
||||
const SYSTEM_FILE_TITLE_PREFIX = "$:/plugins/tiddlywiki/multiwikiserver/system-files/";
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
// Get the parameters
|
||||
const filename = $tw.utils.decodeURIComponentSafe(state.params[0]),
|
||||
title = SYSTEM_FILE_TITLE_PREFIX + filename,
|
||||
tiddler = $tw.wiki.getTiddler(title),
|
||||
isSystemFile = tiddler && tiddler.hasTag("$:/tags/MWS/SystemFile"),
|
||||
isSystemFileWikified = tiddler && tiddler.hasTag("$:/tags/MWS/SystemFileWikified");
|
||||
if(tiddler && (isSystemFile || isSystemFileWikified)) {
|
||||
let text = tiddler.fields.text || "";
|
||||
const type = tiddler.fields["system-file-type"] || tiddler.fields.type || "text/plain",
|
||||
encoding = ($tw.config.contentTypeInfo[type] ||{encoding: "utf8"}).encoding;
|
||||
if(isSystemFileWikified) {
|
||||
text = $tw.wiki.renderTiddler("text/plain",title);
|
||||
}
|
||||
response.writeHead(200, "OK",{
|
||||
"Content-Type": type
|
||||
});
|
||||
response.write(text,encoding);
|
||||
response.end();
|
||||
} else {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
}
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,93 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-wiki.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
GET /wiki/:recipe_name
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "GET";
|
||||
|
||||
exports.path = /^\/wiki\/([^\/]+)$/;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
// Get the recipe name from the parameters
|
||||
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
|
||||
recipeTiddlers = recipe_name && $tw.mws.store.getRecipeTiddlers(recipe_name);
|
||||
// Check request is valid
|
||||
if(recipe_name && recipeTiddlers) {
|
||||
// Start the response
|
||||
response.writeHead(200, "OK",{
|
||||
"Content-Type": "text/html"
|
||||
});
|
||||
// Get the tiddlers in the recipe
|
||||
// Render the template
|
||||
var template = $tw.mws.store.adminWiki.renderTiddler("text/plain","$:/core/templates/tiddlywiki5.html",{
|
||||
variables: {
|
||||
saveTiddlerFilter: `
|
||||
$:/boot/boot.css
|
||||
$:/boot/boot.js
|
||||
$:/boot/bootprefix.js
|
||||
$:/core
|
||||
$:/library/sjcl.js
|
||||
$:/plugins/tiddlywiki/multiwikiclient
|
||||
$:/themes/tiddlywiki/snowwhite
|
||||
$:/themes/tiddlywiki/vanilla
|
||||
`
|
||||
}
|
||||
});
|
||||
// Splice in our tiddlers
|
||||
var marker = `<` + `script class="tiddlywiki-tiddler-store" type="application/json">[`,
|
||||
markerPos = template.indexOf(marker);
|
||||
if(markerPos === -1) {
|
||||
throw new Error("Cannot find tiddler store in template");
|
||||
}
|
||||
function writeTiddler(tiddlerFields) {
|
||||
response.write(JSON.stringify(tiddlerFields).replace(/</g,"\\u003c"));
|
||||
response.write(",\n");
|
||||
}
|
||||
response.write(template.substring(0,markerPos + marker.length));
|
||||
const bagInfo = {},
|
||||
revisionInfo = {};
|
||||
$tw.utils.each(recipeTiddlers,function(recipeTiddlerInfo) {
|
||||
var result = $tw.mws.store.getRecipeTiddler(recipeTiddlerInfo.title,recipe_name);
|
||||
if(result) {
|
||||
bagInfo[result.tiddler.title] = result.bag_name;
|
||||
revisionInfo[result.tiddler.title] = result.tiddler_id.toString();
|
||||
writeTiddler(result.tiddler);
|
||||
}
|
||||
});
|
||||
writeTiddler({
|
||||
title: "$:/state/multiwikiclient/tiddlers/bag",
|
||||
text: JSON.stringify(bagInfo),
|
||||
type: "application/json"
|
||||
});
|
||||
writeTiddler({
|
||||
title: "$:/state/multiwikiclient/tiddlers/revision",
|
||||
text: JSON.stringify(revisionInfo),
|
||||
type: "application/json"
|
||||
});
|
||||
writeTiddler({
|
||||
title: "$:/config/multiwikiclient/recipe",
|
||||
text: recipe_name
|
||||
});
|
||||
writeTiddler({
|
||||
title: "$:/state/multiwikiclient/recipe/last_tiddler_id",
|
||||
text: ($tw.mws.store.getRecipeLastTiddlerId(recipe_name) || 0).toString()
|
||||
});
|
||||
response.write(template.substring(markerPos + marker.length))
|
||||
// Finish response
|
||||
response.end();
|
||||
} else {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
}
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,70 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-bag-tiddlers.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
POST /bags/:bag_name/tiddlers/
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "POST";
|
||||
|
||||
exports.path = /^\/bags\/([^\/]+)\/tiddlers\/$/;
|
||||
|
||||
exports.bodyFormat = "stream";
|
||||
|
||||
exports.csrfDisable = true;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
const path = require("path"),
|
||||
fs = require("fs"),
|
||||
processIncomingStream = require("$:/plugins/tiddlywiki/multiwikiserver/routes/helpers/multipart-forms.js").processIncomingStream;
|
||||
// Get the parameters
|
||||
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]);
|
||||
// Process the incoming data
|
||||
processIncomingStream({
|
||||
store: $tw.mws.store,
|
||||
state: state,
|
||||
response: response,
|
||||
bag_name: bag_name,
|
||||
callback: function(err,results) {
|
||||
// If application/json is requested then this is an API request, and gets the response in JSON
|
||||
if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) {
|
||||
state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify({
|
||||
"imported-tiddlers": results
|
||||
}));
|
||||
} else {
|
||||
response.writeHead(200, "OK",{
|
||||
"Content-Type": "text/html"
|
||||
});
|
||||
response.write(`
|
||||
<!doctype html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
|
||||
</head>
|
||||
<body>
|
||||
`);
|
||||
// Render the html
|
||||
var html = $tw.mws.store.adminWiki.renderTiddler("text/html","$:/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers",{
|
||||
variables: {
|
||||
"bag-name": bag_name,
|
||||
"imported-titles": JSON.stringify(results)
|
||||
}
|
||||
});
|
||||
response.write(html);
|
||||
response.write(`
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
response.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,50 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-bag.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
POST /bags
|
||||
|
||||
Parameters:
|
||||
|
||||
bag_name
|
||||
description
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "POST";
|
||||
|
||||
exports.path = /^\/bags$/;
|
||||
|
||||
exports.bodyFormat = "www-form-urlencoded";
|
||||
|
||||
exports.csrfDisable = true;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
if(state.data.bag_name) {
|
||||
const result = $tw.mws.store.createBag(state.data.bag_name,state.data.description);
|
||||
if(!result) {
|
||||
state.sendResponse(302,{
|
||||
"Content-Type": "text/plain",
|
||||
"Location": "/"
|
||||
});
|
||||
} else {
|
||||
state.sendResponse(400,{
|
||||
"Content-Type": "text/plain"
|
||||
},
|
||||
result.message,
|
||||
"utf8");
|
||||
}
|
||||
} else {
|
||||
state.sendResponse(400,{
|
||||
"Content-Type": "text/plain"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,51 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-recipe.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
POST /recipes
|
||||
|
||||
Parameters:
|
||||
|
||||
recipe_name
|
||||
description
|
||||
bag_names: space separated list of bags
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "POST";
|
||||
|
||||
exports.path = /^\/recipes$/;
|
||||
|
||||
exports.bodyFormat = "www-form-urlencoded";
|
||||
|
||||
exports.csrfDisable = true;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
if(state.data.recipe_name && state.data.bag_names) {
|
||||
const result = $tw.mws.store.createRecipe(state.data.recipe_name,$tw.utils.parseStringArray(state.data.bag_names),state.data.description);
|
||||
if(!result) {
|
||||
state.sendResponse(302,{
|
||||
"Content-Type": "text/plain",
|
||||
"Location": "/"
|
||||
});
|
||||
} else {
|
||||
state.sendResponse(400,{
|
||||
"Content-Type": "text/plain"
|
||||
},
|
||||
result.message,
|
||||
"utf8");
|
||||
}
|
||||
} else {
|
||||
state.sendResponse(400,{
|
||||
"Content-Type": "text/plain"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,42 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/put-bag.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
PUT /bags/:bag_name
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "PUT";
|
||||
|
||||
exports.path = /^\/bags\/(.+)$/;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
// Get the parameters
|
||||
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
|
||||
data = $tw.utils.parseJSONSafe(state.data);
|
||||
if(bag_name && data) {
|
||||
const result = $tw.mws.store.createBag(bag_name,data.description);
|
||||
if(!result) {
|
||||
state.sendResponse(204,{
|
||||
"Content-Type": "text/plain"
|
||||
});
|
||||
} else {
|
||||
state.sendResponse(400,{
|
||||
"Content-Type": "text/plain"
|
||||
},
|
||||
result.message,
|
||||
"utf8");
|
||||
}
|
||||
} else {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
}
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,45 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/put-recipe-tiddler.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
PUT /recipes/:recipe_name/tiddlers/:title
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "PUT";
|
||||
|
||||
exports.path = /^\/recipes\/([^\/]+)\/tiddlers\/(.+)$/;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
// Get the parameters
|
||||
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
|
||||
title = $tw.utils.decodeURIComponentSafe(state.params[1]),
|
||||
fields = $tw.utils.parseJSONSafe(state.data);
|
||||
if(recipe_name && title === fields.title) {
|
||||
var result = $tw.mws.store.saveRecipeTiddler(fields,recipe_name);
|
||||
if(result) {
|
||||
response.writeHead(204, "OK",{
|
||||
"X-Revision-Number": result.tiddler_id.toString(),
|
||||
"X-Bag-Name": result.bag_name,
|
||||
Etag: state.makeTiddlerEtag(result),
|
||||
"Content-Type": "text/plain"
|
||||
});
|
||||
} else {
|
||||
response.writeHead(400);
|
||||
}
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
// Fail if something went wrong
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,42 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/put-recipe.js
|
||||
type: application/javascript
|
||||
module-type: mws-route
|
||||
|
||||
PUT /recipes/:recipe_name
|
||||
|
||||
\*/
|
||||
(function() {
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
exports.method = "PUT";
|
||||
|
||||
exports.path = /^\/recipes\/(.+)$/;
|
||||
|
||||
exports.handler = function(request,response,state) {
|
||||
// Get the parameters
|
||||
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
|
||||
data = $tw.utils.parseJSONSafe(state.data);
|
||||
if(recipe_name && data) {
|
||||
const result = $tw.mws.store.createRecipe(recipe_name,data.bag_names,data.description);
|
||||
if(!result) {
|
||||
state.sendResponse(204,{
|
||||
"Content-Type": "text/plain"
|
||||
});
|
||||
} else {
|
||||
state.sendResponse(400,{
|
||||
"Content-Type": "text/plain"
|
||||
},
|
||||
result.message,
|
||||
"utf8");
|
||||
}
|
||||
} else {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
}
|
||||
};
|
||||
|
||||
}());
|
|
@ -0,0 +1,100 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/routes/helpers/multipart-forms.js
|
||||
type: application/javascript
|
||||
module-type: library
|
||||
|
||||
A function that handles an incoming multipart/form-data stream, streaming the data to temporary files
|
||||
in the store/inbox folder. Once the data is received, it imports any tiddlers and invokes a callback.
|
||||
|
||||
\*/
|
||||
|
||||
(function() {
|
||||
|
||||
/*
|
||||
Process an incoming new multipart/form-data stream. Options include:
|
||||
|
||||
store - tiddler store
|
||||
state - provided by server.js
|
||||
response - provided by server.js
|
||||
bag_name - name of bag to write to
|
||||
callback - invoked as callback(err,results). Results is an array of titles of imported tiddlers
|
||||
*/
|
||||
exports.processIncomingStream = function(options) {
|
||||
const self = this;
|
||||
const path = require("path"),
|
||||
fs = require("fs");
|
||||
// Process the incoming data
|
||||
const inboxName = $tw.utils.stringifyDate(new Date());
|
||||
const inboxPath = path.resolve(options.store.attachmentStore.storePath,"inbox",inboxName);
|
||||
$tw.utils.createDirectory(inboxPath);
|
||||
let fileStream = null; // Current file being written
|
||||
let hash = null; // Accumulating hash of current part
|
||||
let length = 0; // Accumulating length of current part
|
||||
const parts = []; // Array of {name:, headers:, value:, hash:} and/or {name:, filename:, headers:, inboxFilename:, hash:}
|
||||
options.state.streamMultipartData({
|
||||
cbPartStart: function(headers,name,filename) {
|
||||
const part = {
|
||||
name: name,
|
||||
filename: filename,
|
||||
headers: headers
|
||||
};
|
||||
if(filename) {
|
||||
const inboxFilename = (parts.length).toString();
|
||||
part.inboxFilename = path.resolve(inboxPath,inboxFilename);
|
||||
fileStream = fs.createWriteStream(part.inboxFilename);
|
||||
} else {
|
||||
part.value = "";
|
||||
}
|
||||
hash = new $tw.sjcl.hash.sha256();
|
||||
length = 0;
|
||||
parts.push(part)
|
||||
},
|
||||
cbPartChunk: function(chunk) {
|
||||
if(fileStream) {
|
||||
fileStream.write(chunk);
|
||||
} else {
|
||||
parts[parts.length - 1].value += chunk;
|
||||
}
|
||||
length = length + chunk.length;
|
||||
hash.update(chunk);
|
||||
},
|
||||
cbPartEnd: function() {
|
||||
if(fileStream) {
|
||||
fileStream.end();
|
||||
}
|
||||
fileStream = null;
|
||||
parts[parts.length - 1].hash = $tw.sjcl.codec.hex.fromBits(hash.finalize()).slice(0,64).toString();
|
||||
hash = null;
|
||||
},
|
||||
cbFinished: function(err) {
|
||||
if(err) {
|
||||
return options.callback(err);
|
||||
} else {
|
||||
const partFile = parts.find(part => part.name === "file-to-upload" && !!part.filename);
|
||||
if(!partFile) {
|
||||
return state.sendResponse(400, {"Content-Type": "text/plain"},"Missing file to upload");
|
||||
}
|
||||
const type = partFile.headers["content-type"];
|
||||
const tiddlerFields = {
|
||||
title: partFile.filename,
|
||||
type: type
|
||||
};
|
||||
for(const part of parts) {
|
||||
const tiddlerFieldPrefix = "tiddler-field-";
|
||||
if(part.name.startsWith(tiddlerFieldPrefix)) {
|
||||
tiddlerFields[part.name.slice(tiddlerFieldPrefix.length)] = part.value.trim();
|
||||
}
|
||||
}
|
||||
options.store.saveBagTiddlerWithAttachment(tiddlerFields,options.bag_name,{
|
||||
filepath: partFile.inboxFilename,
|
||||
type: type,
|
||||
hash: partFile.hash
|
||||
});
|
||||
$tw.utils.deleteDirectory(inboxPath);
|
||||
options.callback(null,[tiddlerFields.title]);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
})();
|
|
@ -0,0 +1,189 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/startup.js
|
||||
type: application/javascript
|
||||
module-type: startup
|
||||
|
||||
Multi wiki server initialisation
|
||||
|
||||
\*/
|
||||
(function(){
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
// Export name and synchronous status
|
||||
exports.name = "multiwikiserver";
|
||||
exports.platforms = ["node"];
|
||||
exports.before = ["story"];
|
||||
exports.synchronous = true;
|
||||
|
||||
exports.startup = function() {
|
||||
const store = setupStore();
|
||||
loadStore(store);
|
||||
$tw.mws = {
|
||||
store: store,
|
||||
serverManager: new ServerManager({
|
||||
store: store
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function setupStore() {
|
||||
const path = require("path");
|
||||
// Create and initialise the attachment store and the tiddler store
|
||||
const AttachmentStore = require("$:/plugins/tiddlywiki/multiwikiserver/store/attachments.js").AttachmentStore,
|
||||
attachmentStore = new AttachmentStore({
|
||||
storePath: path.resolve($tw.boot.wikiPath,"store/")
|
||||
}),
|
||||
SqlTiddlerStore = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-store.js").SqlTiddlerStore,
|
||||
store = new SqlTiddlerStore({
|
||||
databasePath: path.resolve($tw.boot.wikiPath,"store/database.sqlite"),
|
||||
engine: $tw.wiki.getTiddlerText("$:/config/MultiWikiServer/Engine","better"), // better || wasm
|
||||
attachmentStore: attachmentStore
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
function loadStore(store) {
|
||||
const path = require("path"),
|
||||
fs = require("fs");
|
||||
// Performance timing
|
||||
console.time("mws-initial-load");
|
||||
// Copy plugins
|
||||
var makePluginBagName = function(type,publisher,name) {
|
||||
return "$:/" + type + "/" + (publisher ? publisher + "/" : "") + name;
|
||||
},
|
||||
savePlugin = function(pluginFields,type,publisher,name) {
|
||||
const bagName = makePluginBagName(type,publisher,name);
|
||||
const result = store.createBag(bagName,pluginFields.description || "(no description)",{allowPrivilegedCharacters: true});
|
||||
if(result) {
|
||||
console.log(`Error creating plugin bag ${bagname}: ${JSON.stringify(result)}`);
|
||||
}
|
||||
console.log(`saveBagTiddler of ${pluginFields.title} to ${bagName}`);
|
||||
store.saveBagTiddler(pluginFields,bagName);
|
||||
},
|
||||
collectPlugins = function(folder,type,publisher) {
|
||||
var pluginFolders = $tw.utils.getSubdirectories(folder) || [];
|
||||
for(var p=0; p<pluginFolders.length; p++) {
|
||||
const pluginFolderName = pluginFolders[p];
|
||||
if(!$tw.boot.excludeRegExp.test(pluginFolderName)) {
|
||||
var pluginFields = $tw.loadPluginFolder(path.resolve(folder,pluginFolderName));
|
||||
if(pluginFields && pluginFields.title) {
|
||||
savePlugin(pluginFields,type,publisher,pluginFolderName);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
collectPublisherPlugins = function(folder,type) {
|
||||
var publisherFolders = $tw.utils.getSubdirectories(folder) || [];
|
||||
for(var t=0; t<publisherFolders.length; t++) {
|
||||
const publisherFolderName = publisherFolders[t];
|
||||
if(!$tw.boot.excludeRegExp.test(publisherFolderName)) {
|
||||
collectPlugins(path.resolve(folder,publisherFolderName),type,publisherFolderName);
|
||||
}
|
||||
}
|
||||
};
|
||||
$tw.utils.each($tw.getLibraryItemSearchPaths($tw.config.pluginsPath,$tw.config.pluginsEnvVar),function(folder) {
|
||||
collectPublisherPlugins(folder,"plugin");
|
||||
});
|
||||
$tw.utils.each($tw.getLibraryItemSearchPaths($tw.config.themesPath,$tw.config.themesEnvVar),function(folder) {
|
||||
collectPublisherPlugins(folder,"theme");
|
||||
});
|
||||
$tw.utils.each($tw.getLibraryItemSearchPaths($tw.config.languagesPath,$tw.config.languagesEnvVar),function(folder) {
|
||||
collectPlugins(folder,"language");
|
||||
});
|
||||
// Copy TiddlyWiki core editions
|
||||
function copyEdition(options) {
|
||||
// Read the tiddlywiki.info file
|
||||
const wikiInfoPath = path.resolve($tw.boot.corePath,$tw.config.editionsPath,options.wikiPath,$tw.config.wikiInfo);
|
||||
let wikiInfo;
|
||||
if(fs.existsSync(wikiInfoPath)) {
|
||||
wikiInfo = $tw.utils.parseJSONSafe(fs.readFileSync(wikiInfoPath,"utf8"),function() {return null;});
|
||||
}
|
||||
if(wikiInfo) {
|
||||
// Create the bag
|
||||
const result = store.createBag(options.bagName,options.bagDescription);
|
||||
if(result) {
|
||||
console.log(`Error creating bag ${options.bagName} for edition ${options.wikiPath}: ${JSON.stringify(result)}`);
|
||||
}
|
||||
// Add plugins to the recipe list
|
||||
const recipeList = [];
|
||||
const processPlugins = function(type,plugins) {
|
||||
$tw.utils.each(plugins,function(pluginName) {
|
||||
const parts = pluginName.split("/");
|
||||
let publisher, name;
|
||||
if(parts.length === 2) {
|
||||
publisher = parts[0];
|
||||
name = parts[1];
|
||||
} else {
|
||||
name = parts[0];
|
||||
}
|
||||
recipeList.push(makePluginBagName(type,publisher,name));
|
||||
});
|
||||
};
|
||||
processPlugins("plugin",wikiInfo.plugins);
|
||||
processPlugins("theme",wikiInfo.themes);
|
||||
processPlugins("language",wikiInfo.languages);
|
||||
// Create the recipe
|
||||
recipeList.push(options.bagName);
|
||||
store.createRecipe(options.recipeName,recipeList,options.recipeDescription);
|
||||
store.saveTiddlersFromPath(path.resolve($tw.boot.corePath,$tw.config.editionsPath,options.wikiPath,$tw.config.wikiTiddlersSubDir),options.bagName);
|
||||
}
|
||||
}
|
||||
copyEdition({
|
||||
bagName: "docs",
|
||||
bagDescription: "TiddlyWiki Documentation from https://tiddlywiki.com",
|
||||
recipeName: "docs",
|
||||
recipeDescription: "TiddlyWiki Documentation from https://tiddlywiki.com",
|
||||
wikiPath: "tw5.com"
|
||||
});
|
||||
copyEdition({
|
||||
bagName: "dev-docs",
|
||||
bagDescription: "TiddlyWiki Developer Documentation from https://tiddlywiki.com/dev",
|
||||
recipeName: "dev-docs",
|
||||
recipeDescription: "TiddlyWiki Developer Documentation from https://tiddlywiki.com/dev",
|
||||
wikiPath: "dev"
|
||||
});
|
||||
copyEdition({
|
||||
bagName: "tour",
|
||||
bagDescription: "TiddlyWiki Interactive Tour from https://tiddlywiki.com",
|
||||
recipeName: "tour",
|
||||
recipeDescription: "TiddlyWiki Interactive Tour from https://tiddlywiki.com",
|
||||
wikiPath: "tour"
|
||||
});
|
||||
// copyEdition({
|
||||
// bagName: "full",
|
||||
// bagDescription: "TiddlyWiki Fully Loaded Edition from https://tiddlywiki.com",
|
||||
// recipeName: "full",
|
||||
// recipeDescription: "TiddlyWiki Fully Loaded Edition from https://tiddlywiki.com",
|
||||
// wikiPath: "full"
|
||||
// });
|
||||
// Create bags and recipes
|
||||
store.createBag("bag-alpha","A test bag");
|
||||
store.createBag("bag-beta","Another test bag");
|
||||
store.createBag("bag-gamma","A further test bag");
|
||||
store.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"First wiki");
|
||||
store.createRecipe("recipe-sigma",["bag-alpha","bag-gamma"],"Second Wiki");
|
||||
store.createRecipe("recipe-tau",["bag-alpha"],"Third Wiki");
|
||||
store.createRecipe("recipe-upsilon",["bag-alpha","bag-gamma","bag-beta"],"Fourth Wiki");
|
||||
// Save tiddlers
|
||||
store.saveBagTiddler({title: "$:/SiteTitle",text: "Bag Alpha"},"bag-alpha");
|
||||
store.saveBagTiddler({title: "😀😃😄😁😆🥹😅😂",text: "Bag Alpha"},"bag-alpha");
|
||||
store.saveBagTiddler({title: "$:/SiteTitle",text: "Bag Beta"},"bag-beta");
|
||||
store.saveBagTiddler({title: "$:/SiteTitle",text: "Bag Gamma"},"bag-gamma");
|
||||
console.timeEnd("mws-initial-load");
|
||||
}
|
||||
|
||||
function ServerManager(store) {
|
||||
this.servers = [];
|
||||
}
|
||||
|
||||
ServerManager.prototype.createServer = function(options) {
|
||||
const MWSServer = require("$:/plugins/tiddlywiki/multiwikiserver/mws-server.js").Server,
|
||||
server = new MWSServer(options);
|
||||
this.servers.push(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
})();
|
|
@ -0,0 +1,140 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/store/attachments.js
|
||||
type: application/javascript
|
||||
module-type: library
|
||||
|
||||
Class to handle the attachments in the filing system
|
||||
|
||||
The store folder looks like this:
|
||||
|
||||
store/
|
||||
inbox/ - files that are in the process of being uploaded via a multipart form upload
|
||||
202402282125432742/
|
||||
0
|
||||
1
|
||||
...
|
||||
...
|
||||
files/ - files that are the text content of large tiddlers
|
||||
b7def178-79c4-4d88-b7a4-39763014a58b/
|
||||
data.jpg - the extension is provided for convenience when directly inspecting the file system
|
||||
meta.json - contains:
|
||||
{
|
||||
"filename": "data.jpg",
|
||||
"type": "video/mp4",
|
||||
"uploaded": "2024021821224823"
|
||||
}
|
||||
database.sql - The database file (managed by sql-tiddler-database.js)
|
||||
|
||||
\*/
|
||||
|
||||
(function() {
|
||||
|
||||
/*
|
||||
Class to handle an attachment store. Options include:
|
||||
|
||||
storePath - path to the store
|
||||
*/
|
||||
function AttachmentStore(options) {
|
||||
options = options || {};
|
||||
this.storePath = options.storePath;
|
||||
}
|
||||
|
||||
/*
|
||||
Check if an attachment name is valid
|
||||
*/
|
||||
AttachmentStore.prototype.isValidAttachmentName = function(attachment_name) {
|
||||
const re = new RegExp('^[a-f0-9]{64}$');
|
||||
return re.test(attachment_name);
|
||||
};
|
||||
|
||||
/*
|
||||
Saves an attachment to a file. Options include:
|
||||
|
||||
text: text content (may be binary)
|
||||
type: MIME type of content
|
||||
reference: reference to use for debugging
|
||||
*/
|
||||
AttachmentStore.prototype.saveAttachment = function(options) {
|
||||
const path = require("path"),
|
||||
fs = require("fs");
|
||||
// Compute the content hash for naming the attachment
|
||||
const contentHash = $tw.sjcl.codec.hex.fromBits($tw.sjcl.hash.sha256.hash(options.text)).slice(0,64).toString();
|
||||
// Choose the best file extension for the attachment given its type
|
||||
const contentTypeInfo = $tw.config.contentTypeInfo[options.type] || $tw.config.contentTypeInfo["application/octet-stream"];
|
||||
// Creat the attachment directory
|
||||
const attachmentPath = path.resolve(this.storePath,"files",contentHash);
|
||||
$tw.utils.createDirectory(attachmentPath);
|
||||
// Save the data file
|
||||
const dataFilename = "data" + contentTypeInfo.extension;
|
||||
fs.writeFileSync(path.resolve(attachmentPath,dataFilename),options.text,contentTypeInfo.encoding);
|
||||
// Save the meta.json file
|
||||
fs.writeFileSync(path.resolve(attachmentPath,"meta.json"),JSON.stringify({
|
||||
modified: $tw.utils.stringifyDate(new Date()),
|
||||
contentHash: contentHash,
|
||||
filename: dataFilename,
|
||||
type: options.type
|
||||
},null,4));
|
||||
return contentHash;
|
||||
};
|
||||
|
||||
/*
|
||||
Adopts an attachment file into the store
|
||||
*/
|
||||
AttachmentStore.prototype.adoptAttachment = function(incomingFilepath,type,hash) {
|
||||
const path = require("path"),
|
||||
fs = require("fs");
|
||||
// Choose the best file extension for the attachment given its type
|
||||
const contentTypeInfo = $tw.config.contentTypeInfo[type] || $tw.config.contentTypeInfo["application/octet-stream"];
|
||||
// Creat the attachment directory
|
||||
const attachmentPath = path.resolve(this.storePath,"files",hash);
|
||||
$tw.utils.createDirectory(attachmentPath);
|
||||
// Rename the data file
|
||||
const dataFilename = "data" + contentTypeInfo.extension,
|
||||
dataFilepath = path.resolve(attachmentPath,dataFilename);
|
||||
fs.renameSync(incomingFilepath,dataFilepath);
|
||||
// Save the meta.json file
|
||||
fs.writeFileSync(path.resolve(attachmentPath,"meta.json"),JSON.stringify({
|
||||
modified: $tw.utils.stringifyDate(new Date()),
|
||||
contentHash: hash,
|
||||
filename: dataFilename,
|
||||
type: type
|
||||
},null,4));
|
||||
return hash;
|
||||
};
|
||||
|
||||
/*
|
||||
Get an attachment ready to stream. Returns null if there is an error or:
|
||||
stream: filestream of file
|
||||
type: type of file
|
||||
*/
|
||||
AttachmentStore.prototype.getAttachmentStream = function(attachment_name) {
|
||||
const path = require("path"),
|
||||
fs = require("fs");
|
||||
// Check the attachment name
|
||||
if(this.isValidAttachmentName(attachment_name)) {
|
||||
// Construct the path to the attachment directory
|
||||
const attachmentPath = path.resolve(this.storePath,"files",attachment_name);
|
||||
// Read the meta.json file
|
||||
const metaJsonPath = path.resolve(attachmentPath,"meta.json");
|
||||
if(fs.existsSync(metaJsonPath) && fs.statSync(metaJsonPath).isFile()) {
|
||||
const meta = $tw.utils.parseJSONSafe(fs.readFileSync(metaJsonPath,"utf8"),function() {return null;});
|
||||
if(meta) {
|
||||
const dataFilepath = path.resolve(attachmentPath,meta.filename);
|
||||
// Check if the data file exists
|
||||
if(fs.existsSync(dataFilepath) && fs.statSync(dataFilepath).isFile()) {
|
||||
// Stream the file
|
||||
return {
|
||||
stream: fs.createReadStream(dataFilepath),
|
||||
type: meta.type
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// An error occured
|
||||
return null;
|
||||
};
|
||||
|
||||
exports.AttachmentStore = AttachmentStore;
|
||||
|
||||
})();
|
|
@ -0,0 +1,139 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/store/sql-engine.js
|
||||
type: application/javascript
|
||||
module-type: library
|
||||
|
||||
Low level functions to work with the SQLite engine, either via better-sqlite3 or node-sqlite3-wasm.
|
||||
|
||||
This class is intended to encapsulate all engine-specific logic.
|
||||
|
||||
\*/
|
||||
|
||||
(function() {
|
||||
|
||||
/*
|
||||
Create a database engine. Options include:
|
||||
|
||||
databasePath - path to the database file (can be ":memory:" or missing to get a temporary database)
|
||||
engine - wasm | better
|
||||
*/
|
||||
function SqlEngine(options) {
|
||||
options = options || {};
|
||||
// Initialise transaction mechanism
|
||||
this.transactionDepth = 0;
|
||||
// Initialise the statement cache
|
||||
this.statements = Object.create(null); // Hashmap by SQL text of statement objects
|
||||
// Choose engine
|
||||
this.engine = options.engine || "better"; // wasm | better
|
||||
// Create the database file directories if needed
|
||||
if(options.databasePath) {
|
||||
$tw.utils.createFileDirectories(options.databasePath);
|
||||
}
|
||||
// Create the database
|
||||
const databasePath = options.databasePath || ":memory:";
|
||||
let Database;
|
||||
switch(this.engine) {
|
||||
case "wasm":
|
||||
({ Database } = require("node-sqlite3-wasm"));
|
||||
break;
|
||||
case "better":
|
||||
Database = require("better-sqlite3");
|
||||
break;
|
||||
}
|
||||
this.db = new Database(databasePath,{
|
||||
verbose: undefined && console.log
|
||||
});
|
||||
// Turn on WAL mode for better-sqlite3
|
||||
if(this.engine === "better") {
|
||||
// See https://github.com/WiseLibs/better-sqlite3/blob/master/docs/performance.md
|
||||
this.db.pragma("journal_mode = WAL");
|
||||
}
|
||||
}
|
||||
|
||||
SqlEngine.prototype.close = function() {
|
||||
for(const sql in this.statements) {
|
||||
if(this.statements[sql].finalize) {
|
||||
this.statements[sql].finalize();
|
||||
}
|
||||
}
|
||||
this.statements = Object.create(null);
|
||||
this.db.close();
|
||||
this.db = undefined;
|
||||
};
|
||||
|
||||
SqlEngine.prototype.normaliseParams = function(params) {
|
||||
params = params || {};
|
||||
const result = Object.create(null);
|
||||
for(const paramName in params) {
|
||||
if(this.engine !== "wasm" && paramName.startsWith("$")) {
|
||||
result[paramName.slice(1)] = params[paramName];
|
||||
} else {
|
||||
result[paramName] = params[paramName];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
SqlEngine.prototype.prepareStatement = function(sql) {
|
||||
if(!(sql in this.statements)) {
|
||||
this.statements[sql] = this.db.prepare(sql);
|
||||
}
|
||||
return this.statements[sql];
|
||||
};
|
||||
|
||||
SqlEngine.prototype.runStatement = function(sql,params) {
|
||||
params = this.normaliseParams(params);
|
||||
const statement = this.prepareStatement(sql);
|
||||
return statement.run(params);
|
||||
};
|
||||
|
||||
SqlEngine.prototype.runStatementGet = function(sql,params) {
|
||||
params = this.normaliseParams(params);
|
||||
const statement = this.prepareStatement(sql);
|
||||
return statement.get(params);
|
||||
};
|
||||
|
||||
SqlEngine.prototype.runStatementGetAll = function(sql,params) {
|
||||
params = this.normaliseParams(params);
|
||||
const statement = this.prepareStatement(sql);
|
||||
return statement.all(params);
|
||||
};
|
||||
|
||||
SqlEngine.prototype.runStatements = function(sqlArray) {
|
||||
for(const sql of sqlArray) {
|
||||
this.runStatement(sql);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Execute the given function in a transaction, committing if successful but rolling back if an error occurs. Returns whatever the given function returns.
|
||||
|
||||
Calls to this function can be safely nested, but only the topmost call will actually take place in a transaction.
|
||||
|
||||
TODO: better-sqlite3 provides its own transaction method which we should be using if available
|
||||
*/
|
||||
SqlEngine.prototype.transaction = function(fn) {
|
||||
const alreadyInTransaction = this.transactionDepth > 0;
|
||||
this.transactionDepth++;
|
||||
try {
|
||||
if(alreadyInTransaction) {
|
||||
return fn();
|
||||
} else {
|
||||
this.runStatement(`BEGIN TRANSACTION`);
|
||||
try {
|
||||
var result = fn();
|
||||
this.runStatement(`COMMIT TRANSACTION`);
|
||||
} catch(e) {
|
||||
this.runStatement(`ROLLBACK TRANSACTION`);
|
||||
throw(e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} finally {
|
||||
this.transactionDepth--;
|
||||
}
|
||||
};
|
||||
|
||||
exports.SqlEngine = SqlEngine;
|
||||
|
||||
})();
|
|
@ -0,0 +1,544 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-database.js
|
||||
type: application/javascript
|
||||
module-type: library
|
||||
|
||||
Low level SQL functions to store and retrieve tiddlers in a SQLite database.
|
||||
|
||||
This class is intended to encapsulate all the SQL queries used to access the database.
|
||||
Validation is for the most part left to the caller
|
||||
|
||||
\*/
|
||||
|
||||
(function() {
|
||||
|
||||
/*
|
||||
Create a tiddler store. Options include:
|
||||
|
||||
databasePath - path to the database file (can be ":memory:" to get a temporary database)
|
||||
engine - wasm | better
|
||||
*/
|
||||
function SqlTiddlerDatabase(options) {
|
||||
options = options || {};
|
||||
const SqlEngine = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-engine.js").SqlEngine;
|
||||
this.engine = new SqlEngine({
|
||||
databasePath: options.databasePath,
|
||||
engine: options.engine
|
||||
});
|
||||
}
|
||||
|
||||
SqlTiddlerDatabase.prototype.close = function() {
|
||||
this.engine.close();
|
||||
};
|
||||
|
||||
|
||||
SqlTiddlerDatabase.prototype.transaction = function(fn) {
|
||||
return this.engine.transaction(fn);
|
||||
};
|
||||
|
||||
SqlTiddlerDatabase.prototype.createTables = function() {
|
||||
this.engine.runStatements([`
|
||||
-- Bags have names and access control settings
|
||||
CREATE TABLE IF NOT EXISTS bags (
|
||||
bag_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bag_name TEXT UNIQUE NOT NULL,
|
||||
accesscontrol TEXT NOT NULL,
|
||||
description TEXT NOT NULL
|
||||
)
|
||||
`,`
|
||||
-- Recipes have names...
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
recipe_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
recipe_name TEXT UNIQUE NOT NULL,
|
||||
description TEXT NOT NULL
|
||||
)
|
||||
`,`
|
||||
-- ...and recipes also have an ordered list of bags
|
||||
CREATE TABLE IF NOT EXISTS recipe_bags (
|
||||
recipe_id INTEGER NOT NULL,
|
||||
bag_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
FOREIGN KEY (recipe_id) REFERENCES recipes(recipe_id) ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
FOREIGN KEY (bag_id) REFERENCES bags(bag_id) ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
UNIQUE (recipe_id, bag_id)
|
||||
)
|
||||
`,`
|
||||
-- Tiddlers are contained in bags and have titles
|
||||
CREATE TABLE IF NOT EXISTS tiddlers (
|
||||
tiddler_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bag_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
is_deleted BOOLEAN NOT NULL,
|
||||
attachment_blob TEXT, -- null or the name of an attachment blob
|
||||
FOREIGN KEY (bag_id) REFERENCES bags(bag_id) ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
UNIQUE (bag_id, title)
|
||||
)
|
||||
`,`
|
||||
-- Tiddlers also have unordered lists of fields, each of which has a name and associated value
|
||||
CREATE TABLE IF NOT EXISTS fields (
|
||||
tiddler_id INTEGER,
|
||||
field_name TEXT NOT NULL,
|
||||
field_value TEXT NOT NULL,
|
||||
FOREIGN KEY (tiddler_id) REFERENCES tiddlers(tiddler_id) ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
UNIQUE (tiddler_id, field_name)
|
||||
)
|
||||
`]);
|
||||
};
|
||||
|
||||
SqlTiddlerDatabase.prototype.listBags = function() {
|
||||
const rows = this.engine.runStatementGetAll(`
|
||||
SELECT bag_name, bag_id, accesscontrol, description
|
||||
FROM bags
|
||||
ORDER BY bag_name
|
||||
`);
|
||||
return rows;
|
||||
};
|
||||
|
||||
/*
|
||||
Create or update a bag
|
||||
Returns the bag_id of the bag
|
||||
*/
|
||||
SqlTiddlerDatabase.prototype.createBag = function(bag_name,description,accesscontrol) {
|
||||
accesscontrol = accesscontrol || "";
|
||||
// Run the queries
|
||||
this.engine.runStatement(`
|
||||
INSERT OR IGNORE INTO bags (bag_name, accesscontrol, description)
|
||||
VALUES ($bag_name, '', '')
|
||||
`,{
|
||||
$bag_name: bag_name
|
||||
});
|
||||
const updateBags = this.engine.runStatement(`
|
||||
UPDATE bags
|
||||
SET accesscontrol = $accesscontrol,
|
||||
description = $description
|
||||
WHERE bag_name = $bag_name
|
||||
`,{
|
||||
$bag_name: bag_name,
|
||||
$accesscontrol: accesscontrol,
|
||||
$description: description
|
||||
});
|
||||
return updateBags.lastInsertRowid;
|
||||
};
|
||||
|
||||
/*
|
||||
Returns array of {recipe_name:,recipe_id:,description:,bag_names: []}
|
||||
*/
|
||||
SqlTiddlerDatabase.prototype.listRecipes = function() {
|
||||
const rows = this.engine.runStatementGetAll(`
|
||||
SELECT r.recipe_name, r.recipe_id, r.description, b.bag_name, rb.position
|
||||
FROM recipes AS r
|
||||
JOIN recipe_bags AS rb ON rb.recipe_id = r.recipe_id
|
||||
JOIN bags AS b ON rb.bag_id = b.bag_id
|
||||
ORDER BY r.recipe_name, rb.position
|
||||
`);
|
||||
const results = [];
|
||||
let currentRecipeName = null, currentRecipeIndex = -1;
|
||||
for(const row of rows) {
|
||||
if(row.recipe_name !== currentRecipeName) {
|
||||
currentRecipeName = row.recipe_name;
|
||||
currentRecipeIndex += 1;
|
||||
results.push({
|
||||
recipe_name: row.recipe_name,
|
||||
recipe_id: row.recipe_id,
|
||||
description: row.description,
|
||||
bag_names: []
|
||||
});
|
||||
}
|
||||
results[currentRecipeIndex].bag_names.push(row.bag_name);
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
/*
|
||||
Create or update a recipe
|
||||
Returns the recipe_id of the recipe
|
||||
*/
|
||||
SqlTiddlerDatabase.prototype.createRecipe = function(recipe_name,bag_names,description) {
|
||||
// Run the queries
|
||||
this.engine.runStatement(`
|
||||
-- Delete existing recipe_bags entries for this recipe
|
||||
DELETE FROM recipe_bags WHERE recipe_id = (SELECT recipe_id FROM recipes WHERE recipe_name = $recipe_name)
|
||||
`,{
|
||||
$recipe_name: recipe_name
|
||||
});
|
||||
const updateRecipes = this.engine.runStatement(`
|
||||
-- Create the entry in the recipes table if required
|
||||
INSERT OR REPLACE INTO recipes (recipe_name, description)
|
||||
VALUES ($recipe_name, $description)
|
||||
`,{
|
||||
$recipe_name: recipe_name,
|
||||
$description: description
|
||||
});
|
||||
this.engine.runStatement(`
|
||||
INSERT INTO recipe_bags (recipe_id, bag_id, position)
|
||||
SELECT r.recipe_id, b.bag_id, j.key as position
|
||||
FROM recipes r
|
||||
JOIN bags b
|
||||
INNER JOIN json_each($bag_names) AS j ON j.value = b.bag_name
|
||||
WHERE r.recipe_name = $recipe_name
|
||||
`,{
|
||||
$recipe_name: recipe_name,
|
||||
$bag_names: JSON.stringify(bag_names)
|
||||
});
|
||||
return updateRecipes.lastInsertRowid;
|
||||
};
|
||||
|
||||
/*
|
||||
Returns {tiddler_id:}
|
||||
*/
|
||||
SqlTiddlerDatabase.prototype.saveBagTiddler = function(tiddlerFields,bag_name,attachment_blob) {
|
||||
attachment_blob = attachment_blob || null;
|
||||
// Update the tiddlers table
|
||||
var info = this.engine.runStatement(`
|
||||
INSERT OR REPLACE INTO tiddlers (bag_id, title, is_deleted, attachment_blob)
|
||||
VALUES (
|
||||
(SELECT bag_id FROM bags WHERE bag_name = $bag_name),
|
||||
$title,
|
||||
FALSE,
|
||||
$attachment_blob
|
||||
)
|
||||
`,{
|
||||
$title: tiddlerFields.title,
|
||||
$attachment_blob: attachment_blob,
|
||||
$bag_name: bag_name
|
||||
});
|
||||
// Update the fields table
|
||||
this.engine.runStatement(`
|
||||
INSERT OR REPLACE INTO fields (tiddler_id, field_name, field_value)
|
||||
SELECT
|
||||
t.tiddler_id,
|
||||
json_each.key AS field_name,
|
||||
json_each.value AS field_value
|
||||
FROM (
|
||||
SELECT tiddler_id
|
||||
FROM tiddlers
|
||||
WHERE bag_id = (
|
||||
SELECT bag_id
|
||||
FROM bags
|
||||
WHERE bag_name = $bag_name
|
||||
) AND title = $title
|
||||
) AS t
|
||||
JOIN json_each($field_values) AS json_each
|
||||
`,{
|
||||
$title: tiddlerFields.title,
|
||||
$bag_name: bag_name,
|
||||
$field_values: JSON.stringify(Object.assign({},tiddlerFields,{title: undefined}))
|
||||
});
|
||||
return {
|
||||
tiddler_id: info.lastInsertRowid
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Returns {tiddler_id:,bag_name:} or null if the recipe is empty
|
||||
*/
|
||||
SqlTiddlerDatabase.prototype.saveRecipeTiddler = function(tiddlerFields,recipe_name,attachment_blob) {
|
||||
// Find the topmost bag in the recipe
|
||||
var row = this.engine.runStatementGet(`
|
||||
SELECT b.bag_name
|
||||
FROM bags AS b
|
||||
JOIN (
|
||||
SELECT rb.bag_id
|
||||
FROM recipe_bags AS rb
|
||||
WHERE rb.recipe_id = (
|
||||
SELECT recipe_id
|
||||
FROM recipes
|
||||
WHERE recipe_name = $recipe_name
|
||||
)
|
||||
ORDER BY rb.position DESC
|
||||
LIMIT 1
|
||||
) AS selected_bag
|
||||
ON b.bag_id = selected_bag.bag_id
|
||||
`,{
|
||||
$recipe_name: recipe_name
|
||||
});
|
||||
if(!row) {
|
||||
return null;
|
||||
}
|
||||
// Save the tiddler to the topmost bag
|
||||
var info = this.saveBagTiddler(tiddlerFields,row.bag_name,attachment_blob);
|
||||
return {
|
||||
tiddler_id: info.tiddler_id,
|
||||
bag_name: row.bag_name
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
Returns {tiddler_id:} of the delete marker
|
||||
*/
|
||||
SqlTiddlerDatabase.prototype.deleteTiddler = function(title,bag_name) {
|
||||
// Delete the fields of this tiddler
|
||||
this.engine.runStatement(`
|
||||
DELETE FROM fields
|
||||
WHERE tiddler_id IN (
|
||||
SELECT t.tiddler_id
|
||||
FROM tiddlers AS t
|
||||
INNER JOIN bags AS b ON t.bag_id = b.bag_id
|
||||
WHERE b.bag_name = $bag_name AND t.title = $title
|
||||
)
|
||||
`,{
|
||||
$title: title,
|
||||
$bag_name: bag_name
|
||||
});
|
||||
// Mark the tiddler itself as deleted
|
||||
const rowDeleteMarker = this.engine.runStatement(`
|
||||
INSERT OR REPLACE INTO tiddlers (bag_id, title, is_deleted, attachment_blob)
|
||||
VALUES (
|
||||
(SELECT bag_id FROM bags WHERE bag_name = $bag_name),
|
||||
$title,
|
||||
TRUE,
|
||||
NULL
|
||||
)
|
||||
`,{
|
||||
$title: title,
|
||||
$bag_name: bag_name
|
||||
});
|
||||
return {tiddler_id: rowDeleteMarker.lastInsertRowid};
|
||||
};
|
||||
|
||||
/*
|
||||
returns {tiddler_id:,tiddler:,attachment_blob:}
|
||||
*/
|
||||
SqlTiddlerDatabase.prototype.getBagTiddler = function(title,bag_name) {
|
||||
const rowTiddler = this.engine.runStatementGet(`
|
||||
SELECT t.tiddler_id, t.attachment_blob
|
||||
FROM bags AS b
|
||||
INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id
|
||||
WHERE t.title = $title AND b.bag_name = $bag_name AND t.is_deleted = FALSE
|
||||
`,{
|
||||
$title: title,
|
||||
$bag_name: bag_name
|
||||
});
|
||||
if(!rowTiddler) {
|
||||
return null;
|
||||
}
|
||||
const rows = this.engine.runStatementGetAll(`
|
||||
SELECT field_name, field_value, tiddler_id
|
||||
FROM fields
|
||||
WHERE tiddler_id = $tiddler_id
|
||||
`,{
|
||||
$tiddler_id: rowTiddler.tiddler_id
|
||||
});
|
||||
if(rows.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
return {
|
||||
tiddler_id: rows[0].tiddler_id,
|
||||
attachment_blob: rowTiddler.attachment_blob,
|
||||
tiddler: rows.reduce((accumulator,value) => {
|
||||
accumulator[value["field_name"]] = value.field_value;
|
||||
return accumulator;
|
||||
},{title: title})
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Returns {bag_name:, tiddler: {fields}, tiddler_id:, attachment_blob:}
|
||||
*/
|
||||
SqlTiddlerDatabase.prototype.getRecipeTiddler = function(title,recipe_name) {
|
||||
const rowTiddlerId = this.engine.runStatementGet(`
|
||||
SELECT t.tiddler_id, t.attachment_blob, b.bag_name
|
||||
FROM bags AS b
|
||||
INNER JOIN recipe_bags AS rb ON b.bag_id = rb.bag_id
|
||||
INNER JOIN recipes AS r ON rb.recipe_id = r.recipe_id
|
||||
INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id
|
||||
WHERE r.recipe_name = $recipe_name
|
||||
AND t.title = $title
|
||||
AND t.is_deleted = FALSE
|
||||
ORDER BY rb.position DESC
|
||||
LIMIT 1
|
||||
`,{
|
||||
$title: title,
|
||||
$recipe_name: recipe_name
|
||||
});
|
||||
if(!rowTiddlerId) {
|
||||
return null;
|
||||
}
|
||||
// Get the fields
|
||||
const rows = this.engine.runStatementGetAll(`
|
||||
SELECT field_name, field_value
|
||||
FROM fields
|
||||
WHERE tiddler_id = $tiddler_id
|
||||
`,{
|
||||
$tiddler_id: rowTiddlerId.tiddler_id
|
||||
});
|
||||
return {
|
||||
bag_name: rowTiddlerId.bag_name,
|
||||
tiddler_id: rowTiddlerId.tiddler_id,
|
||||
attachment_blob: rowTiddlerId.attachment_blob,
|
||||
tiddler: rows.reduce((accumulator,value) => {
|
||||
accumulator[value["field_name"]] = value.field_value;
|
||||
return accumulator;
|
||||
},{title: title})
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
Get the titles of the tiddlers in a bag. Returns an empty array for bags that do not exist
|
||||
*/
|
||||
SqlTiddlerDatabase.prototype.getBagTiddlers = function(bag_name) {
|
||||
const rows = this.engine.runStatementGetAll(`
|
||||
SELECT DISTINCT title, tiddler_id
|
||||
FROM tiddlers
|
||||
WHERE bag_id IN (
|
||||
SELECT bag_id
|
||||
FROM bags
|
||||
WHERE bag_name = $bag_name
|
||||
)
|
||||
AND tiddlers.is_deleted = FALSE
|
||||
ORDER BY title ASC
|
||||
`,{
|
||||
$bag_name: bag_name
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
||||
/*
|
||||
Get the tiddler_id of the newest tiddler in a bag. Returns null for bags that do not exist
|
||||
*/
|
||||
SqlTiddlerDatabase.prototype.getBagLastTiddlerId = function(bag_name) {
|
||||
const row = this.engine.runStatementGet(`
|
||||
SELECT tiddler_id
|
||||
FROM tiddlers
|
||||
WHERE bag_id IN (
|
||||
SELECT bag_id
|
||||
FROM bags
|
||||
WHERE bag_name = $bag_name
|
||||
)
|
||||
ORDER BY tiddler_id DESC
|
||||
LIMIT 1
|
||||
`,{
|
||||
$bag_name: bag_name
|
||||
});
|
||||
if(row) {
|
||||
return row.tiddler_id;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Get the metadata of the tiddlers in a recipe as an array [{title:,tiddler_id:,bag_name:,is_deleted:}],
|
||||
sorted in ascending order of tiddler_id.
|
||||
|
||||
Options include:
|
||||
|
||||
limit: optional maximum number of results to return
|
||||
last_known_tiddler_id: tiddler_id of the last known update. Only returns tiddlers that have been created, modified or deleted since
|
||||
include_deleted: boolean, defaults to false
|
||||
|
||||
Returns null for recipes that do not exist
|
||||
*/
|
||||
SqlTiddlerDatabase.prototype.getRecipeTiddlers = function(recipe_name,options) {
|
||||
options = options || {};
|
||||
// Get the recipe ID
|
||||
const rowsCheckRecipe = this.engine.runStatementGet(`
|
||||
SELECT recipe_id FROM recipes WHERE recipes.recipe_name = $recipe_name
|
||||
`,{
|
||||
$recipe_name: recipe_name
|
||||
});
|
||||
if(!rowsCheckRecipe) {
|
||||
return null;
|
||||
}
|
||||
const recipe_id = rowsCheckRecipe.recipe_id;
|
||||
// Compose the query to get the tiddlers
|
||||
const params = {
|
||||
$recipe_id: recipe_id
|
||||
}
|
||||
if(options.limit) {
|
||||
params.$limit = options.limit.toString();
|
||||
}
|
||||
if(options.last_known_tiddler_id) {
|
||||
params.$last_known_tiddler_id = options.last_known_tiddler_id;
|
||||
}
|
||||
const rows = this.engine.runStatementGetAll(`
|
||||
SELECT title, tiddler_id, is_deleted, bag_name
|
||||
FROM (
|
||||
SELECT t.title, t.tiddler_id, t.is_deleted, b.bag_name, MAX(rb.position) AS position
|
||||
FROM bags AS b
|
||||
INNER JOIN recipe_bags AS rb ON b.bag_id = rb.bag_id
|
||||
INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id
|
||||
WHERE rb.recipe_id = $recipe_id
|
||||
${options.include_deleted ? "" : "AND t.is_deleted = FALSE"}
|
||||
${options.last_known_tiddler_id ? "AND tiddler_id > $last_known_tiddler_id" : ""}
|
||||
GROUP BY t.title
|
||||
ORDER BY t.title, tiddler_id DESC
|
||||
${options.limit ? "LIMIT $limit" : ""}
|
||||
)
|
||||
`,params);
|
||||
return rows;
|
||||
};
|
||||
|
||||
/*
|
||||
Get the tiddler_id of the newest tiddler in a recipe. Returns null for recipes that do not exist
|
||||
*/
|
||||
SqlTiddlerDatabase.prototype.getRecipeLastTiddlerId = function(recipe_name) {
|
||||
const row = this.engine.runStatementGet(`
|
||||
SELECT t.title, t.tiddler_id, b.bag_name, MAX(rb.position) AS position
|
||||
FROM bags AS b
|
||||
INNER JOIN recipe_bags AS rb ON b.bag_id = rb.bag_id
|
||||
INNER JOIN recipes AS r ON rb.recipe_id = r.recipe_id
|
||||
INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id
|
||||
WHERE r.recipe_name = $recipe_name
|
||||
GROUP BY t.title
|
||||
ORDER BY t.tiddler_id DESC
|
||||
LIMIT 1
|
||||
`,{
|
||||
$recipe_name: recipe_name
|
||||
});
|
||||
if(row) {
|
||||
return row.tiddler_id;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
SqlTiddlerDatabase.prototype.deleteAllTiddlersInBag = function(bag_name) {
|
||||
// Delete the fields
|
||||
this.engine.runStatement(`
|
||||
DELETE FROM fields
|
||||
WHERE tiddler_id IN (
|
||||
SELECT tiddler_id
|
||||
FROM tiddlers
|
||||
WHERE bag_id = (SELECT bag_id FROM bags WHERE bag_name = $bag_name)
|
||||
AND is_deleted = FALSE
|
||||
)
|
||||
`,{
|
||||
$bag_name: bag_name
|
||||
});
|
||||
// Mark the tiddlers as deleted
|
||||
this.engine.runStatement(`
|
||||
UPDATE tiddlers
|
||||
SET is_deleted = TRUE
|
||||
WHERE bag_id = (SELECT bag_id FROM bags WHERE bag_name = $bag_name)
|
||||
AND is_deleted = FALSE
|
||||
`,{
|
||||
$bag_name: bag_name
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
Get the names of the bags in a recipe. Returns an empty array for recipes that do not exist
|
||||
*/
|
||||
SqlTiddlerDatabase.prototype.getRecipeBags = function(recipe_name) {
|
||||
const rows = this.engine.runStatementGetAll(`
|
||||
SELECT bags.bag_name
|
||||
FROM bags
|
||||
JOIN (
|
||||
SELECT rb.bag_id, rb.position as position
|
||||
FROM recipe_bags AS rb
|
||||
JOIN recipes AS r ON rb.recipe_id = r.recipe_id
|
||||
WHERE r.recipe_name = $recipe_name
|
||||
ORDER BY rb.position
|
||||
) AS bag_priority ON bags.bag_id = bag_priority.bag_id
|
||||
ORDER BY position
|
||||
`,{
|
||||
$recipe_name: recipe_name
|
||||
});
|
||||
return rows.map(value => value.bag_name);
|
||||
};
|
||||
|
||||
exports.SqlTiddlerDatabase = SqlTiddlerDatabase;
|
||||
|
||||
})();
|
|
@ -0,0 +1,409 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-store.js
|
||||
type: application/javascript
|
||||
module-type: library
|
||||
|
||||
Higher level functions to perform basic tiddler operations with a sqlite3 database.
|
||||
|
||||
This class is largely a wrapper for the sql-tiddler-database.js class, adding the following functionality:
|
||||
|
||||
* Validating requests (eg bag and recipe name constraints)
|
||||
* Synchronising bag and recipe names to the admin wiki
|
||||
* Handling large tiddlers as attachments
|
||||
|
||||
\*/
|
||||
|
||||
(function() {
|
||||
|
||||
/*
|
||||
Create a tiddler store. Options include:
|
||||
|
||||
databasePath - path to the database file (can be ":memory:" to get a temporary database)
|
||||
adminWiki - reference to $tw.Wiki object used for configuration
|
||||
attachmentStore - reference to associated attachment store
|
||||
engine - wasm | better
|
||||
*/
|
||||
function SqlTiddlerStore(options) {
|
||||
options = options || {};
|
||||
this.attachmentStore = options.attachmentStore;
|
||||
this.adminWiki = options.adminWiki || $tw.wiki;
|
||||
this.eventListeners = {}; // Hashmap by type of array of event listener functions
|
||||
this.eventOutstanding = {}; // Hashmap by type of boolean true of outstanding events
|
||||
// Create the database
|
||||
this.databasePath = options.databasePath || ":memory:";
|
||||
var SqlTiddlerDatabase = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-database.js").SqlTiddlerDatabase;
|
||||
this.sqlTiddlerDatabase = new SqlTiddlerDatabase({
|
||||
databasePath: this.databasePath,
|
||||
engine: options.engine
|
||||
});
|
||||
this.sqlTiddlerDatabase.createTables();
|
||||
}
|
||||
|
||||
SqlTiddlerStore.prototype.addEventListener = function(type,listener) {
|
||||
this.eventListeners[type] = this.eventListeners[type] || [];
|
||||
this.eventListeners[type].push(listener);
|
||||
};
|
||||
|
||||
SqlTiddlerStore.prototype.removeEventListener = function(type,listener) {
|
||||
const listeners = this.eventListeners[type];
|
||||
if(listeners) {
|
||||
var p = listeners.indexOf(listener);
|
||||
if(p !== -1) {
|
||||
listeners.splice(p,1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
SqlTiddlerStore.prototype.dispatchEvent = function(type /*, args */) {
|
||||
const self = this;
|
||||
if(!this.eventOutstanding[type]) {
|
||||
$tw.utils.nextTick(function() {
|
||||
self.eventOutstanding[type] = false;
|
||||
const args = Array.prototype.slice.call(arguments,1),
|
||||
listeners = self.eventListeners[type];
|
||||
if(listeners) {
|
||||
for(var p=0; p<listeners.length; p++) {
|
||||
var listener = listeners[p];
|
||||
listener.apply(listener,args);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.eventOutstanding[type] = true;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Returns null if a bag/recipe name is valid, or a string error message if not
|
||||
*/
|
||||
SqlTiddlerStore.prototype.validateItemName = function(name,allowPrivilegedCharacters) {
|
||||
if(typeof name !== "string") {
|
||||
return "Not a valid string";
|
||||
}
|
||||
if(name.length > 256) {
|
||||
return "Too long";
|
||||
}
|
||||
// Removed ~ from this list temporarily
|
||||
if(allowPrivilegedCharacters) {
|
||||
if(!(/^[^\s\u00A0\x00-\x1F\x7F`!@#%^&*()+={}\[\];\'\"<>,\\\?]+$/g.test(name))) {
|
||||
return "Invalid character(s)";
|
||||
}
|
||||
} else {
|
||||
if(!(/^[^\s\u00A0\x00-\x1F\x7F`!@#$%^&*()+={}\[\];:\'\"<>.,\/\\\?]+$/g.test(name))) {
|
||||
return "Invalid character(s)";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/*
|
||||
Returns null if the argument is an array of valid bag/recipe names, or a string error message if not
|
||||
*/
|
||||
SqlTiddlerStore.prototype.validateItemNames = function(names,allowPrivilegedCharacters) {
|
||||
if(!$tw.utils.isArray(names)) {
|
||||
return "Not a valid array";
|
||||
}
|
||||
var errors = [];
|
||||
for(const name of names) {
|
||||
const result = this.validateItemName(name,allowPrivilegedCharacters);
|
||||
if(result && errors.indexOf(result) === -1) {
|
||||
errors.push(result);
|
||||
}
|
||||
}
|
||||
if(errors.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
return errors.join("\n");
|
||||
}
|
||||
};
|
||||
|
||||
SqlTiddlerStore.prototype.close = function() {
|
||||
this.sqlTiddlerDatabase.close();
|
||||
this.sqlTiddlerDatabase = undefined;
|
||||
};
|
||||
|
||||
/*
|
||||
Given tiddler fields, tiddler_id and a bag_name, return the tiddler fields after the following process:
|
||||
- Apply the tiddler_id as the revision field
|
||||
- Apply the bag_name as the bag field
|
||||
*/
|
||||
SqlTiddlerStore.prototype.processOutgoingTiddler = function(tiddlerFields,tiddler_id,bag_name,attachment_blob) {
|
||||
if(attachment_blob !== null) {
|
||||
return $tw.utils.extend(
|
||||
{},
|
||||
tiddlerFields,
|
||||
{
|
||||
text: undefined,
|
||||
_canonical_uri: `/bags/${encodeURIComponent(bag_name)}/tiddlers/${encodeURIComponent(tiddlerFields.title)}/blob`
|
||||
}
|
||||
);
|
||||
} else {
|
||||
return tiddlerFields;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
*/
|
||||
SqlTiddlerStore.prototype.processIncomingTiddler = function(tiddlerFields) {
|
||||
let attachmentSizeLimit = $tw.utils.parseNumber(this.adminWiki.getTiddlerText("$:/config/MultiWikiServer/AttachmentSizeLimit"));
|
||||
if(attachmentSizeLimit < 100 * 1024) {
|
||||
attachmentSizeLimit = 100 * 1024;
|
||||
}
|
||||
const contentTypeInfo = $tw.config.contentTypeInfo[tiddlerFields.type || "text/vnd.tiddlywiki"],
|
||||
isBinary = !!contentTypeInfo && contentTypeInfo.encoding === "base64";
|
||||
if(isBinary && tiddlerFields.text && tiddlerFields.text.length > attachmentSizeLimit) {
|
||||
const attachment_blob = this.attachmentStore.saveAttachment({
|
||||
text: tiddlerFields.text,
|
||||
type: tiddlerFields.type,
|
||||
reference: tiddlerFields.title
|
||||
});
|
||||
return {
|
||||
tiddlerFields: Object.assign({},tiddlerFields,{text: undefined}),
|
||||
attachment_blob: attachment_blob
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
tiddlerFields: tiddlerFields,
|
||||
attachment_blob: null
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
SqlTiddlerStore.prototype.saveTiddlersFromPath = function(tiddler_files_path,bag_name) {
|
||||
var self = this;
|
||||
this.sqlTiddlerDatabase.transaction(function() {
|
||||
// Clear out the bag
|
||||
self.deleteAllTiddlersInBag(bag_name);
|
||||
// Get the tiddlers
|
||||
var path = require("path");
|
||||
var tiddlersFromPath = $tw.loadTiddlersFromPath(path.resolve($tw.boot.corePath,$tw.config.editionsPath,tiddler_files_path));
|
||||
// Save the tiddlers
|
||||
for(const tiddlersFromFile of tiddlersFromPath) {
|
||||
for(const tiddler of tiddlersFromFile.tiddlers) {
|
||||
self.saveBagTiddler(tiddler,bag_name,null);
|
||||
}
|
||||
}
|
||||
});
|
||||
self.dispatchEvent("change");
|
||||
};
|
||||
|
||||
SqlTiddlerStore.prototype.listBags = function() {
|
||||
return this.sqlTiddlerDatabase.listBags();
|
||||
};
|
||||
|
||||
/*
|
||||
Options include:
|
||||
|
||||
allowPrivilegedCharacters - allows "$", ":" and "/" to appear in recipe name
|
||||
*/
|
||||
SqlTiddlerStore.prototype.createBag = function(bag_name,description,options) {
|
||||
options = options || {};
|
||||
var self = this;
|
||||
return this.sqlTiddlerDatabase.transaction(function() {
|
||||
const validationBagName = self.validateItemName(bag_name,options.allowPrivilegedCharacters);
|
||||
if(validationBagName) {
|
||||
return {message: validationBagName};
|
||||
}
|
||||
self.sqlTiddlerDatabase.createBag(bag_name,description);
|
||||
self.dispatchEvent("change");
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
SqlTiddlerStore.prototype.listRecipes = function() {
|
||||
return this.sqlTiddlerDatabase.listRecipes();
|
||||
};
|
||||
|
||||
/*
|
||||
Returns null on success, or {message:} on error
|
||||
|
||||
Options include:
|
||||
|
||||
allowPrivilegedCharacters - allows "$", ":" and "/" to appear in recipe name
|
||||
*/
|
||||
SqlTiddlerStore.prototype.createRecipe = function(recipe_name,bag_names,description,options) {
|
||||
bag_names = bag_names || [];
|
||||
description = description || "";
|
||||
options = options || {};
|
||||
const validationRecipeName = this.validateItemName(recipe_name,options.allowPrivilegedCharacters);
|
||||
if(validationRecipeName) {
|
||||
return {message: validationRecipeName};
|
||||
}
|
||||
if(bag_names.length === 0) {
|
||||
return {message: "Recipes must contain at least one bag"};
|
||||
}
|
||||
var self = this;
|
||||
return this.sqlTiddlerDatabase.transaction(function() {
|
||||
self.sqlTiddlerDatabase.createRecipe(recipe_name,bag_names,description);
|
||||
self.dispatchEvent("change");
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
Returns {tiddler_id:}
|
||||
*/
|
||||
SqlTiddlerStore.prototype.saveBagTiddler = function(incomingTiddlerFields,bag_name) {
|
||||
const {tiddlerFields, attachment_blob} = this.processIncomingTiddler(incomingTiddlerFields);
|
||||
const result = this.sqlTiddlerDatabase.saveBagTiddler(tiddlerFields,bag_name,attachment_blob);
|
||||
this.dispatchEvent("change");
|
||||
return result;
|
||||
};
|
||||
|
||||
/*
|
||||
Create a tiddler in a bag adopting the specified file as the attachment. The attachment file must be on the same disk as the attachment store
|
||||
Options include:
|
||||
|
||||
filepath - filepath to the attachment file
|
||||
hash - string hash of the attachment file
|
||||
type - content type of file as uploaded
|
||||
|
||||
Returns {tiddler_id:}
|
||||
*/
|
||||
SqlTiddlerStore.prototype.saveBagTiddlerWithAttachment = function(incomingTiddlerFields,bag_name,options) {
|
||||
const attachment_blob = this.attachmentStore.adoptAttachment(options.filepath,options.type,options.hash);
|
||||
if(attachment_blob) {
|
||||
const result = this.sqlTiddlerDatabase.saveBagTiddler(incomingTiddlerFields,bag_name,attachment_blob);
|
||||
this.dispatchEvent("change");
|
||||
return result;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Returns {tiddler_id:,bag_name:}
|
||||
*/
|
||||
SqlTiddlerStore.prototype.saveRecipeTiddler = function(incomingTiddlerFields,recipe_name) {
|
||||
const {tiddlerFields, attachment_blob} = this.processIncomingTiddler(incomingTiddlerFields);
|
||||
const result = this.sqlTiddlerDatabase.saveRecipeTiddler(tiddlerFields,recipe_name,attachment_blob);
|
||||
this.dispatchEvent("change");
|
||||
return result;
|
||||
};
|
||||
|
||||
SqlTiddlerStore.prototype.deleteTiddler = function(title,bag_name) {
|
||||
const result = this.sqlTiddlerDatabase.deleteTiddler(title,bag_name);
|
||||
this.dispatchEvent("change");
|
||||
return result;
|
||||
};
|
||||
|
||||
/*
|
||||
returns {tiddler_id:,tiddler:}
|
||||
*/
|
||||
SqlTiddlerStore.prototype.getBagTiddler = function(title,bag_name) {
|
||||
var tiddlerInfo = this.sqlTiddlerDatabase.getBagTiddler(title,bag_name);
|
||||
if(tiddlerInfo) {
|
||||
return Object.assign(
|
||||
{},
|
||||
tiddlerInfo,
|
||||
{
|
||||
tiddler: this.processOutgoingTiddler(tiddlerInfo.tiddler,tiddlerInfo.tiddler_id,bag_name,tiddlerInfo.attachment_blob)
|
||||
});
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Get an attachment ready to stream. Returns null if there is an error or:
|
||||
tiddler_id: revision of tiddler
|
||||
stream: stream of file
|
||||
type: type of file
|
||||
Returns {tiddler_id:,bag_name:}
|
||||
*/
|
||||
SqlTiddlerStore.prototype.getBagTiddlerStream = function(title,bag_name) {
|
||||
const tiddlerInfo = this.sqlTiddlerDatabase.getBagTiddler(title,bag_name);
|
||||
if(tiddlerInfo) {
|
||||
if(tiddlerInfo.attachment_blob) {
|
||||
return $tw.utils.extend(
|
||||
{},
|
||||
this.attachmentStore.getAttachmentStream(tiddlerInfo.attachment_blob),
|
||||
{
|
||||
tiddler_id: tiddlerInfo.tiddler_id
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const { Readable } = require('stream');
|
||||
const stream = new Readable();
|
||||
stream._read = function() {
|
||||
// Push data
|
||||
const type = tiddlerInfo.tiddler.type || "text/plain";
|
||||
stream.push(tiddlerInfo.tiddler.text || "",($tw.config.contentTypeInfo[type] ||{encoding: "utf8"}).encoding);
|
||||
// Push null to indicate the end of the stream
|
||||
stream.push(null);
|
||||
};
|
||||
return {
|
||||
tiddler_id: tiddlerInfo.tiddler_id,
|
||||
bag_name: bag_name,
|
||||
stream: stream,
|
||||
type: tiddlerInfo.tiddler.type || "text/plain"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Returns {bag_name:, tiddler: {fields}, tiddler_id:}
|
||||
*/
|
||||
SqlTiddlerStore.prototype.getRecipeTiddler = function(title,recipe_name) {
|
||||
var tiddlerInfo = this.sqlTiddlerDatabase.getRecipeTiddler(title,recipe_name);
|
||||
if(tiddlerInfo) {
|
||||
return Object.assign(
|
||||
{},
|
||||
tiddlerInfo,
|
||||
{
|
||||
tiddler: this.processOutgoingTiddler(tiddlerInfo.tiddler,tiddlerInfo.tiddler_id,tiddlerInfo.bag_name,tiddlerInfo.attachment_blob)
|
||||
});
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Get the titles of the tiddlers in a bag. Returns an empty array for bags that do not exist
|
||||
*/
|
||||
SqlTiddlerStore.prototype.getBagTiddlers = function(bag_name) {
|
||||
return this.sqlTiddlerDatabase.getBagTiddlers(bag_name);
|
||||
};
|
||||
|
||||
/*
|
||||
Get the tiddler_id of the newest tiddler in a bag. Returns null for bags that do not exist
|
||||
*/
|
||||
SqlTiddlerStore.prototype.getBagLastTiddlerId = function(bag_name) {
|
||||
return this.sqlTiddlerDatabase.getBagLastTiddlerId(bag_name);
|
||||
};
|
||||
|
||||
/*
|
||||
Get the titles of the tiddlers in a recipe as {title:,bag_name:}. Returns null for recipes that do not exist
|
||||
*/
|
||||
SqlTiddlerStore.prototype.getRecipeTiddlers = function(recipe_name,options) {
|
||||
return this.sqlTiddlerDatabase.getRecipeTiddlers(recipe_name,options);
|
||||
};
|
||||
|
||||
/*
|
||||
Get the tiddler_id of the newest tiddler in a recipe. Returns null for recipes that do not exist
|
||||
*/
|
||||
SqlTiddlerStore.prototype.getRecipeLastTiddlerId = function(recipe_name) {
|
||||
return this.sqlTiddlerDatabase.getRecipeLastTiddlerId(recipe_name);
|
||||
};
|
||||
|
||||
SqlTiddlerStore.prototype.deleteAllTiddlersInBag = function(bag_name) {
|
||||
var self = this;
|
||||
return this.sqlTiddlerDatabase.transaction(function() {
|
||||
const result = self.sqlTiddlerDatabase.deleteAllTiddlersInBag(bag_name);
|
||||
self.dispatchEvent("change");
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
Get the names of the bags in a recipe. Returns an empty array for recipes that do not exist
|
||||
*/
|
||||
SqlTiddlerStore.prototype.getRecipeBags = function(recipe_name) {
|
||||
return this.sqlTiddlerDatabase.getRecipeBags(recipe_name);
|
||||
};
|
||||
|
||||
exports.SqlTiddlerStore = SqlTiddlerStore;
|
||||
|
||||
})();
|
|
@ -0,0 +1,112 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/store/tests-sql-tiddler-database.js
|
||||
type: application/javascript
|
||||
tags: [[$:/tags/test-spec]]
|
||||
|
||||
Tests the SQL tiddler database layer
|
||||
|
||||
\*/
|
||||
(function(){
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
if($tw.node) {
|
||||
|
||||
describe("SQL tiddler database with node-sqlite3-wasm", function() {
|
||||
runSqlDatabaseTests("wasm");
|
||||
});
|
||||
|
||||
describe("SQL tiddler database with better-sqlite3", function() {
|
||||
runSqlDatabaseTests("better");
|
||||
});
|
||||
|
||||
function runSqlDatabaseTests(engine) {
|
||||
// Create and initialise the tiddler store
|
||||
var SqlTiddlerDatabase = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-database.js").SqlTiddlerDatabase;
|
||||
const sqlTiddlerDatabase = new SqlTiddlerDatabase({
|
||||
engine: engine
|
||||
});
|
||||
sqlTiddlerDatabase.createTables();
|
||||
// Tear down
|
||||
afterAll(function() {
|
||||
// Close the database
|
||||
sqlTiddlerDatabase.close();
|
||||
});
|
||||
// Run tests
|
||||
it("should save and retrieve tiddlers using engine: " + engine, function() {
|
||||
// Create bags and recipes
|
||||
expect(sqlTiddlerDatabase.createBag("bag-alpha","Bag alpha")).toEqual(1);
|
||||
expect(sqlTiddlerDatabase.createBag("bag-beta","Bag beta")).toEqual(2);
|
||||
expect(sqlTiddlerDatabase.createBag("bag-gamma","Bag gamma")).toEqual(3);
|
||||
expect(sqlTiddlerDatabase.listBags()).toEqual([
|
||||
{ bag_name: 'bag-alpha', bag_id: 1, accesscontrol: '', description: "Bag alpha" },
|
||||
{ bag_name: 'bag-beta', bag_id: 2, accesscontrol: '', description: "Bag beta" },
|
||||
{ bag_name: 'bag-gamma', bag_id: 3, accesscontrol: '', description: "Bag gamma" }
|
||||
]);
|
||||
expect(sqlTiddlerDatabase.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"Recipe rho")).toEqual(1);
|
||||
expect(sqlTiddlerDatabase.createRecipe("recipe-sigma",["bag-alpha","bag-gamma"],"Recipe sigma")).toEqual(2);
|
||||
expect(sqlTiddlerDatabase.createRecipe("recipe-tau",["bag-alpha"],"Recipe tau")).toEqual(3);
|
||||
expect(sqlTiddlerDatabase.createRecipe("recipe-upsilon",["bag-alpha","bag-gamma","bag-beta"],"Recipe upsilon")).toEqual(4);
|
||||
expect(sqlTiddlerDatabase.listRecipes()).toEqual([
|
||||
{ recipe_name: 'recipe-rho', recipe_id: 1, bag_names: ["bag-alpha","bag-beta"], description: "Recipe rho" },
|
||||
{ recipe_name: 'recipe-sigma', recipe_id: 2, bag_names: ["bag-alpha","bag-gamma"], description: "Recipe sigma" },
|
||||
{ recipe_name: 'recipe-tau', recipe_id: 3, bag_names: ["bag-alpha"], description: "Recipe tau" },
|
||||
{ recipe_name: 'recipe-upsilon', recipe_id: 4, bag_names: ["bag-alpha","bag-gamma","bag-beta"], description: "Recipe upsilon" }
|
||||
]);
|
||||
expect(sqlTiddlerDatabase.getRecipeBags("recipe-rho")).toEqual(["bag-alpha","bag-beta"]);
|
||||
expect(sqlTiddlerDatabase.getRecipeBags("recipe-sigma")).toEqual(["bag-alpha","bag-gamma"]);
|
||||
expect(sqlTiddlerDatabase.getRecipeBags("recipe-tau")).toEqual(["bag-alpha"]);
|
||||
expect(sqlTiddlerDatabase.getRecipeBags("recipe-upsilon")).toEqual(["bag-alpha","bag-gamma","bag-beta"]);
|
||||
// Save tiddlers
|
||||
expect(sqlTiddlerDatabase.saveBagTiddler({title: "Another Tiddler",text: "I'm in alpha",tags: "one two three"},"bag-alpha")).toEqual({
|
||||
tiddler_id: 1
|
||||
});
|
||||
expect(sqlTiddlerDatabase.saveBagTiddler({title: "Hello There",text: "I'm in alpha as well",tags: "one two three"},"bag-alpha")).toEqual({
|
||||
tiddler_id: 2
|
||||
});
|
||||
expect(sqlTiddlerDatabase.saveBagTiddler({title: "Hello There",text: "I'm in beta",tags: "four five six"},"bag-beta")).toEqual({
|
||||
tiddler_id: 3
|
||||
});
|
||||
expect(sqlTiddlerDatabase.saveBagTiddler({title: "Hello There",text: "I'm in gamma",tags: "seven eight nine"},"bag-gamma")).toEqual({
|
||||
tiddler_id: 4
|
||||
});
|
||||
// Verify what we've got
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-rho")).toEqual([
|
||||
{ title: 'Another Tiddler', tiddler_id: 1, bag_name: 'bag-alpha', is_deleted: 0 },
|
||||
{ title: 'Hello There', tiddler_id: 3, bag_name: 'bag-beta', is_deleted: 0 }
|
||||
]);
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-sigma")).toEqual([
|
||||
{ title: 'Another Tiddler', tiddler_id: 1, bag_name: 'bag-alpha', is_deleted: 0 },
|
||||
{ title: 'Hello There', tiddler_id: 4, bag_name: 'bag-gamma', is_deleted: 0 }
|
||||
]);
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddler("Hello There","recipe-rho").tiddler).toEqual({ title: "Hello There", text: "I'm in beta", tags: "four five six" });
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddler("Missing Tiddler","recipe-rho")).toEqual(null);
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddler("Another Tiddler","recipe-rho").tiddler).toEqual({ title: "Another Tiddler", text: "I'm in alpha", tags: "one two three" });
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddler("Hello There","recipe-sigma").tiddler).toEqual({ title: "Hello There", text: "I'm in gamma", tags: "seven eight nine" });
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddler("Another Tiddler","recipe-sigma").tiddler).toEqual({ title: "Another Tiddler", text: "I'm in alpha", tags: "one two three" });
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddler("Hello There","recipe-upsilon").tiddler).toEqual({title: "Hello There",text: "I'm in beta",tags: "four five six"});
|
||||
// Delete a tiddlers to ensure the underlying tiddler in the recipe shows through
|
||||
sqlTiddlerDatabase.deleteTiddler("Hello There","bag-beta");
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-rho")).toEqual([
|
||||
{ title: 'Another Tiddler', tiddler_id: 1, bag_name: 'bag-alpha', is_deleted: 0 },
|
||||
{ title: 'Hello There', tiddler_id: 2, bag_name: 'bag-alpha', is_deleted: 0 }
|
||||
]);
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-sigma")).toEqual([
|
||||
{ title: 'Another Tiddler', tiddler_id: 1, bag_name: 'bag-alpha', is_deleted: 0 },
|
||||
{ title: 'Hello There', tiddler_id: 4, bag_name: 'bag-gamma', is_deleted: 0 }
|
||||
]);
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddler("Hello There","recipe-beta")).toEqual(null);
|
||||
sqlTiddlerDatabase.deleteTiddler("Another Tiddler","bag-alpha");
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-rho")).toEqual([ { title: 'Hello There', tiddler_id: 2, bag_name: 'bag-alpha', is_deleted: 0 } ]);
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-sigma")).toEqual([ { title: 'Hello There', tiddler_id: 4, bag_name: 'bag-gamma', is_deleted: 0 } ]);
|
||||
// Save a recipe tiddler
|
||||
expect(sqlTiddlerDatabase.saveRecipeTiddler({title: "More", text: "None"},"recipe-rho")).toEqual({tiddler_id: 7, bag_name: 'bag-beta'});
|
||||
expect(sqlTiddlerDatabase.getRecipeTiddler("More","recipe-rho").tiddler).toEqual({title: "More", text: "None"});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
})();
|
|
@ -0,0 +1,150 @@
|
|||
/*\
|
||||
title: $:/plugins/tiddlywiki/multiwikiserver/store/tests-sql-tiddler-store.js
|
||||
type: application/javascript
|
||||
tags: [[$:/tags/test-spec]]
|
||||
|
||||
Tests the SQL tiddler store layer
|
||||
|
||||
\*/
|
||||
(function(){
|
||||
|
||||
/*jslint node: true, browser: true */
|
||||
/*global $tw: false */
|
||||
"use strict";
|
||||
|
||||
if($tw.node) {
|
||||
|
||||
describe("SQL tiddler store with node-sqlite3-wasm", function() {
|
||||
runSqlStoreTests("wasm");
|
||||
});
|
||||
|
||||
describe("SQL tiddler store with better-sqlite3", function() {
|
||||
runSqlStoreTests("better");
|
||||
});
|
||||
|
||||
function runSqlStoreTests(engine) {
|
||||
var SqlTiddlerStore = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-store.js").SqlTiddlerStore;
|
||||
|
||||
var store;
|
||||
|
||||
beforeEach(function() {
|
||||
store = new SqlTiddlerStore({
|
||||
databasePath: ":memory:",
|
||||
engine: engine
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
store.close();
|
||||
store = null;
|
||||
});
|
||||
|
||||
it("should return empty results without failure on an empty store", function() {
|
||||
expect(store.listBags()).toEqual([]);
|
||||
expect(store.listRecipes()).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return a single bag after creating a bag", function() {
|
||||
expect(store.createBag("bag-alpha", "Bag alpha")).toEqual(null);
|
||||
expect(store.listBags()).toEqual([{
|
||||
bag_name: "bag-alpha",
|
||||
bag_id: 1,
|
||||
accesscontrol: "",
|
||||
description: "Bag alpha"
|
||||
}]);
|
||||
});
|
||||
|
||||
it("should return empty results after failing to create a bag with an invalid name", function() {
|
||||
expect(store.createBag("bag alpha", "Bag alpha")).toEqual({
|
||||
message: "Invalid character(s)"
|
||||
});
|
||||
expect(store.listBags()).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return a bag with new description after re-creating", function() {
|
||||
expect(store.createBag("bag-alpha", "Bag alpha")).toEqual(null);
|
||||
expect(store.createBag("bag-alpha", "Different description")).toEqual(null);
|
||||
expect(store.listBags()).toEqual([{
|
||||
bag_name: "bag-alpha",
|
||||
bag_id: 1,
|
||||
accesscontrol: "",
|
||||
description: "Different description"
|
||||
}]);
|
||||
});
|
||||
|
||||
it("should return a saved tiddler within a bag", function() {
|
||||
expect(store.createBag("bag-alpha", "Bag alpha")).toEqual(null);
|
||||
var saveBagResult = store.saveBagTiddler({
|
||||
title: "Another Tiddler",
|
||||
text: "I'm in alpha",
|
||||
tags: "one two three"
|
||||
}, "bag-alpha");
|
||||
|
||||
expect(new Set(Object.keys(saveBagResult))).toEqual(new Set(["tiddler_id"]));
|
||||
expect(typeof(saveBagResult.tiddler_id)).toBe("number");
|
||||
|
||||
expect(store.getBagTiddlers("bag-alpha")).toEqual([{title: "Another Tiddler", tiddler_id: 1}]);
|
||||
|
||||
var getBagTiddlerResult = store.getBagTiddler("Another Tiddler","bag-alpha");
|
||||
expect(typeof(getBagTiddlerResult.tiddler_id)).toBe("number");
|
||||
delete getBagTiddlerResult.tiddler_id;
|
||||
expect(getBagTiddlerResult).toEqual({ attachment_blob: null, tiddler: {title: "Another Tiddler", text: "I'm in alpha", tags: "one two three"} });
|
||||
});
|
||||
|
||||
it("should return a single recipe after creating that recipe", function() {
|
||||
expect(store.createBag("bag-alpha","Bag alpha")).toEqual(null);
|
||||
expect(store.createBag("bag-beta","Bag beta")).toEqual(null);
|
||||
expect(store.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"Recipe rho")).toEqual(null);
|
||||
|
||||
expect(store.listRecipes()).toEqual([
|
||||
{ recipe_name: "recipe-rho", recipe_id: 1, bag_names: ["bag-alpha","bag-beta"], description: "Recipe rho" }
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return a recipe's bags after creating that recipe", function() {
|
||||
expect(store.createBag("bag-alpha","Bag alpha")).toEqual(null);
|
||||
expect(store.createBag("bag-beta","Bag beta")).toEqual(null);
|
||||
expect(store.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"Recipe rho")).toEqual(null);
|
||||
|
||||
expect(store.getRecipeBags("recipe-rho")).toEqual(["bag-alpha","bag-beta"]);
|
||||
});
|
||||
|
||||
it("should return a saved tiddler within a recipe", function() {
|
||||
expect(store.createBag("bag-alpha","Bag alpha")).toEqual(null);
|
||||
expect(store.createBag("bag-beta","Bag beta")).toEqual(null);
|
||||
expect(store.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"Recipe rho")).toEqual(null);
|
||||
|
||||
var saveRecipeResult = store.saveRecipeTiddler({
|
||||
title: "Another Tiddler",
|
||||
text: "I'm in rho"
|
||||
},"recipe-rho");
|
||||
|
||||
expect(new Set(Object.keys(saveRecipeResult))).toEqual(new Set(["tiddler_id", "bag_name"]));
|
||||
expect(typeof(saveRecipeResult.tiddler_id)).toBe("number");
|
||||
expect(saveRecipeResult.bag_name).toBe("bag-beta");
|
||||
|
||||
expect(store.getRecipeTiddlers("recipe-rho")).toEqual([{title: "Another Tiddler", tiddler_id: 1, bag_name: "bag-beta", is_deleted: 0 }]);
|
||||
|
||||
var getRecipeTiddlerResult = store.getRecipeTiddler("Another Tiddler","recipe-rho");
|
||||
expect(typeof(getRecipeTiddlerResult.tiddler_id)).toBe("number");
|
||||
delete getRecipeTiddlerResult.tiddler_id;
|
||||
expect(getRecipeTiddlerResult).toEqual({ attachment_blob: null, bag_name: "bag-beta", tiddler: {title: "Another Tiddler", text: "I'm in rho"} });
|
||||
});
|
||||
|
||||
it("should return no tiddlers after the only one has been deleted", function() {
|
||||
expect(store.createBag("bag-alpha","Bag alpha")).toEqual(null);
|
||||
|
||||
store.saveBagTiddler({
|
||||
title: "Another Tiddler",
|
||||
text: "I'm in alpha",
|
||||
tags: "one two three"
|
||||
}, "bag-alpha");
|
||||
|
||||
store.deleteTiddler("Another Tiddler","bag-alpha");
|
||||
expect(store.getBagTiddlers("bag-alpha")).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
})();
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"title": "$:/plugins/tiddlywiki/multiwikiserver",
|
||||
"name": "Multi Wiki Server",
|
||||
"description": "Multiple Wiki Server Extension",
|
||||
"list": "readme docs",
|
||||
"dependents": []
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 2.4 KiB |
|
@ -0,0 +1,3 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiserver/system-files/missing-favicon.png
|
||||
tags: $:/tags/MWS/SystemFile
|
||||
type: image/png
|
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
|
@ -0,0 +1,3 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiserver/system-files/motovun-jack.jpg
|
||||
tags: $:/tags/MWS/SystemFile
|
||||
type: image/jpeg
|
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
|
@ -0,0 +1,3 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiserver/system-files/mws-icon.png
|
||||
tags: $:/tags/MWS/SystemFile
|
||||
type: image/png
|
|
@ -0,0 +1,113 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiserver/system-files/styles.css
|
||||
tags: $:/tags/MWS/SystemFileWikified
|
||||
system-file-type: text/css
|
||||
|
||||
\import [subfilter{$:/core/config/GlobalImportFilter}]
|
||||
\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline macrocallblock
|
||||
|
||||
/* Import TiddlyWiki theme styles */
|
||||
|
||||
{{$:/core/ui/PageStylesheet}}
|
||||
|
||||
/* MWS Styles */
|
||||
|
||||
body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mws-wiki-card {
|
||||
display: flex;
|
||||
margin: 1em 0;
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
color: <<colour foreground>>;
|
||||
background: <<colour background>>;
|
||||
border-radius: 0.28571429rem;
|
||||
box-shadow: 0 1px 3px 0 #d4d4d5, 0 0 0 1px #d4d4d5;
|
||||
padding: 0.5em 0.5em 0.5em 1em;
|
||||
}
|
||||
|
||||
|
||||
.mws-wiki-card:hover {
|
||||
background: <<colour tiddler-info-background>>;
|
||||
color: <<colour foreground>>;
|
||||
}
|
||||
|
||||
.mws-wiki-card-image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mws-wiki-card-content {
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
.mws-wiki-card-header {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
margin: 0 0 0.25em 0;
|
||||
}
|
||||
|
||||
.mws-wiki-card-meta {
|
||||
color: <<colour muted-foreground>>;
|
||||
}
|
||||
|
||||
.mws-wiki-card-description {
|
||||
|
||||
}
|
||||
|
||||
.mws-vertical-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mws-horizontal-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mws-horizontal-list > li {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.mws-bag-pill {
|
||||
background: <<colour muted-foreground>>;
|
||||
color: <<colour background>>;
|
||||
fill: <<colour background>>;
|
||||
margin-right: 0.5em;
|
||||
border-radius: 0.25em;
|
||||
padding: 0 0.25em;
|
||||
}
|
||||
|
||||
.mws-bag-pill:hover {
|
||||
background: <<colour foreground>>;
|
||||
color: <<colour background>>;
|
||||
fill: <<colour background>>;
|
||||
}
|
||||
|
||||
.mws-bag-pill-topmost {
|
||||
background: <<colour very-muted-foreground>>;
|
||||
}
|
||||
|
||||
.mws-bag-pill .mws-bag-pill-label {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.mws-bag-pill-link {
|
||||
text-decoration: none;
|
||||
color: currentcolor;
|
||||
}
|
||||
|
||||
.mws-favicon {
|
||||
object-fit: contain;
|
||||
width: 4em;
|
||||
max-height: 4em;
|
||||
}
|
||||
|
||||
.mws-favicon-small {
|
||||
object-fit: contain;
|
||||
vertical-align: text-bottom;
|
||||
width: 1em;
|
||||
max-height: 1em;
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-bag
|
||||
|
||||
! <img
|
||||
src=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico?fallback=/.system/missing-favicon.png`
|
||||
class="mws-favicon-small"
|
||||
width="32px"
|
||||
/> Bag <$text text={{{ [<bag-name>]}}}/>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action="tiddlers/"
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
<div>
|
||||
<label>
|
||||
File to upload:
|
||||
</label>
|
||||
<input type="file" name="file-to-upload" accept="*/*" />
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
Tiddler title:
|
||||
</label>
|
||||
<input type="text" name="tiddler-field-title" />
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
Tiddler tags:
|
||||
</label>
|
||||
<input type="text" name="tiddler-field-tags" />
|
||||
</div>
|
||||
<div>
|
||||
<input type="submit" value="Upload"/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul>
|
||||
<$list filter="[<bag-titles>jsonget[]sort[]]">
|
||||
<li>
|
||||
<a href=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/${ [<currentTiddler>encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
|
||||
<$text text=<<currentTiddler>>/>
|
||||
</a>
|
||||
</li>
|
||||
</$list>
|
||||
</ul>
|
|
@ -0,0 +1,152 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-index
|
||||
|
||||
|
||||
\function .hide.system()
|
||||
[<show-system>match[on]]
|
||||
[all[]!prefix[$:/]]
|
||||
\end
|
||||
|
||||
\procedure bagPill(element-tag:"span",is-topmost:"yes")
|
||||
\whitespace trim
|
||||
<$genesis $type=<<element-tag>> class={{{ mws-bag-pill [<is-topmost>match[yes]then[mws-bag-pill-topmost]] +[join[ ]] }}}>
|
||||
<a class="mws-bag-pill-link" href=`/bags/${ [<bag-name>encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
|
||||
<img
|
||||
src=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico?fallback=/.system/missing-favicon.png`
|
||||
class="mws-favicon-small"
|
||||
/>
|
||||
<span class="mws-bag-pill-label">
|
||||
<$text text=<<bag-name>>/>
|
||||
</span>
|
||||
</a>
|
||||
</$genesis>
|
||||
\end
|
||||
|
||||
! Wikis Available Here
|
||||
|
||||
<ul class="mws-vertical-list">
|
||||
<$list filter="[<recipe-list>jsonindexes[]] :sort[<currentTiddler>jsonget[recipe_name]]" variable="recipe-index">
|
||||
<li>
|
||||
<$let
|
||||
recipe-info={{{ [<recipe-list>jsonextract<recipe-index>] }}}
|
||||
recipe-name={{{ [<recipe-info>jsonget[recipe_name]] }}}
|
||||
>
|
||||
<div
|
||||
class="mws-wiki-card"
|
||||
>
|
||||
<div class="mws-wiki-card-image">
|
||||
<img
|
||||
src=`/recipes/${ [<recipe-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico?fallback=/.system/missing-favicon.png`
|
||||
class="mws-favicon"
|
||||
/>
|
||||
</div>
|
||||
<div class="mws-wiki-card-content">
|
||||
<div class="mws-wiki-card-header">
|
||||
<a
|
||||
href=`/wiki/${ [<recipe-name>encodeuricomponent[]] }$`
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<$text text={{{ [<recipe-info>jsonget[recipe_name]] }}}/>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mws-wiki-card-meta">
|
||||
<%if true %>
|
||||
<ol class="mws-vertical-list">
|
||||
<$list filter="[<recipe-info>jsonget[bag_names]reverse[]] :filter[.hide.system[]]" variable="bag-name" counter="counter">
|
||||
<$transclude $variable="bagPill" is-topmost={{{ [<counter-first>match[yes]] }}} element-tag="li"/>
|
||||
</$list>
|
||||
</ol>
|
||||
<%else%>
|
||||
(no bags defined)
|
||||
<%endif%>
|
||||
</div>
|
||||
<div class="mws-wiki-card-description">
|
||||
<$text text={{{ [<recipe-info>jsonget[description]] }}}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</$let>
|
||||
</li>
|
||||
</$list>
|
||||
</ul>
|
||||
|
||||
<form action="/recipes" method="post" class="mws-form">
|
||||
<div class="mws-form-heading">
|
||||
Create a new recipe or modify and existing one
|
||||
</div>
|
||||
<div class="mws-form-fields">
|
||||
<div class="mws-form-field">
|
||||
<label class="mws-form-field-description">
|
||||
Recipe name
|
||||
</label>
|
||||
<input name="recipe_name" type="text"/>
|
||||
</div>
|
||||
<div class="mws-form-field">
|
||||
<label class="mws-form-field-description">
|
||||
Recipe description
|
||||
</label>
|
||||
<input name="description" type="text"/>
|
||||
</div>
|
||||
<div class="mws-form-field">
|
||||
<label class="mws-form-field-description">
|
||||
Bags in recipe (space separated)
|
||||
</label>
|
||||
<input name="bag_names" type="text"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mws-form-buttons">
|
||||
<input type="submit" value="Create or Update Recipe" formmethod="post"/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
! Bags
|
||||
|
||||
<ul class="mws-vertical-list">
|
||||
<$list filter="[<bag-list>jsonindexes[]] :filter[<bag-list>jsonget<currentTiddler>,[bag_name].hide.system[]] :sort[<bag-list>jsonget<currentTiddler>,[bag_name]]" variable="bag-index" counter="counter">
|
||||
<li class="mws-wiki-card">
|
||||
<$let
|
||||
bag-info={{{ [<bag-list>jsonextract<bag-index>] }}}
|
||||
bag-name={{{ [<bag-info>jsonget[bag_name]] }}}
|
||||
>
|
||||
<$transclude $variable="bagPill"/>
|
||||
<$text text={{{ [<bag-info>jsonget[description]] }}}/>
|
||||
</$let>
|
||||
</li>
|
||||
</$list>
|
||||
</ul>
|
||||
|
||||
<form action="/bags" method="post" class="mws-form">
|
||||
<div class="mws-form-heading">
|
||||
Create a new bag or modify and existing one
|
||||
</div>
|
||||
<div class="mws-form-fields">
|
||||
<div class="mws-form-field">
|
||||
<label class="mws-form-field-description">
|
||||
Bag name
|
||||
</label>
|
||||
<input name="bag_name" type="text"/>
|
||||
</div>
|
||||
<div class="mws-form-field">
|
||||
<label class="mws-form-field-description">
|
||||
Bag description
|
||||
</label>
|
||||
<input name="description" type="text"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mws-form-buttons">
|
||||
<input type="submit" value="Create or Update Bag" formmethod="post"/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
! Advanced
|
||||
|
||||
|
||||
<form id="checkboxForm" action="." method="GET">
|
||||
<%if [<show-system>match[on]] %>
|
||||
<input type="checkbox" id="chkShowSystem" name="show_system" value="on" checked="checked"/>
|
||||
<%else%>
|
||||
<input type="checkbox" id="chkShowSystem" name="show_system" value="on"/>
|
||||
<%endif%>
|
||||
<label for="chkShowSystem">Show system bags</label>
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
|
@ -0,0 +1,20 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiserver/templates/page
|
||||
|
||||
<!--
|
||||
Template for the basic HTML page layout. Expects the following variables:
|
||||
|
||||
page-content: title of tiddler containing the main page content
|
||||
-->
|
||||
`
|
||||
<!doctype html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
|
||||
<link rel="stylesheet" href="/.system/styles.css">
|
||||
</head>
|
||||
<body class="tc-body">
|
||||
`
|
||||
<$view tiddler=<<page-content>> field="text" format="htmlwikified" />
|
||||
`
|
||||
</body>
|
||||
</html>
|
||||
`
|
|
@ -0,0 +1,31 @@
|
|||
title: $:/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers
|
||||
|
||||
! <$image
|
||||
source=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
|
||||
class="mws-favicon-small"
|
||||
width="32px"
|
||||
>
|
||||
<$image
|
||||
source="$:/plugins/multiwikiserver/images/missing-favicon.png"
|
||||
class="mws-favicon-small"
|
||||
width="32px"
|
||||
/>
|
||||
</$image> Bag <$text text={{{ [<bag-name>]}}}/>
|
||||
|
||||
<p>
|
||||
Go back to <a href="..">Bag <$text text={{{ [<bag-name>]}}}/></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The following tiddlers were successfully imported:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<$list filter="[<imported-titles>jsonget[]sort[]]">
|
||||
<li>
|
||||
<a href=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/${ [<currentTiddler>encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
|
||||
<$text text=<<currentTiddler>>/>
|
||||
</a>
|
||||
</li>
|
||||
</$list>
|
||||
</ul>
|
|
@ -42,7 +42,8 @@ TiddlyWebAdaptor.prototype.getHost = function() {
|
|||
var text = this.wiki.getTiddlerText(CONFIG_HOST_TIDDLER,DEFAULT_HOST_TIDDLER),
|
||||
substitutions = [
|
||||
{name: "protocol", value: document.location.protocol},
|
||||
{name: "host", value: document.location.host}
|
||||
{name: "host", value: document.location.host},
|
||||
{name: "pathname", value: document.location.pathname}
|
||||
];
|
||||
for(var t=0; t<substitutions.length; t++) {
|
||||
var s = substitutions[t];
|
||||
|
|
18
readme.md
18
readme.md
|
@ -1,7 +1,11 @@
|
|||
<p>Welcome to <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a>, a non-linear personal web notebook that anyone can use and keep forever, independently of any corporation.</p><p><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> is a complete interactive wiki in <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/JavaScript.html">JavaScript</a>. It can be used as a single HTML file in the browser or as a powerful Node.js application. It is highly customisable: the entire user interface is itself implemented in hackable <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/WikiText.html">WikiText</a>.</p><p>Learn more and see it in action at <a class="tc-tiddlylink-external" href="https://tiddlywiki.com/" rel="noopener noreferrer" target="_blank">https://tiddlywiki.com/</a></p><p>Developer documentation is in progress at <a class="tc-tiddlylink-external" href="https://tiddlywiki.com/dev/" rel="noopener noreferrer" target="_blank">https://tiddlywiki.com/dev/</a></p><h1 class="">Join the Community</h1><p>
|
||||
<h2 class="">Official Forums</h2><p>The new official forum for talking about TiddlyWiki: requests for help, announcements of new releases and plugins, debating new features, or just sharing experiences. You can participate via the associated website, or subscribe via email.</p><p><a class="tc-tiddlylink-external" href="https://talk.tiddlywiki.org/" rel="noopener noreferrer" target="_blank">https://talk.tiddlywiki.org/</a></p><p>Note that talk.tiddlywiki.org is a community run service that we host and maintain ourselves. The modest running costs are covered by community contributions.</p><p>For the convenience of existing users, we also continue to operate the original TiddlyWiki group (hosted on Google Groups since 2005):</p><p><a class="tc-tiddlylink-external" href="https://groups.google.com/group/TiddlyWiki" rel="noopener noreferrer" target="_blank">https://groups.google.com/group/TiddlyWiki</a></p><h2 class="">Developer Forums</h2><p>There are several resources for developers to learn more about <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> and to discuss and contribute to its development.</p><ul><li><a class="tc-tiddlylink-external" href="https://tiddlywiki.com/dev" rel="noopener noreferrer" target="_blank">tiddlywiki.com/dev</a> is the official developer documentation</li><li>Get involved in the <a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5" rel="noopener noreferrer" target="_blank">development on GitHub</a><ul><li><a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5/discussions" rel="noopener noreferrer" target="_blank">Discussions</a> are for Q&A and open-ended discussion</li><li><a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5/issues" rel="noopener noreferrer" target="_blank">Issues</a> are for raising bug reports and proposing specific, actionable new ideas</li></ul></li><li>The older TiddlyWikiDev Google Group is now closed in favour of <a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5/discussions" rel="noopener noreferrer" target="_blank">GitHub Discussions</a> but remains a useful archive: <a class="tc-tiddlylink-external" href="https://groups.google.com/group/TiddlyWikiDev" rel="noopener noreferrer" target="_blank">https://groups.google.com/group/TiddlyWikiDev</a><ul><li>An enhanced group search facility is available on <a class="tc-tiddlylink-external" href="https://www.mail-archive.com/tiddlywikidev@googlegroups.com/" rel="noopener noreferrer" target="_blank">mail-archive.com</a></li></ul></li><li>Follow <a class="tc-tiddlylink-external" href="http://twitter.com/#!/TiddlyWiki" rel="noopener noreferrer" target="_blank">@TiddlyWiki on Twitter</a> for the latest news</li><li>Chat at <a class="tc-tiddlylink-external" href="https://gitter.im/TiddlyWiki/public" rel="noopener noreferrer" target="_blank">https://gitter.im/TiddlyWiki/public</a> (development room coming soon)</li></ul><h2 class="">Other Forums</h2><ul><li><a class="tc-tiddlylink-external" href="https://www.reddit.com/r/TiddlyWiki5/" rel="noopener noreferrer" target="_blank">TiddlyWiki Subreddit</a></li><li>Chat with Gitter at <a class="tc-tiddlylink-external" href="https://gitter.im/TiddlyWiki/public" rel="noopener noreferrer" target="_blank">https://gitter.im/TiddlyWiki/public</a> !</li><li>Chat on Discord at <a class="tc-tiddlylink-external" href="https://discord.gg/HFFZVQ8" rel="noopener noreferrer" target="_blank">https://discord.gg/HFFZVQ8</a></li></ul><h3 class="">Documentation</h3><p>There is also a discussion group specifically for discussing <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> documentation improvement initiatives: <a class="tc-tiddlylink-external" href="https://groups.google.com/group/tiddlywikidocs" rel="noopener noreferrer" target="_blank">https://groups.google.com/group/tiddlywikidocs</a>
|
||||
<hr><h1 class="">TiddlyWiki MultiWikiServer</h1><p>UNDER DEVELOPMENT</p><p>This is a branch of <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> that adds the MultiWikiServer plugin.</p><h2 class="">Readme</h2><p>This plugin extends the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> 5 server running on Node.js to be able to host multiple wikis that can share content or be independent.</p><p>Installation</p><pre><code>git clone https://github.com/Jermolene/TiddlyWiki5.git --branch multi-wiki-support
|
||||
|
||||
cd TiddlyWiki5
|
||||
|
||||
npm install</code></pre><p>To start the server:</p><pre><code>npm start</code></pre><p>The <code>npm start</code> command is a shortcut for the following command:</p><pre><code>node ./tiddlywiki.js ./editions/multiwikiserver --listen</code></pre><p>Then visit the administration interface in a browser:</p><ul><li><a class="tc-tiddlylink-external" href="http://127.0.0.1:8080/" rel="noopener noreferrer" target="_blank">http://127.0.0.1:8080/</a></li></ul><p>Note that changes are written to the topmost bag in a recipe.</p><p>Note that until syncing is improved it is necessary to use "Get latest changes from the server" to speed up propogation of changes.</p><p>To run the tests:</p><pre><code>./bin/test.sh</code></pre><h2 class="">Docs</h2><h1 class="">HTTP API</h1><p>The MultiWikiServer HTTP API provides access to resources hosted by the MWS store. It is based on <a class="tc-tiddlylink-external" href="https://tank.peermore.com/tanks/tiddlyweb/HTTP%20API" rel="noopener noreferrer" target="_blank">the API of TiddlyWeb</a>, first developed in 2008 by Chris Dent.</p><p>The design goals of the API are:</p><ul><li>To follow the principles of REST where practical</li><li>To present resources as nouns, not verbs</li></ul><p>General points about the design:</p><ul><li>In MWS there are no resources that end with / (except for the root path which is /)</li></ul><hr><p>Welcome to <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a>, a non-linear personal web notebook that anyone can use and keep forever, independently of any corporation.</p><p><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> is a complete interactive wiki in <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/JavaScript.html">JavaScript</a>. It can be used as a single HTML file in the browser or as a powerful Node.js application. It is highly customisable: the entire user interface is itself implemented in hackable <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/WikiText.html">WikiText</a>.</p><p>Learn more and see it in action at <a class="tc-tiddlylink-external" href="https://tiddlywiki.com/" rel="noopener noreferrer" target="_blank">https://tiddlywiki.com/</a></p><p>Developer documentation is in progress at <a class="tc-tiddlylink-external" href="https://tiddlywiki.com/dev/" rel="noopener noreferrer" target="_blank">https://tiddlywiki.com/dev/</a></p><h1 class="">Join the Community</h1><p>
|
||||
<h2 class="">Official Forums</h2><p>The new official forum for talking about TiddlyWiki: requests for help, announcements of new releases and plugins, debating new features, or just sharing experiences. You can participate via the associated website, or subscribe via email.</p><p><a class="tc-tiddlylink-external" href="https://talk.tiddlywiki.org/" rel="noopener noreferrer" target="_blank">https://talk.tiddlywiki.org/</a></p><p>Note that talk.tiddlywiki.org is a community run service that we host and maintain ourselves. The modest running costs are covered by community contributions.</p><p>For the convenience of existing users, we also continue to operate the original TiddlyWiki group (hosted on Google Groups since 2005):</p><p><a class="tc-tiddlylink-external" href="https://groups.google.com/group/TiddlyWiki" rel="noopener noreferrer" target="_blank">https://groups.google.com/group/TiddlyWiki</a></p><h2 class="">Developer Forums</h2><p>There are several resources for developers to learn more about <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> and to discuss and contribute to its development.</p><ul><li><a class="tc-tiddlylink-external" href="https://tiddlywiki.com/dev" rel="noopener noreferrer" target="_blank">tiddlywiki.com/dev</a> is the official developer documentation</li><li>Get involved in the <a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5" rel="noopener noreferrer" target="_blank">development on GitHub</a><ul><li><img class=" tc-image-loading" src="https://repobeats.axiom.co/api/embed/5a3bb51fd1ebe84a2da5548f78d2d74e456cebf3.svg"><span style="display:none;"></span></li><li><a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5/discussions" rel="noopener noreferrer" target="_blank">Discussions</a> are for Q&A and open-ended discussion</li><li><a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5/issues" rel="noopener noreferrer" target="_blank">Issues</a> are for raising bug reports and proposing specific, actionable new ideas</li></ul></li><li>The older TiddlyWikiDev Google Group is now closed in favour of <a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5/discussions" rel="noopener noreferrer" target="_blank">GitHub Discussions</a> but remains a useful archive: <a class="tc-tiddlylink-external" href="https://groups.google.com/group/TiddlyWikiDev" rel="noopener noreferrer" target="_blank">https://groups.google.com/group/TiddlyWikiDev</a><ul><li>An enhanced group search facility is available on <a class="tc-tiddlylink-external" href="https://www.mail-archive.com/tiddlywikidev@googlegroups.com/" rel="noopener noreferrer" target="_blank">mail-archive.com</a></li></ul></li><li>Follow <a class="tc-tiddlylink-external" href="http://twitter.com/#!/TiddlyWiki" rel="noopener noreferrer" target="_blank">@TiddlyWiki on Twitter</a> for the latest news</li><li>Chat at <a class="tc-tiddlylink-external" href="https://gitter.im/TiddlyWiki/public" rel="noopener noreferrer" target="_blank">https://gitter.im/TiddlyWiki/public</a> (development room coming soon)</li></ul><h2 class="">Other Forums</h2><ul><li><a class="tc-tiddlylink-external" href="https://www.reddit.com/r/TiddlyWiki5/" rel="noopener noreferrer" target="_blank">TiddlyWiki Subreddit</a></li><li>Chat with Gitter at <a class="tc-tiddlylink-external" href="https://gitter.im/TiddlyWiki/public" rel="noopener noreferrer" target="_blank">https://gitter.im/TiddlyWiki/public</a> !</li><li>Chat on Discord at <a class="tc-tiddlylink-external" href="https://discord.gg/HFFZVQ8" rel="noopener noreferrer" target="_blank">https://discord.gg/HFFZVQ8</a></li></ul><h3 class="">Documentation</h3><p>There is also a discussion group specifically for discussing <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> documentation improvement initiatives: <a class="tc-tiddlylink-external" href="https://groups.google.com/group/tiddlywikidocs" rel="noopener noreferrer" target="_blank">https://groups.google.com/group/tiddlywikidocs</a>
|
||||
</p>
|
||||
</p><h1 class="">Installing <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> on Node.js</h1><ol><li>Install <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Node.js.html">Node.js</a><ul><li>Linux: <blockquote><div><em>Debian/Ubuntu</em>:<br><code>apt install nodejs</code><br>May need to be followed up by:<br><code>apt install npm</code></div><div><em>Arch Linux</em><br><code>yay -S tiddlywiki</code> <br>(installs node and tiddlywiki)</div></blockquote></li><li>Mac<blockquote><div><code>brew install node</code></div></blockquote></li><li>Android<blockquote><div><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Serving%2520TW5%2520from%2520Android.html">Termux for Android</a></div></blockquote></li><li>Other <blockquote><div>See <a class="tc-tiddlylink-external" href="http://nodejs.org" rel="noopener noreferrer" target="_blank">http://nodejs.org</a></div></blockquote></li></ul></li><li>Open a command line terminal and type:<blockquote><div><code>npm install -g tiddlywiki</code></div><div>If it fails with an error you may need to re-run the command as an administrator:</div><div><code>sudo npm install -g tiddlywiki</code> (Mac/Linux)</div></blockquote></li><li>Ensure TiddlyWiki is installed by typing:<blockquote><div><code>tiddlywiki --version</code></div></blockquote><ul><li>In response, you should see <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> report its current version (eg "5.3.3". You may also see other debugging information reported.)</li></ul></li><li>Try it out:<ol><li><code>tiddlywiki mynewwiki --init server</code> to create a folder for a new wiki that includes server-related components</li><li><code>tiddlywiki mynewwiki --listen</code> to start <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a></li><li>Visit <a class="tc-tiddlylink-external" href="http://127.0.0.1:8080/" rel="noopener noreferrer" target="_blank">http://127.0.0.1:8080/</a> in your browser</li><li>Try editing and creating tiddlers</li></ol></li><li>Optionally, make an offline copy:<ul><li>click the <span class="doc-icon"><svg class="tc-image-save-button-dynamic tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt">
|
||||
</p><h1 class="">Installing <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> on Node.js</h1><ol><li>Install <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Node.js.html">Node.js</a><ul><li>Linux: <blockquote><div><em>Debian/Ubuntu</em>:<br><code>apt install nodejs</code><br>May need to be followed up by:<br><code>apt install npm</code></div><div><em>Arch Linux</em><br><code>yay -S tiddlywiki</code> <br>(installs node and tiddlywiki)</div></blockquote></li><li>Mac<blockquote><div><code>brew install node</code></div></blockquote></li><li>Android<blockquote><div><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Serving%2520TW5%2520from%2520Android.html">Termux for Android</a></div></blockquote></li><li>Other <blockquote><div>See <a class="tc-tiddlylink-external" href="http://nodejs.org" rel="noopener noreferrer" target="_blank">http://nodejs.org</a></div></blockquote></li></ul></li><li>Open a command line terminal and type:<blockquote><div><code>npm install -g tiddlywiki</code></div><div>If it fails with an error you may need to re-run the command as an administrator:</div><div><code>sudo npm install -g tiddlywiki</code> (Mac/Linux)</div></blockquote></li><li>Ensure TiddlyWiki is installed by typing:<blockquote><div><code>tiddlywiki --version</code></div></blockquote><ul><li>In response, you should see <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> report its current version (eg "5.3.4-prerelease". You may also see other debugging information reported.)</li></ul></li><li>Try it out:<ol><li><code>tiddlywiki mynewwiki --init server</code> to create a folder for a new wiki that includes server-related components</li><li><code>tiddlywiki mynewwiki --listen</code> to start <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a></li><li>Visit <a class="tc-tiddlylink-external" href="http://127.0.0.1:8080/" rel="noopener noreferrer" target="_blank">http://127.0.0.1:8080/</a> in your browser</li><li>Try editing and creating tiddlers</li></ol></li><li>Optionally, make an offline copy:<ul><li>click the <span class="doc-icon"><svg class="tc-image-save-button-dynamic tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt">
|
||||
<g class="tc-image-save-button-dynamic-clean">
|
||||
<path d="M120.783 34.33c4.641 8.862 7.266 18.948 7.266 29.646 0 35.347-28.653 64-64 64-35.346 0-64-28.653-64-64 0-35.346 28.654-64 64-64 18.808 0 35.72 8.113 47.43 21.03l2.68-2.68c3.13-3.13 8.197-3.132 11.321-.008 3.118 3.118 3.121 8.193-.007 11.32l-4.69 4.691zm-12.058 12.058a47.876 47.876 0 013.324 17.588c0 26.51-21.49 48-48 48s-48-21.49-48-48 21.49-48 48-48c14.39 0 27.3 6.332 36.098 16.362L58.941 73.544 41.976 56.578c-3.127-3.127-8.201-3.123-11.32-.005-3.123 3.124-3.119 8.194.006 11.319l22.617 22.617a7.992 7.992 0 005.659 2.347c2.05 0 4.101-.783 5.667-2.349l44.12-44.12z" fill-rule="evenodd"></path>
|
||||
</g>
|
||||
|
@ -9,10 +13,10 @@
|
|||
<path d="M64.856912,0 C100.203136,0 128.856912,28.653776 128.856912,64 C128.856912,99.346224 100.203136,128 64.856912,128 C29.510688,128 0.856911958,99.346224 0.856911958,64 C0.856911958,28.653776 29.510688,0 64.856912,0 Z M64.856912,16 C38.347244,16 16.856912,37.490332 16.856912,64 C16.856912,90.509668 38.347244,112 64.856912,112 C91.3665799,112 112.856912,90.509668 112.856912,64 C112.856912,37.490332 91.3665799,16 64.856912,16 Z"></path>
|
||||
<circle cx="65" cy="64" r="32"></circle>
|
||||
</g>
|
||||
</svg></span> <strong>save changes</strong> button in the sidebar, <strong>OR</strong></li><li><code>tiddlywiki mynewwiki --build index</code></li></ul></li></ol><p>The <code>-g</code> flag causes <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> to be installed globally. Without it, <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> will only be available in the directory where you installed it.</p><p><div class="doc-icon-block"><div class="doc-block-icon"><svg class="tc-image-warning tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><path d="M57.072 11c3.079-5.333 10.777-5.333 13.856 0l55.426 96c3.079 5.333-.77 12-6.928 12H8.574c-6.158 0-10.007-6.667-6.928-12l55.426-96zM64 37c-4.418 0-8 3.582-8 7.994v28.012C56 77.421 59.59 81 64 81c4.418 0 8-3.582 8-7.994V44.994C72 40.579 68.41 37 64 37zm0 67a8 8 0 100-16 8 8 0 000 16z" fill-rule="evenodd"></path></svg></div> If you are using Debian or Debian-based Linux and you are receiving a <code>node: command not found</code> error though node.js package is installed, you may need to create a symbolic link between <code>nodejs</code> and <code>node</code>. Consult your distro's manual and <code>whereis</code> to correctly create a link. See github <a class="tc-tiddlylink-external" href="http://github.com/Jermolene/TiddlyWiki5/issues/1434" rel="noopener noreferrer" target="_blank">issue 1434</a>. <br><br>Example Debian v8.0: <code>sudo ln -s /usr/bin/nodejs /usr/bin/node</code></div></p><p><br>
|
||||
<div class="doc-icon-block"><div class="doc-block-icon"><svg class="tc-image-tip tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><path d="M64 128.242c35.346 0 64-28.654 64-64 0-35.346-28.654-64-64-64-35.346 0-64 28.654-64 64 0 35.346 28.654 64 64 64zm11.936-36.789c-.624 4.129-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349C54.33 94.05 58.824 95.82 64 95.82c5.175 0 9.67-1.769 11.936-4.366zm0 4.492c-.624 4.13-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349 2.266 2.597 6.76 4.366 11.936 4.366 5.175 0 9.67-1.769 11.936-4.366zm0 4.456c-.624 4.129-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349 2.266 2.597 6.76 4.366 11.936 4.366 5.175 0 9.67-1.769 11.936-4.366zm0 4.492c-.624 4.13-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349 2.266 2.597 6.76 4.366 11.936 4.366 5.175 0 9.67-1.769 11.936-4.366zM64.3 24.242c11.618 0 23.699 7.82 23.699 24.2S75.92 71.754 75.92 83.576c0 5.873-5.868 9.26-11.92 9.26s-12.027-3.006-12.027-9.26C51.973 71.147 40 65.47 40 48.442s12.683-24.2 24.301-24.2z" fill-rule="evenodd"></path></svg></div> You can also install prior versions like this: <br><code> npm install -g tiddlywiki@5.1.13</code></div>
|
||||
</p><h1 class="">Using <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> on Node.js</h1><p>TiddlyWiki5 includes a set of commands for use on the command line to perform an extensive set of operations based on <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolders</a>, <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlerFiles.html">TiddlerFiles</a>.</p><p>For example, the following command loads the tiddlers from a TiddlyWiki HTML file and then saves one of them in static HTML:</p><pre><code>tiddlywiki --verbose --load mywiki.html --rendertiddler ReadMe ./readme.html</code></pre><p>Running <code>tiddlywiki</code> from the command line boots the TiddlyWiki kernel, loads the core plugins and establishes an empty wiki store. It then sequentially processes the command line arguments from left to right. The arguments are separated with spaces.</p><p><a class="tc-tiddlylink tc-tiddlylink-resolves doc-from-version" href="https://tiddlywiki.com/static/Release%25205.1.20.html"><svg class="tc-image-warning tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><path d="M57.072 11c3.079-5.333 10.777-5.333 13.856 0l55.426 96c3.079 5.333-.77 12-6.928 12H8.574c-6.158 0-10.007-6.667-6.928-12l55.426-96zM64 37c-4.418 0-8 3.582-8 7.994v28.012C56 77.421 59.59 81 64 81c4.418 0 8-3.582 8-7.994V44.994C72 40.579 68.41 37 64 37zm0 67a8 8 0 100-16 8 8 0 000 16z" fill-rule="evenodd"></path></svg> New in: 5.1.20</a> First, there can be zero or more plugin references identified by the prefix <code>+</code> for plugin names or <code>++</code> for a path to a plugin folder. These plugins are loaded in addition to any specified in the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolder</a>.</p><p>The next argument is the optional path to the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolder</a> to be loaded. If not present, then the current directory is used.</p><p>The commands and their individual arguments follow, each command being identified by the prefix <code>--</code>.</p><pre><code>tiddlywiki [+<pluginname> | ++<pluginpath>] [<wikipath>] [--<command> [<arg>[,<arg>]]]</code></pre><p>For example:</p><pre><code>tiddlywiki --version
|
||||
</svg></span> <strong>save changes</strong> button in the sidebar, <strong>OR</strong></li><li><code>tiddlywiki mynewwiki --build index</code></li></ul></li></ol><p>The <code>-g</code> flag causes <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> to be installed globally. Without it, <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> will only be available in the directory where you installed it.</p><p><div class="doc-icon-block doc-warning"><div><strong>Warning</strong></div><div class="doc-block-icon"><svg class="tc-image-warning tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><path d="M57.072 11c3.079-5.333 10.777-5.333 13.856 0l55.426 96c3.079 5.333-.77 12-6.928 12H8.574c-6.158 0-10.007-6.667-6.928-12l55.426-96zM64 37c-4.418 0-8 3.582-8 7.994v28.012C56 77.421 59.59 81 64 81c4.418 0 8-3.582 8-7.994V44.994C72 40.579 68.41 37 64 37zm0 67a8 8 0 100-16 8 8 0 000 16z" fill-rule="evenodd"></path></svg></div>If you are using Debian or Debian-based Linux and you are receiving a <code>node: command not found</code> error though node.js package is installed, you may need to create a symbolic link between <code>nodejs</code> and <code>node</code>. Consult your distro's manual and <code>whereis</code> to correctly create a link. See github <a class="tc-tiddlylink-external" href="http://github.com/Jermolene/TiddlyWiki5/issues/1434" rel="noopener noreferrer" target="_blank">issue 1434</a>. <br><br>Example Debian v8.0: <code>sudo ln -s /usr/bin/nodejs /usr/bin/node</code></div></p><p><br>
|
||||
<div class="doc-icon-block doc-tip"><div><strong>Tip</strong></div><div class="doc-block-icon"><svg class="tc-image-tip tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><path d="M64 128.242c35.346 0 64-28.654 64-64 0-35.346-28.654-64-64-64-35.346 0-64 28.654-64 64 0 35.346 28.654 64 64 64zm11.936-36.789c-.624 4.129-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349C54.33 94.05 58.824 95.82 64 95.82c5.175 0 9.67-1.769 11.936-4.366zm0 4.492c-.624 4.13-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349 2.266 2.597 6.76 4.366 11.936 4.366 5.175 0 9.67-1.769 11.936-4.366zm0 4.456c-.624 4.129-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349 2.266 2.597 6.76 4.366 11.936 4.366 5.175 0 9.67-1.769 11.936-4.366zm0 4.492c-.624 4.13-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349 2.266 2.597 6.76 4.366 11.936 4.366 5.175 0 9.67-1.769 11.936-4.366zM64.3 24.242c11.618 0 23.699 7.82 23.699 24.2S75.92 71.754 75.92 83.576c0 5.873-5.868 9.26-11.92 9.26s-12.027-3.006-12.027-9.26C51.973 71.147 40 65.47 40 48.442s12.683-24.2 24.301-24.2z" fill-rule="evenodd"></path></svg></div>You can also install prior versions like this: <br><code> npm install -g tiddlywiki@5.1.13</code></div>
|
||||
</p><h1 class="">Using <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> on Node.js</h1><p>TiddlyWiki5 includes a set of commands for use on the command line to perform an extensive set of operations based on <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolders</a>, <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlerFiles.html">TiddlerFiles</a>.</p><p>For example, the following command loads the tiddlers from a TiddlyWiki HTML file and then saves one of them in static HTML:</p><pre><code>tiddlywiki --verbose --load mywiki.html --rendertiddler ReadMe ./readme.html</code></pre><p>Running <code>tiddlywiki</code> from the command line boots the TiddlyWiki kernel, loads the core plugins and establishes an empty wiki store. It then sequentially processes the command line arguments from left to right. The arguments are separated with spaces.</p><p><a class="tc-tiddlylink tc-tiddlylink-resolves doc-from-version" href="https://tiddlywiki.com/static/Release%25205.1.20.html"><span class="tc-tiny-gap-right"><svg class="tc-image-info-button tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><g fill-rule="evenodd" transform="translate(.05)"><path d="M64 128c35.346 0 64-28.654 64-64 0-35.346-28.654-64-64-64C28.654 0 0 28.654 0 64c0 35.346 28.654 64 64 64zm0-16c26.51 0 48-21.49 48-48S90.51 16 64 16 16 37.49 16 64s21.49 48 48 48z"></path><circle cx="64" cy="32" r="8"></circle><rect height="56" rx="8" width="16" x="56" y="48"></rect></g></svg></span>Introduced in v5.1.20</a> First, there can be zero or more plugin references identified by the prefix <code>+</code> for plugin names or <code>++</code> for a path to a plugin folder. These plugins are loaded in addition to any specified in the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolder</a>.</p><p>The next argument is the optional path to the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolder</a> to be loaded. If not present, then the current directory is used.</p><p>The commands and their individual arguments follow, each command being identified by the prefix <code>--</code>.</p><pre><code>tiddlywiki [+<pluginname> | ++<pluginpath>] [<wikipath>] [--<command> [<arg>[,<arg>]]]</code></pre><p>For example:</p><pre><code>tiddlywiki --version
|
||||
tiddlywiki +plugins/tiddlywiki/filesystem +plugins/tiddlywiki/tiddlyweb mywiki --listen
|
||||
tiddlywiki ++./mygreatplugin mywiki --listen</code></pre><p><a class="tc-tiddlylink tc-tiddlylink-resolves doc-from-version" href="https://tiddlywiki.com/static/Release%25205.1.18.html"><svg class="tc-image-warning tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><path d="M57.072 11c3.079-5.333 10.777-5.333 13.856 0l55.426 96c3.079 5.333-.77 12-6.928 12H8.574c-6.158 0-10.007-6.667-6.928-12l55.426-96zM64 37c-4.418 0-8 3.582-8 7.994v28.012C56 77.421 59.59 81 64 81c4.418 0 8-3.582 8-7.994V44.994C72 40.579 68.41 37 64 37zm0 67a8 8 0 100-16 8 8 0 000 16z" fill-rule="evenodd"></path></svg> New in: 5.1.18</a> Commands such as the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/ListenCommand.html">ListenCommand</a> that support large numbers of parameters can use <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/NamedCommandParameters.html">NamedCommandParameters</a> to make things less unwieldy. For example:</p><pre><code>tiddlywiki wikipath --listen username=jeremy port=8090</code></pre><p>See <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Commands.html">Commands</a> for a full listing of the available commands.
|
||||
tiddlywiki ++./mygreatplugin mywiki --listen</code></pre><p><a class="tc-tiddlylink tc-tiddlylink-resolves doc-from-version" href="https://tiddlywiki.com/static/Release%25205.1.18.html"><span class="tc-tiny-gap-right"><svg class="tc-image-info-button tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><g fill-rule="evenodd" transform="translate(.05)"><path d="M64 128c35.346 0 64-28.654 64-64 0-35.346-28.654-64-64-64C28.654 0 0 28.654 0 64c0 35.346 28.654 64 64 64zm0-16c26.51 0 48-21.49 48-48S90.51 16 64 16 16 37.49 16 64s21.49 48 48 48z"></path><circle cx="64" cy="32" r="8"></circle><rect height="56" rx="8" width="16" x="56" y="48"></rect></g></svg></span>Introduced in v5.1.18</a> Commands such as the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/ListenCommand.html">ListenCommand</a> that support large numbers of parameters can use <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/NamedCommandParameters.html">NamedCommandParameters</a> to make things less unwieldy. For example:</p><pre><code>tiddlywiki wikipath --listen username=jeremy port=8090</code></pre><p>See <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Commands.html">Commands</a> for a full listing of the available commands.
|
||||
</p><h1 class="">Upgrading <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> on Node.js</h1><p>If you've installed <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki%2520on%2520Node.js.html">TiddlyWiki on Node.js</a> on the usual way, when a new version is released you can upgrade it with this command:</p><pre><code>npm update -g tiddlywiki</code></pre><p>On Mac or Linux you'll need to add <strong>sudo</strong> like this:</p><pre><code>sudo npm update -g tiddlywiki</code></pre><h1 class="">Also see</h1><p><ul class=""><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Building%2520TiddlyWikiClassic.html">Building TiddlyWikiClassic</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Customising%2520Tiddler%2520File%2520Naming.html">Customising Tiddler File Naming</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Environment%2520Variables%2520on%2520Node.js.html">Environment Variables on Node.js</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Generating%2520Static%2520Sites%2520with%2520TiddlyWiki.html">Generating Static Sites with TiddlyWiki</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/How%2520to%2520build%2520a%2520TiddlyWiki5%2520from%2520individual%2520tiddlers.html">How to build a TiddlyWiki5 from individual tiddlers</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Installing%2520custom%2520plugins%2520on%2520Node.js.html">Installing custom plugins on Node.js</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Installing%2520official%2520plugins%2520on%2520Node.js.html">Installing official plugins on Node.js</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Installing%2520TiddlyWiki%2520on%2520Microsoft%2520Internet%2520Information%2520Server.html">Internet Information Services</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Installing%2520TiddlyWiki%2520Prerelease%2520on%2520Node.js.html">Installing TiddlyWiki Prerelease on Node.js</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/MultiTiddlerFiles.html">MultiTiddlerFiles</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/MultiTiddlerFileSyntax.html">MultiTiddlerFileSyntax</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/NamedCommandParameters.html">NamedCommandParameters</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Scripts%2520for%2520TiddlyWiki%2520on%2520Node.js.html">Scripts for TiddlyWiki on Node.js</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Serving%2520TW5%2520from%2520Android.html">Node.js on Termux</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlerFiles.html">TiddlerFiles</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/tiddlywiki.files%2520Files.html">tiddlywiki.files Files</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/tiddlywiki.info%2520Files.html">tiddlywiki.info Files</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolders</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Uninstalling%2520a%2520plugin%2520with%2520Node.js.html">Uninstalling a plugin with Node.js</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Using%2520a%2520custom%2520path%2520prefix%2520with%2520the%2520client-server%2520edition.html">Using a custom path prefix with the client-server edition</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Using%2520TiddlyWiki%2520for%2520GitHub%2520project%2520documentation.html">Using TiddlyWiki for GitHub project documentation</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Working%2520with%2520the%2520TiddlyWiki5%2520repository.html">Working with the TiddlyWiki5 repository</a></li></ul></p><p><em>This readme file was automatically generated by <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a></em>
|
||||
</p>
|
Loading…
Reference in New Issue