This commit is contained in:
Jeremy Ruston 2024-05-02 22:11:54 +02:00 committed by GitHub
commit b35b3802c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
86 changed files with 6361 additions and 16 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@
tmp/
output/
node_modules/
store/
/test-results/
/playwright-report/
/playwright/.cache/

View File

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

View File

@ -2,6 +2,8 @@
# test TiddlyWiki5 for tiddlywiki.com
npm install
node ./tiddlywiki.js \
./editions/test \
--verbose \

View File

@ -10,6 +10,7 @@ fi
# tw5.com readmes
node $TW5_BUILD_TIDDLYWIKI \
+plugins/tiddlywiki/multiwikiserver \
editions/tw5.com \
--verbose \
--output . \

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -163,4 +163,4 @@ ImageWidget.prototype.refresh = function(changedTiddlers) {
exports.image = ImageWidget;
})();
})();

View File

@ -0,0 +1,2 @@
title: $:/config/MultiWikiServer/Engine
text: better

View File

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

View File

@ -1,7 +1,8 @@
{
"description": "TiddlyWiki core tests",
"plugins": [
"tiddlywiki/jasmine"
"tiddlywiki/jasmine",
"tiddlywiki/multiwikiserver"
],
"themes": [
"tiddlywiki/vanilla",

View File

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

1648
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -0,0 +1,3 @@
title: $:/config/SaveWikiButton/Template
$:/plugins/tiddlywiki/multiwikiclient/save/offline

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
{
"title": "$:/plugins/tiddlywiki/multiwikiclient",
"name": "MultiWikiClient",
"description": "Synchronise changes from the browser to TiddlyWiki ~MultiWikiServer",
"list": "readme",
"plugin-priority": 10
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
title: $:/plugins/multiwikiclient/SideBarSegment
tags: $:/tags/SideBarSegment
list-before: $:/core/ui/SideBarSegments/page-controls
MWS Connection Status: {{$:/state/multiwikiclient/connection}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
title: $:/config/MultiWikiServer/AttachmentSizeLimit
text: 204800

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
title: $:/plugins/tiddlywiki/multiwikiserver/system-files/mws-icon.png
tags: $:/tags/MWS/SystemFile
type: image/png

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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&amp;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&amp;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 [+&lt;pluginname&gt; | ++&lt;pluginpath&gt;] [&lt;wikipath&gt;] [--&lt;command&gt; [&lt;arg&gt;[,&lt;arg&gt;]]]</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 [+&lt;pluginname&gt; | ++&lt;pluginpath&gt;] [&lt;wikipath&gt;] [--&lt;command&gt; [&lt;arg&gt;[,&lt;arg&gt;]]]</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>