Implement APIs for client wikis to sync with the server

It is now possible to create and edit tiddlers, using the existing tiddlywebadaptor syncing mechanism. There are a lot of hacks and lumpiness to make things compatible, so I think I will end up with an independent implementation
This commit is contained in:
Jeremy Ruston 2024-01-03 16:27:13 +00:00
parent 299781bdba
commit a980390870
11 changed files with 302 additions and 13 deletions

View File

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

View File

@ -7,7 +7,10 @@
"tiddlywiki/evernote",
"tiddlywiki/internals",
"tiddlywiki/menubar",
"tiddlywiki/qrcode"
"tiddlywiki/qrcode",
"tiddlywiki/tiddlyweb",
"tiddlywiki/filesystem",
"tiddlywiki/multiwikiserver"
],
"themes": [
"tiddlywiki/vanilla",

View File

@ -0,0 +1,39 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/route-delete-tiddler.js
type: application/javascript
module-type: route
DELETE /wikis/:recipe_name/recipes/:bag_name/tiddler/:title
NOTE: Urls currently include the recipe name twice. This is temporary to minimise the changes to the TiddlyWeb plugin
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "DELETE";
exports.path = /^\/wiki\/([^\/]+)\/bags\/([^\/]+)\/tiddlers\/([^\/]+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
bag_name = $tw.utils.decodeURIComponentSafe(state.params[1]),
title = $tw.utils.decodeURIComponentSafe(state.params[2]);
var recipeBags = $tw.sqlTiddlerStore.getRecipeBags(recipe_name);
if(recipeBags.indexOf(bag_name) !== -1) {
$tw.sqlTiddlerStore.deleteTiddler(title,bag_name);
response.writeHead(204, "OK", {
"Content-Type": "text/plain"
});
response.end();
} else {
response.writeHead(404);
response.end();
}
};
}());

View File

@ -0,0 +1,42 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/route-get-status.js
type: application/javascript
module-type: route
GET /wikis/:recipe_name/status
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "GET";
exports.path = /^\/wiki\/([^\/]+)\/status$/;
exports.handler = function(request,response,state) {
// Get the recipe name from the parameters
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]);
// Compose the response
var text = JSON.stringify({
username: "Joe Bloggs",
anonymous: false,
read_only: false,
logout_is_available: false,
space: {
recipe: recipe_name
},
tiddlywiki_version: $tw.version
});
// Send response
if(text) {
state.sendResponse(200,{"Content-Type": "application/json"},text,"utf8");
} else {
response.writeHead(404);
response.end();
}
};
}());

View File

@ -0,0 +1,50 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/route-get-tiddler.js
type: application/javascript
module-type: route
GET /wikis/:recipe_name/recipes/:recipe_name/tiddler/:title
NOTE: Urls currently include the recipe name twice. This is temporary to minimise the changes to the TiddlyWeb plugin
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "GET";
exports.path = /^\/wiki\/([^\/]+)\/recipes\/([^\/]+)\/tiddlers\/([^\/]+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
recipe_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]),
title = $tw.utils.decodeURIComponentSafe(state.params[2]);
if(recipe_name === recipe_name_2) {
var tiddler = $tw.sqlTiddlerStore.getTiddler(title,recipe_name),
tiddlerFields = {},
knownFields = [
"bag", "created", "creator", "modified", "modifier", "permissions", "recipe", "revision", "tags", "text", "title", "type", "uri"
];
$tw.utils.each(tiddler,function(value,name) {
if(knownFields.indexOf(name) !== -1) {
tiddlerFields[name] = value;
} else {
tiddlerFields.fields = tiddlerFields.fields || {};
tiddlerFields.fields[name] = value;
}
});
tiddlerFields.revision = "0";
tiddlerFields.bag = "bag-gamma";
tiddlerFields.type = tiddlerFields.type || "text/vnd.tiddlywiki";
state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(tiddlerFields),"utf8");
} else {
response.writeHead(404);
response.end();
}
};
}());

View File

@ -0,0 +1,44 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/route-get-tiddlers-json.js
type: application/javascript
module-type: route
PUT /wikis/:recipe_name/recipes/:recipe_name/tiddlers.json?filter=:filter
NOTE: Urls currently include the recipe name twice. This is temporary to minimise the changes to the TiddlyWeb plugin
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "GET";
exports.path = /^\/wiki\/([^\/]+)\/recipes\/([^\/]+)\/tiddlers.json$/;
exports.handler = function(request,response,state) {
// Get the parameters
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
recipe_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]);
if(recipe_name === recipe_name_2) {
// Get the tiddlers in the recipe
var titles = $tw.sqlTiddlerStore.getRecipeTiddlers(recipe_name);
// Get a skinny version of each tiddler
var tiddlers = [];
$tw.utils.each(titles,function(title) {
var tiddler = $tw.sqlTiddlerStore.getTiddler(title,recipe_name);
tiddlers.push(Object.assign({},tiddler,{text: undefined, revision: "0", bag: "bag-gamma"}));
});
var text = JSON.stringify(tiddlers);
state.sendResponse(200,{"Content-Type": "application/json"},text,"utf8");
return;
}
// Fail if something went wrong
response.writeHead(404);
response.end();
};
}());

View File

@ -1,5 +1,5 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/route-wiki.js
title: $:/plugins/tiddlywiki/multiwikiserver/route-get-wiki.js
type: application/javascript
module-type: route
@ -14,7 +14,7 @@ GET /wikis/:recipe_name
exports.method = "GET";
exports.path = /^\/wiki\/(.+)$/;
exports.path = /^\/wiki\/([^\/]+)$/;
exports.handler = function(request,response,state) {
// Get the recipe name from the parameters
@ -30,6 +30,7 @@ exports.handler = function(request,response,state) {
$:/boot/bootprefix.js
$:/core
$:/library/sjcl.js
$:/plugins/tiddlywiki/tiddlyweb
$:/themes/tiddlywiki/snowwhite
$:/themes/tiddlywiki/vanilla
`
@ -44,9 +45,12 @@ exports.handler = function(request,response,state) {
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)));
var tiddler = $tw.sqlTiddlerStore.getTiddler(title,recipe_name);
htmlParts.push(JSON.stringify(Object.assign({},tiddler,{revision: "0", bag: "bag-gamma"})));
htmlParts.push(",")
});
htmlParts.push(JSON.stringify({title: "$:/config/tiddlyweb/host",text: "$protocol$//$host$$pathname$/"}));
htmlParts.push(",")
htmlParts.push(template.substring(markerPos + marker.length))
// Send response
if(htmlParts) {

View File

@ -0,0 +1,58 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/route-put-tiddler.js
type: application/javascript
module-type: route
PUT /wikis/:recipe_name/recipes/:recipe_name/tiddlers/:title
NOTE: Urls currently include the recipe name twice. This is temporary to minimise the changes to the TiddlyWeb plugin
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "PUT";
exports.path = /^\/wiki\/([^\/]+)\/recipes\/([^\/]+)\/tiddlers\/([^\/]+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
recipe_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]),
title = $tw.utils.decodeURIComponentSafe(state.params[2]),
fields = $tw.utils.parseJSONSafe(state.data);
// Pull up any subfields in the `fields` object
if(typeof fields.fields === "object") {
$tw.utils.each(fields.fields,function(field,name) {
fields[name] = field;
});
delete fields.fields;
}
// Stringify any array fields
$tw.utils.each(fields,function(value,name) {
if($tw.utils.isArray(value)) {
fields[name] = $tw.utils.stringifyList(value);
}
});
// Require the recipe names to match
if(recipe_name === recipe_name_2) {
$tw.sqlTiddlerStore.saveRecipeTiddler(fields,recipe_name);
var recipe_bags = $tw.sqlTiddlerStore.getRecipeBags(recipe_name),
top_bag = recipe_bags[recipe_bags.length - 1];
response.writeHead(204, "OK",{
Etag: "\"" + top_bag + "/" + encodeURIComponent(title) + "/" + 2222 + ":\"",
"Content-Type": "text/plain"
});
response.end();
return;
}
// Fail if something went wrong
response.writeHead(404);
response.end();
};
}());

View File

@ -63,8 +63,8 @@ SqlTiddlerStore.prototype.createTables = function() {
recipe_id INTEGER,
bag_id INTEGER,
position INTEGER,
FOREIGN KEY (recipe_id) REFERENCES recipes(recipe_id),
FOREIGN KEY (bag_id) REFERENCES bags(bag_id),
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)
)
`,`
@ -73,7 +73,7 @@ SqlTiddlerStore.prototype.createTables = function() {
tiddler_id INTEGER PRIMARY KEY,
bag_id INTEGER,
title TEXT,
FOREIGN KEY (bag_id) REFERENCES bags(bag_id),
FOREIGN KEY (bag_id) REFERENCES bags(bag_id) ON UPDATE CASCADE ON DELETE CASCADE,
UNIQUE (bag_id, title)
)
`,`
@ -82,7 +82,7 @@ SqlTiddlerStore.prototype.createTables = function() {
tiddler_id INTEGER,
field_name TEXT,
field_value TEXT,
FOREIGN KEY (tiddler_id) REFERENCES tiddlers(tiddler_id),
FOREIGN KEY (tiddler_id) REFERENCES tiddlers(tiddler_id) ON UPDATE CASCADE ON DELETE CASCADE,
UNIQUE (tiddler_id, field_name)
)
`]);
@ -138,7 +138,7 @@ SqlTiddlerStore.prototype.saveRecipe = function(recipename,bagnames) {
};
SqlTiddlerStore.prototype.saveTiddler = function(tiddlerFields,bagname) {
// Run the queries
// Update the tiddlers table
this.runStatement(`
INSERT OR REPLACE INTO tiddlers (bag_id, title)
VALUES (
@ -149,6 +149,7 @@ SqlTiddlerStore.prototype.saveTiddler = function(tiddlerFields,bagname) {
title: tiddlerFields.title,
bag_name: bagname
});
// Update the fields table
this.runStatement(`
INSERT OR REPLACE INTO fields (tiddler_id, field_name, field_value)
SELECT
@ -172,6 +173,30 @@ SqlTiddlerStore.prototype.saveTiddler = function(tiddlerFields,bagname) {
});
};
SqlTiddlerStore.prototype.saveRecipeTiddler = function(tiddlerFields,recipename) {
// Find the topmost bag in the recipe
var row = this.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: recipename
});
// Save the tiddler to the topmost bag
this.saveTiddler(tiddlerFields,row.bag_name);
};
SqlTiddlerStore.prototype.deleteTiddler = function(title,bagname) {
// Run the queries
this.runStatement(`
@ -258,6 +283,26 @@ SqlTiddlerStore.prototype.getRecipeTiddlers = function(recipename) {
return rows.map(value => value.title);
};
/*
Get the names of the bags in a recipe. Returns an empty array for recipes that do not exist
*/
SqlTiddlerStore.prototype.getRecipeBags = function(recipename) {
const rows = this.runStatementGetAll(`
SELECT bags.bag_name
FROM bags
JOIN (
SELECT rb.bag_id
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
`,{
recipe_name: recipename
});
return rows.map(value => value.bag_name);
};
exports.SqlTiddlerStore = SqlTiddlerStore;
})();

View File

@ -45,7 +45,7 @@ describe("SQL tiddler store", function() {
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
// Delete a tiddlers 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"]);
@ -53,6 +53,10 @@ describe("SQL tiddler store", function() {
sqlTiddlerStore.deleteTiddler("Another Tiddler","bag-alpha");
expect(sqlTiddlerStore.getRecipeTiddlers("recipe-rho")).toEqual([ "Hello There"]);
expect(sqlTiddlerStore.getRecipeTiddlers("recipe-sigma")).toEqual([ "Hello There"]);
// Save a recipe tiddler
sqlTiddlerStore.saveRecipeTiddler({title: "More", text: "None"},"recipe-rho");
expect(sqlTiddlerStore.getTiddler("More","recipe-rho")).toEqual({title: "More", text: "None"});
});
});

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