diff --git a/editions/prerelease/tiddlywiki.info b/editions/prerelease/tiddlywiki.info index 168fbb41f..060e4fb8a 100644 --- a/editions/prerelease/tiddlywiki.info +++ b/editions/prerelease/tiddlywiki.info @@ -14,7 +14,8 @@ "tiddlywiki/dynannotate", "tiddlywiki/codemirror", "tiddlywiki/menubar", - "tiddlywiki/jszip" + "tiddlywiki/jszip", + "tiddlywiki/multiwikiserver" ], "themes": [ "tiddlywiki/vanilla", diff --git a/editions/test/tiddlywiki.info b/editions/test/tiddlywiki.info index afb9c0514..574d196e1 100644 --- a/editions/test/tiddlywiki.info +++ b/editions/test/tiddlywiki.info @@ -1,7 +1,8 @@ { "description": "TiddlyWiki core tests", "plugins": [ - "tiddlywiki/jasmine" + "tiddlywiki/jasmine", + "tiddlywiki/multiwikiserver" ], "themes": [ "tiddlywiki/vanilla", diff --git a/plugins/tiddlywiki/multiwikiserver/docs/readme.tid b/plugins/tiddlywiki/multiwikiserver/docs/readme.tid new file mode 100644 index 000000000..dcc49c06d --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/docs/readme.tid @@ -0,0 +1,9 @@ +title: $:/plugins/tiddlywiki/multiwikiserver/readme + +This plugin extends the TiddlyWiki 5 server running on Node.js to be able to host multiple wikis, which can share content or be independent. + +Before using the plugin, it is necessary to install dependencies by running the following command in the root of the ~TiddlyWiki5 repository: + +``` +npm install better-sqlite3 +``` diff --git a/plugins/tiddlywiki/multiwikiserver/modules/init.js b/plugins/tiddlywiki/multiwikiserver/modules/init.js new file mode 100644 index 000000000..990552924 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/init.js @@ -0,0 +1,53 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/init.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() { + // Install the sqlite3 global namespace + $tw.sqlite3 = { + Database: null + }; + // Check that better-sqlite3 is installed + var logger = new $tw.utils.Logger("multiwikiserver"); + try { + $tw.sqlite3.Database = require("better-sqlite3"); + } catch(e) { + } + if(!$tw.sqlite3.Database) { + logger.alert("The plugin 'tiddlywiki/multiwikiserver' requires the better-sqlite3 npm package to be installed. Run 'npm install better-sqlite3' in the root of the TiddlyWiki repository"); + return; + } + // Create and initialise the tiddler store + var SqlTiddlerStore = require("$:/plugins/tiddlywiki/multiwikiserver/sql-tiddler-store.js").SqlTiddlerStore; + $tw.sqlTiddlerStore = new SqlTiddlerStore({}); + $tw.sqlTiddlerStore.createTables(); + // Create bags and recipes + $tw.sqlTiddlerStore.saveBag("bag-alpha"); + $tw.sqlTiddlerStore.saveBag("bag-beta"); + $tw.sqlTiddlerStore.saveBag("bag-gamma"); + $tw.sqlTiddlerStore.saveRecipe("recipe-rho",["bag-alpha","bag-beta"]); + $tw.sqlTiddlerStore.saveRecipe("recipe-sigma",["bag-alpha","bag-gamma"]); + // Save tiddlers + $tw.sqlTiddlerStore.saveTiddler({title: "Another Tiddler",text: "I'm in alpha",tags: "one two three"},"bag-alpha"); + $tw.sqlTiddlerStore.saveTiddler({title: "Hello There",text: "I'm in alpha as well",tags: "one two three"},"bag-alpha"); + $tw.sqlTiddlerStore.saveTiddler({title: "Hello There",text: "I'm in beta",tags: "four five six"},"bag-beta"); + $tw.sqlTiddlerStore.saveTiddler({title: "Hello There",text: "I'm in gamma",tags: "seven eight nine"},"bag-gamma"); +}; + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-wiki.js b/plugins/tiddlywiki/multiwikiserver/modules/route-wiki.js new file mode 100644 index 000000000..883b16fcc --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-wiki.js @@ -0,0 +1,60 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/route-wiki.js +type: application/javascript +module-type: route + +GET /wikis/: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]); + // Get the tiddlers in the recipe + var titles = $tw.sqlTiddlerStore.getRecipeTiddlers(recipe_name); + // Render the template + var template = $tw.wiki.renderTiddler("text/plain","$:/core/templates/tiddlywiki5.html",{ + variables: { + saveTiddlerFilter: ` + $:/boot/boot.css + $:/boot/boot.js + $:/boot/bootprefix.js + $:/core + $:/library/sjcl.js + $:/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"); + } + var htmlParts = []; + htmlParts.push(template.substring(0,markerPos + marker.length)); + $tw.utils.each(titles,function(title) { + htmlParts.push(JSON.stringify($tw.sqlTiddlerStore.getTiddler(title,recipe_name))); + htmlParts.push(",") + }); + htmlParts.push(template.substring(markerPos + marker.length)) + // Send response + if(htmlParts) { + state.sendResponse(200,{"Content-Type": "text/html"},htmlParts.join("\n"),"utf8"); + } else { + response.writeHead(404); + response.end(); + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js b/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js new file mode 100644 index 000000000..6c1aa59cf --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js @@ -0,0 +1,263 @@ +/*\ +title: $:/plugins/tiddlywiki/multiwikiserver/sql-tiddler-store.js +type: application/javascript +module-type: library + +Functions to perform basic tiddler operations with a sqlite3 database + +\*/ + +(function() { + +function SqlTiddlerStore(options) { + // Create our database + this.db = new $tw.sqlite3.Database(":memory:",{verbose: undefined && console.log}); +} + +SqlTiddlerStore.prototype.close = function() { + this.db.close(); + this.db = undefined; +}; + +SqlTiddlerStore.prototype.runStatement = function(sql,params) { + params = params || {}; + const statement = this.db.prepare(sql); + statement.run(params); +}; + +SqlTiddlerStore.prototype.runStatementGet = function(sql,params) { + params = params || {}; + const statement = this.db.prepare(sql); + return statement.get(params); +}; + +SqlTiddlerStore.prototype.runStatementGetAll = function(sql,params) { + params = params || {}; + const statement = this.db.prepare(sql); + return statement.all(params); +}; + +SqlTiddlerStore.prototype.runStatements = function(sqlArray) { + for(const sql of sqlArray) { + this.runStatement(sql); + } +}; + +SqlTiddlerStore.prototype.createTables = function() { + this.runStatements([` + -- Bags have names and access control settings + CREATE TABLE IF NOT EXISTS bags ( + bag_id INTEGER PRIMARY KEY, + bag_name TEXT UNIQUE, + accesscontrol TEXT + ) + `,` + -- Recipes have names... + CREATE TABLE IF NOT EXISTS recipes ( + recipe_id INTEGER PRIMARY KEY, + recipe_name TEXT UNIQUE + ) + `,` + -- ...and recipes also have an ordered list of bags + CREATE TABLE IF NOT EXISTS recipe_bags ( + recipe_id INTEGER, + bag_id INTEGER, + position INTEGER, + FOREIGN KEY (recipe_id) REFERENCES recipes(recipe_id), + FOREIGN KEY (bag_id) REFERENCES bags(bag_id), + UNIQUE (recipe_id, bag_id) + ) + `,` + -- Tiddlers are contained in bags and have titles + CREATE TABLE IF NOT EXISTS tiddlers ( + tiddler_id INTEGER PRIMARY KEY, + bag_id INTEGER, + title TEXT, + FOREIGN KEY (bag_id) REFERENCES bags(bag_id), + 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, + field_value TEXT, + FOREIGN KEY (tiddler_id) REFERENCES tiddlers(tiddler_id), + UNIQUE (tiddler_id, field_name) + ) + `]); +}; + +SqlTiddlerStore.prototype.logTables = function() { + var self = this; + function sqlLogTable(table) { + console.log(`TABLE ${table}:`); + let statement = self.db.prepare(`select * from ${table}`); + for(const row of statement.all()) { + console.log(row); + } + } + const tables = ["recipes","bags","recipe_bags","tiddlers","fields"]; + for(const table of tables) { + sqlLogTable(table); + } +}; + +SqlTiddlerStore.prototype.saveBag = function(bagname) { + // Run the queries + this.runStatement(` + INSERT OR REPLACE INTO bags (bag_name, accesscontrol) VALUES ($bag_name, $accesscontrol) + `,{ + bag_name: bagname, + accesscontrol: "[some access control stuff]" + }); +}; + +SqlTiddlerStore.prototype.saveRecipe = function(recipename,bagnames) { + // Run the queries + this.runStatement(` + -- Insert or replace the recipe with the given name + INSERT OR REPLACE INTO recipes (recipe_name) + VALUES ($recipe_name) + `,{ + recipe_name: recipename + }); + this.runStatement(` + -- Insert bag names into recipe_bags for the given recipe name + INSERT INTO recipe_bags (recipe_id, bag_id, position) + SELECT r.recipe_id, b.bag_id, j.key + FROM ( + SELECT * FROM json_each($bag_names) + ) AS j + JOIN bags AS b ON b.bag_name = j.value + JOIN recipes AS r ON r.recipe_name = $recipe_name; + `,{ + recipe_name: recipename, + bag_names: JSON.stringify(bagnames) + }); +}; + +SqlTiddlerStore.prototype.saveTiddler = function(tiddlerFields,bagname) { + // Run the queries + this.runStatement(` + INSERT OR REPLACE INTO tiddlers (bag_id, title) + VALUES ( + (SELECT bag_id FROM bags WHERE bag_name = $bag_name), + $title + ) + `,{ + title: tiddlerFields.title, + bag_name: bagname + }); + this.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: bagname, + field_values: JSON.stringify(Object.assign({},tiddlerFields,{title: undefined})) + }); +}; + +SqlTiddlerStore.prototype.deleteTiddler = function(title,bagname) { + // Run the queries + this.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: bagname + }); + this.runStatement(` + DELETE FROM tiddlers + WHERE bag_id = ( + SELECT bag_id + FROM bags + WHERE bag_name = $bag_name + ) AND title = $title + `,{ + title: title, + bag_name: bagname + }); +}; + +SqlTiddlerStore.prototype.getTiddler = function(title,recipename) { + const rows = this.runStatementGetAll(` + SELECT field_name, field_value + FROM fields + WHERE tiddler_id = ( + SELECT tt.tiddler_id + FROM ( + SELECT bb.bag_id, t.tiddler_id + FROM ( + SELECT b.bag_id + 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 + WHERE r.recipe_name = $recipe_name + ORDER BY rb.position + ) AS bb + INNER JOIN tiddlers AS t ON bb.bag_id = t.bag_id + WHERE t.title = $title + ) AS tt + ORDER BY tt.tiddler_id DESC + LIMIT 1 + ) + `,{ + title: title, + recipe_name: recipename + }); + if(rows.length === 0) { + return null; + } else { + return rows.reduce((accumulator,value) => { + accumulator[value["field_name"]] = value.field_value; + return accumulator; + },{title: title}); + } +}; + +/* +Get the titles of the tiddlers in a recipe. Returns an empty array for recipes that do not exist +*/ +SqlTiddlerStore.prototype.getRecipeTiddlers = function(recipename) { + const rows = this.runStatementGetAll(` + SELECT DISTINCT title + FROM tiddlers + WHERE bag_id IN ( + SELECT bag_id + FROM recipe_bags + WHERE recipe_id = ( + SELECT recipe_id + FROM recipes + WHERE recipe_name = $recipe_name + ) + ) + ORDER BY title ASC + `,{ + recipe_name: recipename + }); + return rows.map(value => value.title); +}; + +exports.SqlTiddlerStore = SqlTiddlerStore; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/multiwikiserver/modules/tests-sql-tiddler-store.js b/plugins/tiddlywiki/multiwikiserver/modules/tests-sql-tiddler-store.js new file mode 100644 index 000000000..f85ab6407 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/tests-sql-tiddler-store.js @@ -0,0 +1,61 @@ +/*\ +title: tests-sql-tiddler-store.js +type: application/javascript +tags: [[$:/tags/test-spec]] + +Tests the SQL tiddler store + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +if($tw.node) { + +describe("SQL tiddler store", function() { + // Create and initialise the tiddler store + var SqlTiddlerStore = require("$:/plugins/tiddlywiki/multiwikiserver/sql-tiddler-store.js").SqlTiddlerStore; + const sqlTiddlerStore = new SqlTiddlerStore({}); + sqlTiddlerStore.createTables(); + // Create bags and recipes + sqlTiddlerStore.saveBag("bag-alpha"); + sqlTiddlerStore.saveBag("bag-beta"); + sqlTiddlerStore.saveBag("bag-gamma"); + sqlTiddlerStore.saveRecipe("recipe-rho",["bag-alpha","bag-beta"]); + sqlTiddlerStore.saveRecipe("recipe-sigma",["bag-alpha","bag-gamma"]); + // Tear down + afterAll(function() { + // Close the database + sqlTiddlerStore.close(); + }); + // Run tests + it("should save and retrieve tiddlers", function() { + // Save tiddlers + sqlTiddlerStore.saveTiddler({title: "Another Tiddler",text: "I'm in alpha",tags: "one two three"},"bag-alpha"); + sqlTiddlerStore.saveTiddler({title: "Hello There",text: "I'm in alpha as well",tags: "one two three"},"bag-alpha"); + sqlTiddlerStore.saveTiddler({title: "Hello There",text: "I'm in beta",tags: "four five six"},"bag-beta"); + sqlTiddlerStore.saveTiddler({title: "Hello There",text: "I'm in gamma",tags: "seven eight nine"},"bag-gamma"); + // Verify what we've got + expect(sqlTiddlerStore.getRecipeTiddlers("recipe-rho")).toEqual([ "Another Tiddler", "Hello There"]); + expect(sqlTiddlerStore.getRecipeTiddlers("recipe-sigma")).toEqual([ "Another Tiddler", "Hello There"]); + expect(sqlTiddlerStore.getTiddler("Hello There","recipe-rho")).toEqual({ title: "Hello There", text: "I'm in beta", tags: "four five six" }); + expect(sqlTiddlerStore.getTiddler("Missing Tiddler","recipe-rho")).toEqual(null); + expect(sqlTiddlerStore.getTiddler("Another Tiddler","recipe-rho")).toEqual({ title: "Another Tiddler", text: "I'm in alpha", tags: "one two three" }); + expect(sqlTiddlerStore.getTiddler("Hello There","recipe-sigma")).toEqual({ title: "Hello There", text: "I'm in gamma", tags: "seven eight nine" }); + expect(sqlTiddlerStore.getTiddler("Another Tiddler","recipe-sigma")).toEqual({ title: "Another Tiddler", text: "I'm in alpha", tags: "one two three" }); + // Delete a tiddler to ensure the underlying tiddler in the recipe shows through + sqlTiddlerStore.deleteTiddler("Hello There","bag-beta"); + expect(sqlTiddlerStore.getRecipeTiddlers("recipe-rho")).toEqual([ "Another Tiddler", "Hello There"]); + expect(sqlTiddlerStore.getRecipeTiddlers("recipe-sigma")).toEqual([ "Another Tiddler", "Hello There"]); + expect(sqlTiddlerStore.getTiddler("Hello There","recipe-beta")).toEqual(null); + sqlTiddlerStore.deleteTiddler("Another Tiddler","bag-alpha"); + expect(sqlTiddlerStore.getRecipeTiddlers("recipe-rho")).toEqual([ "Hello There"]); + expect(sqlTiddlerStore.getRecipeTiddlers("recipe-sigma")).toEqual([ "Hello There"]); + }); +}); + +} + +})(); diff --git a/plugins/tiddlywiki/multiwikiserver/plugin.info b/plugins/tiddlywiki/multiwikiserver/plugin.info new file mode 100644 index 000000000..73536e90b --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/plugin.info @@ -0,0 +1,7 @@ +{ + "title": "$:/plugins/tiddlywiki/multiwikiserver", + "name": "Multi Wiki Server", + "description": "Multiple Wiki Server Extension", + "list": "readme", + "dependents": [] +}