From a9803908706a12e708d10972a324da2f7b9498b9 Mon Sep 17 00:00:00 2001 From: Jeremy Ruston Date: Wed, 3 Jan 2024 16:27:13 +0000 Subject: [PATCH] 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 --- editions/prerelease/tiddlywiki.info | 3 +- editions/tw5.com/tiddlywiki.info | 5 +- .../modules/route-delete-tiddler.js | 39 +++++++++++++ .../modules/route-get-status.js | 42 ++++++++++++++ .../modules/route-get-tiddler.js | 50 ++++++++++++++++ .../modules/route-get-tiddlers-json.js | 44 ++++++++++++++ .../{route-wiki.js => route-get-wiki.js} | 10 +++- .../modules/route-put-tiddler.js | 58 +++++++++++++++++++ .../modules/sql-tiddler-store.js | 55 ++++++++++++++++-- .../modules/tests-sql-tiddler-store.js | 6 +- .../tiddlywiki/tiddlyweb/tiddlywebadaptor.js | 3 +- 11 files changed, 302 insertions(+), 13 deletions(-) create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/route-delete-tiddler.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/route-get-status.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/route-get-tiddler.js create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/route-get-tiddlers-json.js rename plugins/tiddlywiki/multiwikiserver/modules/{route-wiki.js => route-get-wiki.js} (77%) create mode 100644 plugins/tiddlywiki/multiwikiserver/modules/route-put-tiddler.js diff --git a/editions/prerelease/tiddlywiki.info b/editions/prerelease/tiddlywiki.info index 060e4fb8a..168fbb41f 100644 --- a/editions/prerelease/tiddlywiki.info +++ b/editions/prerelease/tiddlywiki.info @@ -14,8 +14,7 @@ "tiddlywiki/dynannotate", "tiddlywiki/codemirror", "tiddlywiki/menubar", - "tiddlywiki/jszip", - "tiddlywiki/multiwikiserver" + "tiddlywiki/jszip" ], "themes": [ "tiddlywiki/vanilla", diff --git a/editions/tw5.com/tiddlywiki.info b/editions/tw5.com/tiddlywiki.info index 5ce9a2f1b..e2a15866b 100644 --- a/editions/tw5.com/tiddlywiki.info +++ b/editions/tw5.com/tiddlywiki.info @@ -7,7 +7,10 @@ "tiddlywiki/evernote", "tiddlywiki/internals", "tiddlywiki/menubar", - "tiddlywiki/qrcode" + "tiddlywiki/qrcode", + "tiddlywiki/tiddlyweb", + "tiddlywiki/filesystem", + "tiddlywiki/multiwikiserver" ], "themes": [ "tiddlywiki/vanilla", diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-delete-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/route-delete-tiddler.js new file mode 100644 index 000000000..419676308 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-delete-tiddler.js @@ -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(); + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-get-status.js b/plugins/tiddlywiki/multiwikiserver/modules/route-get-status.js new file mode 100644 index 000000000..438f76ec4 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-get-status.js @@ -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(); + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-get-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/route-get-tiddler.js new file mode 100644 index 000000000..7a9fcea91 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-get-tiddler.js @@ -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(); + } +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-get-tiddlers-json.js b/plugins/tiddlywiki/multiwikiserver/modules/route-get-tiddlers-json.js new file mode 100644 index 000000000..281534f09 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-get-tiddlers-json.js @@ -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(); + +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-wiki.js b/plugins/tiddlywiki/multiwikiserver/modules/route-get-wiki.js similarity index 77% rename from plugins/tiddlywiki/multiwikiserver/modules/route-wiki.js rename to plugins/tiddlywiki/multiwikiserver/modules/route-get-wiki.js index 883b16fcc..f520e53e8 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/route-wiki.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-get-wiki.js @@ -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) { diff --git a/plugins/tiddlywiki/multiwikiserver/modules/route-put-tiddler.js b/plugins/tiddlywiki/multiwikiserver/modules/route-put-tiddler.js new file mode 100644 index 000000000..e9d9d7ae1 --- /dev/null +++ b/plugins/tiddlywiki/multiwikiserver/modules/route-put-tiddler.js @@ -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(); + +}; + +}()); diff --git a/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js b/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js index 6c1aa59cf..de33b6c71 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/sql-tiddler-store.js @@ -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; })(); \ 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 index f85ab6407..abb77dba3 100644 --- a/plugins/tiddlywiki/multiwikiserver/modules/tests-sql-tiddler-store.js +++ b/plugins/tiddlywiki/multiwikiserver/modules/tests-sql-tiddler-store.js @@ -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"}); + }); }); diff --git a/plugins/tiddlywiki/tiddlyweb/tiddlywebadaptor.js b/plugins/tiddlywiki/tiddlyweb/tiddlywebadaptor.js index 15fbaa4fd..36758e873 100644 --- a/plugins/tiddlywiki/tiddlyweb/tiddlywebadaptor.js +++ b/plugins/tiddlywiki/tiddlyweb/tiddlywebadaptor.js @@ -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