Initial Commit

This commit is contained in:
Jeremy Ruston 2024-01-02 14:39:14 +00:00
parent d2d00ffa4d
commit 12d84c43c9
8 changed files with 457 additions and 2 deletions

View File

@ -14,7 +14,8 @@
"tiddlywiki/dynannotate",
"tiddlywiki/codemirror",
"tiddlywiki/menubar",
"tiddlywiki/jszip"
"tiddlywiki/jszip",
"tiddlywiki/multiwikiserver"
],
"themes": [
"tiddlywiki/vanilla",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
{
"title": "$:/plugins/tiddlywiki/multiwikiserver",
"name": "Multi Wiki Server",
"description": "Multiple Wiki Server Extension",
"list": "readme",
"dependents": []
}