From 1c230592045727263c5f84a86d70ba688e54e99a Mon Sep 17 00:00:00 2001 From: Jeremy Ruston Date: Mon, 16 Sep 2019 12:15:39 +0100 Subject: [PATCH] Dynamic loading/unloading of plugins (#4259) * First pass at dynamic loading/unloading * Show warning for changes to plugins containing JS modules * Use $:/config/RegisterPluginType/* for configuring whether a plugin type is automatically registered Where "registered" means "the constituent shadows are loaded". * Fix the info plugin The previous mechanism re-read all plugin info during startup * Don't prettify JSON in the plugin library * Indicate in plugin library whether a plugin requires reloading * Display the highlighted plugin name in the plugin chooser And if there's no name field fall back to the part of the title after the final slash. --- boot/boot.js | 55 +++++++++++++----- core/language/en-GB/ControlPanel.multids | 1 + core/language/en-GB/Misc.multids | 2 +- core/modules/commands/makelibrary.js | 2 +- core/modules/commands/savelibrarytiddlers.js | 10 +++- core/modules/startup/info.js | 8 ++- core/modules/startup/plugins.js | 59 ++++++++++++++++++++ core/modules/wiki.js | 20 +++++++ core/ui/ControlPanel/Modals/AddPlugins.tid | 3 +- core/ui/PageTemplate/pluginreloadwarning.tid | 2 +- core/wiki/config/RegisterPluginTypes.multids | 7 +++ 11 files changed, 145 insertions(+), 24 deletions(-) create mode 100644 core/modules/startup/plugins.js create mode 100644 core/wiki/config/RegisterPluginTypes.multids diff --git a/boot/boot.js b/boot/boot.js index 82583c594..9f867c48a 100644 --- a/boot/boot.js +++ b/boot/boot.js @@ -1237,15 +1237,39 @@ $tw.Wiki = function(options) { return null; }; - // Read plugin info for all plugins - this.readPluginInfo = function() { - for(var title in tiddlers) { - var tiddler = tiddlers[title]; - if(tiddler.fields.type === "application/json" && tiddler.hasField("plugin-type")) { - pluginInfo[tiddler.fields.title] = JSON.parse(tiddler.fields.text); + // Get an array of all the currently recognised plugin types + this.getPluginTypes = function() { + var types = []; + $tw.utils.each(pluginTiddlers,function(pluginTiddler) { + var pluginType = pluginTiddler.fields["plugin-type"]; + if(pluginType && types.indexOf(pluginType) === -1) { + types.push(pluginType); } + }); + return types; + }; - } + // Read plugin info for all plugins, or just an array of titles. Returns the number of plugins updated or deleted + this.readPluginInfo = function(titles) { + var results = { + modifiedPlugins: [], + deletedPlugins: [] + }; + $tw.utils.each(titles || getTiddlerTitles(),function(title) { + var tiddler = tiddlers[title]; + if(tiddler) { + if(tiddler.fields.type === "application/json" && tiddler.hasField("plugin-type")) { + pluginInfo[tiddler.fields.title] = JSON.parse(tiddler.fields.text); + results.modifiedPlugins.push(tiddler.fields.title); + } + } else { + if(pluginInfo[title]) { + delete pluginInfo[title]; + results.deletedPlugins.push(title); + } + } + }); + return results; }; // Get plugin info for a plugin @@ -1253,14 +1277,15 @@ $tw.Wiki = function(options) { return pluginInfo[title]; }; - // Register the plugin tiddlers of a particular type, optionally restricting registration to an array of tiddler titles. Return the array of titles affected + // Register the plugin tiddlers of a particular type, or null/undefined for any type, optionally restricting registration to an array of tiddler titles. Return the array of titles affected this.registerPluginTiddlers = function(pluginType,titles) { var self = this, registeredTitles = [], checkTiddler = function(tiddler,title) { - if(tiddler && tiddler.fields.type === "application/json" && tiddler.fields["plugin-type"] === pluginType) { + if(tiddler && tiddler.fields.type === "application/json" && tiddler.fields["plugin-type"] && (!pluginType || tiddler.fields["plugin-type"] === pluginType)) { var disablingTiddler = self.getTiddler("$:/config/Plugins/Disabled/" + title); if(title === "$:/core" || !disablingTiddler || (disablingTiddler.fields.text || "").trim() !== "yes") { + self.unregisterPluginTiddlers(null,[title]); // Unregister the plugin if it's already registered pluginTiddlers.push(tiddler); registeredTitles.push(tiddler.fields.title); } @@ -1278,19 +1303,19 @@ $tw.Wiki = function(options) { return registeredTitles; }; - // Unregister the plugin tiddlers of a particular type, returning an array of the titles affected - this.unregisterPluginTiddlers = function(pluginType) { + // Unregister the plugin tiddlers of a particular type, or null/undefined for any type, optionally restricting unregistering to an array of tiddler titles. Returns an array of the titles affected + this.unregisterPluginTiddlers = function(pluginType,titles) { var self = this, - titles = []; + unregisteredTitles = []; // Remove any previous registered plugins of this type for(var t=pluginTiddlers.length-1; t>=0; t--) { var tiddler = pluginTiddlers[t]; - if(tiddler.fields["plugin-type"] === pluginType) { - titles.push(tiddler.fields.title); + if(tiddler.fields["plugin-type"] && (!pluginType || tiddler.fields["plugin-type"] === pluginType) && (!titles || titles.indexOf(tiddler.fields.title) !== -1)) { + unregisteredTitles.push(tiddler.fields.title); pluginTiddlers.splice(t,1); } } - return titles; + return unregisteredTitles; }; // Unpack the currently registered plugins, creating shadow tiddlers for their constituent tiddlers diff --git a/core/language/en-GB/ControlPanel.multids b/core/language/en-GB/ControlPanel.multids index faa4d73c5..d1fd8f374 100644 --- a/core/language/en-GB/ControlPanel.multids +++ b/core/language/en-GB/ControlPanel.multids @@ -78,6 +78,7 @@ Plugins/NoInfoFound/Hint: No ''"<$text text=<>/>"'' found Plugins/NotInstalled/Hint: This plugin is not currently installed Plugins/OpenPluginLibrary: open plugin library Plugins/ClosePluginLibrary: close plugin library +Plugins/PluginWillRequireReload: (requires reload) Plugins/Plugins/Caption: Plugins Plugins/Plugins/Hint: Plugins Plugins/Reinstall/Caption: reinstall diff --git a/core/language/en-GB/Misc.multids b/core/language/en-GB/Misc.multids index 0f267186e..599b575eb 100644 --- a/core/language/en-GB/Misc.multids +++ b/core/language/en-GB/Misc.multids @@ -59,7 +59,7 @@ MissingTiddler/Hint: Missing tiddler "<$text text=<>/>" -- click No: No OfficialPluginLibrary: Official ~TiddlyWiki Plugin Library OfficialPluginLibrary/Hint: The official ~TiddlyWiki plugin library at tiddlywiki.com. Plugins, themes and language packs are maintained by the core team. -PluginReloadWarning: Please save {{$:/core/ui/Buttons/save-wiki}} and reload {{$:/core/ui/Buttons/refresh}} to allow changes to plugins to take effect +PluginReloadWarning: Please save {{$:/core/ui/Buttons/save-wiki}} and reload {{$:/core/ui/Buttons/refresh}} to allow changes to ~JavaScript plugins to take effect RecentChanges/DateFormat: DDth MMM YYYY SystemTiddler/Tooltip: This is a system tiddler SystemTiddlers/Include/Prompt: Include system tiddlers diff --git a/core/modules/commands/makelibrary.js b/core/modules/commands/makelibrary.js index bb149fd16..ce7150f0c 100644 --- a/core/modules/commands/makelibrary.js +++ b/core/modules/commands/makelibrary.js @@ -59,7 +59,7 @@ Command.prototype.execute = function() { title: upgradeLibraryTitle, type: "application/json", "plugin-type": "library", - "text": JSON.stringify({tiddlers: tiddlers},null,$tw.config.preferences.jsonSpaces) + "text": JSON.stringify({tiddlers: tiddlers}) }; wiki.addTiddler(new $tw.Tiddler(pluginFields)); return null; diff --git a/core/modules/commands/savelibrarytiddlers.js b/core/modules/commands/savelibrarytiddlers.js index 44feea071..b1874c9b5 100644 --- a/core/modules/commands/savelibrarytiddlers.js +++ b/core/modules/commands/savelibrarytiddlers.js @@ -65,10 +65,11 @@ Command.prototype.execute = function() { // Save each JSON file and collect the skinny data var pathname = path.resolve(self.commander.outputPath,basepath + encodeURIComponent(title) + ".json"); $tw.utils.createFileDirectories(pathname); - fs.writeFileSync(pathname,JSON.stringify(tiddler,null,$tw.config.preferences.jsonSpaces),"utf8"); + fs.writeFileSync(pathname,JSON.stringify(tiddler),"utf8"); // Collect the skinny list data var pluginTiddlers = JSON.parse(tiddler.text), readmeContent = (pluginTiddlers.tiddlers[title + "/readme"] || {}).text, + doesContainJavaScript = !!$tw.wiki.doesPluginInfoContainModules(pluginTiddlers), iconTiddler = pluginTiddlers.tiddlers[title + "/icon"] || {}, iconType = iconTiddler.type, iconText = iconTiddler.text, @@ -76,7 +77,12 @@ Command.prototype.execute = function() { if(iconType && iconText) { iconContent = $tw.utils.makeDataUri(iconText,iconType); } - skinnyList.push($tw.utils.extend({},tiddler,{text: undefined, readme: readmeContent, icon: iconContent})); + skinnyList.push($tw.utils.extend({},tiddler,{ + text: undefined, + readme: readmeContent, + "contains-javascript": doesContainJavaScript ? "yes" : "no", + icon: iconContent + })); }); // Save the catalogue tiddler if(skinnyListTitle) { diff --git a/core/modules/startup/info.js b/core/modules/startup/info.js index 1ebf568f4..7efaa5b0e 100644 --- a/core/modules/startup/info.js +++ b/core/modules/startup/info.js @@ -18,6 +18,8 @@ exports.before = ["startup"]; exports.after = ["load-modules"]; exports.synchronous = true; +var TITLE_INFO_PLUGIN = "$:/temp/info-plugin"; + exports.startup = function() { // Collect up the info tiddlers var infoTiddlerFields = {}; @@ -32,15 +34,15 @@ exports.startup = function() { }); } }); - // Bake the info tiddlers into a plugin + // Bake the info tiddlers into a plugin. We use the non-standard plugin-type "info" because ordinary plugins are only registered asynchronously after being loaded dynamically var fields = { - title: "$:/temp/info-plugin", + title: TITLE_INFO_PLUGIN, type: "application/json", "plugin-type": "info", text: JSON.stringify({tiddlers: infoTiddlerFields},null,$tw.config.preferences.jsonSpaces) }; $tw.wiki.addTiddler(new $tw.Tiddler(fields)); - $tw.wiki.readPluginInfo(); + $tw.wiki.readPluginInfo([TITLE_INFO_PLUGIN]); $tw.wiki.registerPluginTiddlers("info"); $tw.wiki.unpackPluginTiddlers(); }; diff --git a/core/modules/startup/plugins.js b/core/modules/startup/plugins.js new file mode 100644 index 000000000..5dd03bbf1 --- /dev/null +++ b/core/modules/startup/plugins.js @@ -0,0 +1,59 @@ +/*\ +title: $:/core/modules/startup/plugins.js +type: application/javascript +module-type: startup + +Startup logic concerned with managing plugins + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +// Export name and synchronous status +exports.name = "plugins"; +exports.after = ["load-modules"]; +exports.synchronous = true; + +var TITLE_REQUIRE_RELOAD_DUE_TO_PLUGIN_CHANGE = "$:/status/RequireReloadDueToPluginChange"; + +var PREFIX_CONFIG_REGISTER_PLUGIN_TYPE = "$:/config/RegisterPluginType/"; + +exports.startup = function() { + $tw.wiki.addTiddler({title: TITLE_REQUIRE_RELOAD_DUE_TO_PLUGIN_CHANGE,text: "no"}); + $tw.wiki.addEventListener("change",function(changes) { + var changesToProcess = [], + requireReloadDueToPluginChange = false; + $tw.utils.each(Object.keys(changes),function(title) { + var tiddler = $tw.wiki.getTiddler(title), + containsModules = $tw.wiki.doesPluginContainModules(title); + if(containsModules) { + requireReloadDueToPluginChange = true; + } else if(tiddler) { + var pluginType = tiddler.fields["plugin-type"]; + if($tw.wiki.getTiddlerText(PREFIX_CONFIG_REGISTER_PLUGIN_TYPE + (tiddler.fields["plugin-type"] || ""),"no") === "yes") { + changesToProcess.push(title); + } + } + }); + if(requireReloadDueToPluginChange) { + $tw.wiki.addTiddler({title: TITLE_REQUIRE_RELOAD_DUE_TO_PLUGIN_CHANGE,text: "yes"}); + } + // Read or delete the plugin info of the changed tiddlers + if(changesToProcess.length > 0) { + var changes = $tw.wiki.readPluginInfo(changesToProcess); + if(changes.modifiedPlugins.length > 0 || changes.deletedPlugins.length > 0) { + // (Re-)register any modified plugins + $tw.wiki.registerPluginTiddlers(null,changes.modifiedPlugins); + // Unregister any deleted plugins + $tw.wiki.unregisterPluginTiddlers(null,changes.deletedPlugins); + // Unpack the shadow tiddlers + $tw.wiki.unpackPluginTiddlers(); + } + } + }); +}; + +})(); diff --git a/core/modules/wiki.js b/core/modules/wiki.js index a4bc7b650..c531440a3 100755 --- a/core/modules/wiki.js +++ b/core/modules/wiki.js @@ -1450,5 +1450,25 @@ exports.invokeUpgraders = function(titles,tiddlers) { return messages; }; +// Determine whether a plugin by title contains JS modules. +exports.doesPluginContainModules = function(title) { + return this.doesPluginInfoContainModules(this.getPluginInfo(title) || this.getTiddlerDataCached(title)); +}; + +// Determine whether a plugin info structure contains JS modules. +exports.doesPluginInfoContainModules = function(pluginInfo) { + if(pluginInfo) { + var foundModule = false; + $tw.utils.each(pluginInfo.tiddlers,function(tiddler) { + if(tiddler.type === "application/javascript" && $tw.utils.hop(tiddler,"module-type")) { + foundModule = true; + } + }); + return foundModule; + } else { + return null; + } +}; + })(); diff --git a/core/ui/ControlPanel/Modals/AddPlugins.tid b/core/ui/ControlPanel/Modals/AddPlugins.tid index b7f84a3c6..0fe8af20d 100644 --- a/core/ui/ControlPanel/Modals/AddPlugins.tid +++ b/core/ui/ControlPanel/Modals/AddPlugins.tid @@ -7,6 +7,7 @@ subtitle: {{$:/core/images/download-button}} {{$:/language/ControlPanel/Plugins/ <$list filter="[get[original-title]get[version]]" variable="installedVersion" emptyMessage="""{{$:/language/ControlPanel/Plugins/Install/Caption}}"""> {{$:/language/ControlPanel/Plugins/Reinstall/Caption}} +<$reveal stateTitle=<> stateField="contains-javascript" type="match" text="yes">{{$:/language/ControlPanel/Plugins/PluginWillRequireReload}} \end @@ -35,7 +36,7 @@ $:/state/add-plugin-info/$(connectionTiddler)$/$(assetInfo)$
-

<$view tiddler=<> field="description"/>

+

<$text text={{{ [get[name]] ~[get[original-title]split[/]last[1]] }}}/>: <$view tiddler=<> field="description"/>

<$view tiddler=<> field="original-title"/>

<$view tiddler=<> field="version"/>
diff --git a/core/ui/PageTemplate/pluginreloadwarning.tid b/core/ui/PageTemplate/pluginreloadwarning.tid index d7aa21fe4..4ac44ddc9 100644 --- a/core/ui/PageTemplate/pluginreloadwarning.tid +++ b/core/ui/PageTemplate/pluginreloadwarning.tid @@ -3,7 +3,7 @@ tags: $:/tags/PageTemplate \define lingo-base() $:/language/ -<$list filter="[has[plugin-type]haschanged[]!plugin-type[import]limit[1]]"> +<$list filter="[{$:/status/RequireReloadDueToPluginChange}match[yes]]"> <$reveal type="nomatch" state="$:/temp/HidePluginWarning" text="yes"> diff --git a/core/wiki/config/RegisterPluginTypes.multids b/core/wiki/config/RegisterPluginTypes.multids new file mode 100644 index 000000000..d2113728b --- /dev/null +++ b/core/wiki/config/RegisterPluginTypes.multids @@ -0,0 +1,7 @@ +title: $:/config/RegisterPluginType/ + +plugin: yes +theme: yes +language: yes +info: no +import: no