Introduce multiwikiclient plugin

Routes are now rationalised, too.
This commit is contained in:
Jeremy Ruston 2024-03-20 15:13:50 +00:00
parent 38ee942d8f
commit 9b3ca525ee
45 changed files with 679 additions and 166 deletions

View File

@ -3,6 +3,7 @@
"plugins": [
"tiddlywiki/tiddlyweb",
"tiddlywiki/filesystem",
"tiddlywiki/multiwikiclient",
"tiddlywiki/multiwikiserver"
],
"themes": [

View File

@ -0,0 +1,16 @@
title: GettingStarted
tags: $:/tags/GettingStarted
caption: Step 1<br>Syncing
Welcome to ~TiddlyWiki and the ~TiddlyWiki community
Visit https://tiddlywiki.com/ to find out more about ~TiddlyWiki and what it can do.
! Syncing Changes to the Server
Before you can start storing important information in ~TiddlyWiki it is important to make sure that your changes are being reliably saved by the server.
# Create a new tiddler using the {{$:/core/images/new-button}} button in the sidebar on the right
# Click the {{$:/core/images/done-button}} button at the top right of the new tiddler
# Check the ~TiddlyWiki command line for a message confirming the tiddler has been saved
# Refresh the page in the browser to and verify that the new tiddler has been correctly saved

View File

@ -0,0 +1,3 @@
title: $:/config/SaveWikiButton/Template
$:/plugins/tiddlywiki/multiwikiclient/save/offline

View File

@ -0,0 +1,2 @@
title: $:/config/Server/ExternalFilters/[all[tiddlers]] -[[$:/isEncrypted]] -[prefix[$:/temp/]] -[prefix[$:/status/]] -[[$:/boot/boot.js]] -[[$:/boot/bootprefix.js]] -[[$:/library/sjcl.js]] -[[$:/core]]
text: yes

View File

@ -0,0 +1,7 @@
title: $:/config/OfficialPluginLibrary
tags: $:/tags/PluginLibrary
url: https://tiddlywiki.com/library/v5.1.23/index.html
caption: {{$:/language/OfficialPluginLibrary}}
enabled: no
The official plugin library is disabled when using the client-server configuration. Instead, plugins should be installed via the `tiddlywiki.info` file, as described [[here|https://tiddlywiki.com/#Installing%20a%20plugin%20from%20the%20plugin%20library]].

View File

@ -0,0 +1,7 @@
title: $:/core/templates/css-tiddler
<!--
This template is used for saving CSS tiddlers as a style tag with data attributes representing the tiddler fields. This version includes the tiddler changecount as the field `revision`.
-->`<style`<$fields exclude='text revision bag' template=' data-tiddler-$name$="$encoded_value$"'></$fields>` data-tiddler-revision="`<<changecount>>`" data-tiddler-bag="default" type="text/css">`<$view field="text" format="text" />`</style>`

View File

@ -0,0 +1,9 @@
title: $:/core/templates/html-div-skinny-tiddler
<!--
This template is a variant of the tiddlyweb plugin's overridden version of $:/core/templates/html-div-tiddler used for saving skinny tiddlers (with no text field)
-->`<div`<$fields exclude='text revision bag' template=' $name$="$encoded_value$"'></$fields>` revision="`<<changecount>>`" bag="default" _is_skinny="">
<pre></pre>
</div>`

View File

@ -0,0 +1,9 @@
title: $:/core/templates/html-div-tiddler
<!--
This template is used for saving tiddlers as an HTML DIV tag with attributes representing the tiddler fields. This version includes the tiddler changecount as the field `revision`.
-->`<div`<$fields exclude='text revision bag' template=' $name$="$encoded_value$"'></$fields>` revision="`<<changecount>>`" bag="default">
<pre>`<$view field="text" format="htmltextencoded" />`</pre>
</div>`

View File

@ -0,0 +1,3 @@
title: $:/core/templates/html-json-skinny-tiddler
<$text text=<<join>>/><$jsontiddler tiddler=<<currentTiddler>> exclude="text" escapeUnsafeScriptChars="yes" $revision=<<changecount>> $bag="default" $_is_skinny=""/>

View File

@ -0,0 +1,3 @@
title: $:/core/templates/html-json-tiddler
<$jsontiddler tiddler=<<currentTiddler>> escapeUnsafeScriptChars="yes" $revision=<<changecount>> $bag="default"/>

View File

@ -0,0 +1,4 @@
title: $:/plugins/tiddlywiki/multiwikiclient/icon/cloud
tags: $:/tags/Image
<svg class="tc-image-cloud tc-image-button" width="22pt" height="22pt" viewBox="0 0 128 128"><g><path d="M24 103C10.745 103 0 92.255 0 79c0-9.697 5.75-18.05 14.027-21.836A24.787 24.787 0 0114 56c0-13.255 10.745-24 24-24 1.373 0 2.718.115 4.028.337C48.628 24.2 58.707 19 70 19c19.882 0 36 16.118 36 36v.082c12.319 1.016 22 11.336 22 23.918 0 12.239-9.16 22.337-20.999 23.814L107 103H24z"/><path class="tc-image-cloud-idle" d="M57.929 84.698a6 6 0 01-8.485 0L35.302 70.556a6 6 0 118.485-8.485l9.9 9.9L81.97 43.686a6 6 0 018.485 8.486L57.929 84.698z"/><path class="tc-image-cloud-progress tc-animate-rotate-slow" d="M44.8 40a3.6 3.6 0 100 7.2h2.06A23.922 23.922 0 0040 64c0 13.122 10.531 23.785 23.603 23.997L64 88l.001-7.2c-9.171 0-16.626-7.348-16.798-16.477L47.2 64c0-5.165 2.331-9.786 5.999-12.868L53.2 55.6a3.6 3.6 0 107.2 0v-12a3.6 3.6 0 00-3.6-3.6h-12zM64 40v7.2c9.278 0 16.8 7.522 16.8 16.8 0 5.166-2.332 9.787-6 12.869V72.4a3.6 3.6 0 10-7.2 0v12a3.6 3.6 0 003.6 3.6h12a3.6 3.6 0 100-7.2l-2.062.001A23.922 23.922 0 0088 64c0-13.255-10.745-24-24-24z"/></g></svg>

View File

@ -0,0 +1,7 @@
title: $:/core/templates/javascript-tiddler
<!--
This template is used for saving JavaScript tiddlers as a script tag with data attributes representing the tiddler fields. This version includes the tiddler changecount as the field `revision`.
-->`<script`<$fields exclude='text revision bag' template=' data-tiddler-$name$="$encoded_value$"'></$fields>` data-tiddler-revision="`<<changecount>>`" data-tiddler-bag="default" type="text/javascript">`<$view field="text" format="text" />`</script>`

View File

@ -0,0 +1,351 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiclient/multiwikiclientadaptor.js
type: application/javascript
module-type: syncadaptor
A sync adaptor module for synchronising with MultiWikiServer-compatible servers
\*/
(function(){
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
var CONFIG_HOST_TIDDLER = "$:/config/multiwikiclient/host",
DEFAULT_HOST_TIDDLER = "$protocol$//$host$/";
function MultiWikiClientAdaptor(options) {
this.wiki = options.wiki;
this.host = this.getHost();
this.recipe = this.wiki.getTiddlerText("$:/config/multiwikiclient/recipe");
this.logger = new $tw.utils.Logger("MultiWikiClientAdaptor");
this.isLoggedIn = false;
this.isReadOnly = false;
this.logoutIsAvailable = true;
}
MultiWikiClientAdaptor.prototype.name = "multiwikiclient";
MultiWikiClientAdaptor.prototype.supportsLazyLoading = true;
MultiWikiClientAdaptor.prototype.setLoggerSaveBuffer = function(loggerForSaving) {
this.logger.setSaveBuffer(loggerForSaving);
};
MultiWikiClientAdaptor.prototype.isReady = function() {
return true;
};
MultiWikiClientAdaptor.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: "pathname", value: document.location.pathname}
];
for(var t=0; t<substitutions.length; t++) {
var s = substitutions[t];
text = $tw.utils.replaceString(text,new RegExp("\\$" + s.name + "\\$","mg"),s.value);
}
return text;
};
MultiWikiClientAdaptor.prototype.getTiddlerInfo = function(tiddler) {
return {
bag: tiddler.fields.bag
};
};
MultiWikiClientAdaptor.prototype.getTiddlerRevision = function(title) {
var tiddler = this.wiki.getTiddler(title);
return tiddler.fields.revision;
};
/*
Get the current status of the TiddlyWeb connection
*/
MultiWikiClientAdaptor.prototype.getStatus = function(callback) {
// Invoke the callback if present
if(callback) {
callback(
null, // Error
true, // Is logged in
this.username, // Username
false, // Is read only
true // Is anonymous
);
}
};
/*
Attempt to login and invoke the callback(err)
*/
MultiWikiClientAdaptor.prototype.login = function(username,password,callback) {
var options = {
url: this.host + "challenge/tiddlywebplugins.tiddlyspace.cookie_form",
type: "POST",
data: {
user: username,
password: password,
tiddlyweb_redirect: "/status" // workaround to marginalize automatic subsequent GET
},
callback: function(err) {
callback(err);
},
headers: {
"accept": "application/json",
"X-Requested-With": "TiddlyWiki"
}
};
this.logger.log("Logging in:",options);
$tw.utils.httpRequest(options);
};
/*
*/
MultiWikiClientAdaptor.prototype.logout = function(callback) {
if(this.logoutIsAvailable) {
var options = {
url: this.host + "logout",
type: "POST",
data: {
csrf_token: this.getCsrfToken(),
tiddlyweb_redirect: "/status" // workaround to marginalize automatic subsequent GET
},
callback: function(err,data,xhr) {
callback(err);
},
headers: {
"accept": "application/json",
"X-Requested-With": "TiddlyWiki"
}
};
this.logger.log("Logging out:",options);
$tw.utils.httpRequest(options);
} else {
alert("This server does not support logging out. If you are using basic authentication the only way to logout is close all browser windows");
callback(null);
}
};
/*
Retrieve the CSRF token from its cookie
*/
MultiWikiClientAdaptor.prototype.getCsrfToken = function() {
var regex = /^(?:.*; )?csrf_token=([^(;|$)]*)(?:;|$)/,
match = regex.exec(document.cookie),
csrf = null;
if (match && (match.length === 2)) {
csrf = match[1];
}
return csrf;
};
/*
Get an array of skinny tiddler fields from the server
*/
MultiWikiClientAdaptor.prototype.getSkinnyTiddlers = function(callback) {
var self = this;
$tw.utils.httpRequest({
url: this.host + "recipes/" + this.recipe + "/tiddlers.json",
data: {
filter: "[all[tiddlers]] -[[$:/isEncrypted]] -[prefix[$:/temp/]] -[prefix[$:/status/]] -[[$:/boot/boot.js]] -[[$:/boot/bootprefix.js]] -[[$:/library/sjcl.js]] -[[$:/core]]"
},
callback: function(err,data) {
// Check for errors
if(err) {
return callback(err);
}
// Process the tiddlers to make sure the revision is a string
var tiddlers = JSON.parse(data);
for(var t=0; t<tiddlers.length; t++) {
tiddlers[t] = self.convertTiddlerFromTiddlyWebFormat(tiddlers[t]);
}
// Invoke the callback with the skinny tiddlers
callback(null,tiddlers);
// If Browswer Storage tiddlers were cached on reloading the wiki, add them after sync from server completes in the above callback.
if($tw.browserStorage && $tw.browserStorage.isEnabled()) {
$tw.browserStorage.addCachedTiddlers();
}
}
});
};
/*
Save a tiddler and invoke the callback with (err,adaptorInfo,revision)
*/
MultiWikiClientAdaptor.prototype.saveTiddler = function(tiddler,callback,options) {
var self = this;
if(this.isReadOnly) {
return callback(null);
}
$tw.utils.httpRequest({
url: this.host + "recipes/" + encodeURIComponent(this.recipe) + "/tiddlers/" + encodeURIComponent(tiddler.fields.title),
type: "PUT",
headers: {
"Content-type": "application/json"
},
data: this.convertTiddlerToTiddlyWebFormat(tiddler),
callback: function(err,data,request) {
if(err) {
return callback(err);
}
//If Browser-Storage plugin is present, remove tiddler from local storage after successful sync to the server
if($tw.browserStorage && $tw.browserStorage.isEnabled()) {
$tw.browserStorage.removeTiddlerFromLocalStorage(tiddler.fields.title)
}
// Save the details of the new revision of the tiddler
var etag = request.getResponseHeader("Etag");
if(!etag) {
callback("Response from server is missing required `etag` header");
} else {
var etagInfo = self.parseEtag(etag);
// Invoke the callback
callback(null,{
bag: etagInfo.bag
},etagInfo.revision);
}
}
});
};
/*
Load a tiddler and invoke the callback with (err,tiddlerFields)
*/
MultiWikiClientAdaptor.prototype.loadTiddler = function(title,callback) {
var self = this;
$tw.utils.httpRequest({
url: this.host + "recipes/" + encodeURIComponent(this.recipe) + "/tiddlers/" + encodeURIComponent(title),
callback: function(err,data,request) {
if(err) {
return callback(err);
}
// Invoke the callback
callback(null,self.convertTiddlerFromTiddlyWebFormat(JSON.parse(data)));
}
});
};
/*
Delete a tiddler and invoke the callback with (err)
options include:
tiddlerInfo: the syncer's tiddlerInfo for this tiddler
*/
MultiWikiClientAdaptor.prototype.deleteTiddler = function(title,callback,options) {
var self = this;
if(this.isReadOnly) {
return callback(null);
}
// If we don't have a bag it means that the tiddler hasn't been seen by the server, so we don't need to delete it
var bag = options.tiddlerInfo.adaptorInfo && options.tiddlerInfo.adaptorInfo.bag;
if(!bag) {
return callback(null,options.tiddlerInfo.adaptorInfo);
}
// Issue HTTP request to delete the tiddler
$tw.utils.httpRequest({
url: this.host + "bags/" + encodeURIComponent(bag) + "/tiddlers/" + encodeURIComponent(title),
type: "DELETE",
callback: function(err,data,request) {
if(err) {
return callback(err);
}
// Invoke the callback & return null adaptorInfo
callback(null,null);
}
});
};
/*
Convert a tiddler to a field set suitable for PUTting to TiddlyWeb
*/
MultiWikiClientAdaptor.prototype.convertTiddlerToTiddlyWebFormat = function(tiddler) {
var result = {},
knownFields = [
"bag", "created", "creator", "modified", "modifier", "permissions", "recipe", "revision", "tags", "text", "title", "type", "uri"
];
if(tiddler) {
$tw.utils.each(tiddler.fields,function(fieldValue,fieldName) {
var fieldString = fieldName === "tags" ?
tiddler.fields.tags :
tiddler.getFieldString(fieldName); // Tags must be passed as an array, not a string
if(knownFields.indexOf(fieldName) !== -1) {
// If it's a known field, just copy it across
result[fieldName] = fieldString;
} else {
// If it's unknown, put it in the "fields" field
result.fields = result.fields || {};
result.fields[fieldName] = fieldString;
}
});
}
// Default the content type
result.type = result.type || "text/vnd.tiddlywiki";
return JSON.stringify(result,null,$tw.config.preferences.jsonSpaces);
};
/*
Convert a field set in TiddlyWeb format into ordinary TiddlyWiki5 format
*/
MultiWikiClientAdaptor.prototype.convertTiddlerFromTiddlyWebFormat = function(tiddlerFields) {
var self = this,
result = {};
// Transfer the fields, pulling down the `fields` hashmap
$tw.utils.each(tiddlerFields,function(element,title,object) {
if(title === "fields") {
$tw.utils.each(element,function(element,subTitle,object) {
result[subTitle] = element;
});
} else {
result[title] = tiddlerFields[title];
}
});
// Make sure the revision is expressed as a string
if(typeof result.revision === "number") {
result.revision = result.revision.toString();
}
// Some unholy freaking of content types
if(result.type === "text/javascript") {
result.type = "application/javascript";
} else if(!result.type || result.type === "None") {
result.type = "text/x-tiddlywiki";
}
return result;
};
/*
Split a TiddlyWeb Etag into its constituent parts. For example:
```
"system-images_public/unsyncedIcon/946151:9f11c278ccde3a3149f339f4a1db80dd4369fc04"
```
Note that the value includes the opening and closing double quotes.
The parts are:
```
<bag>/<title>/<revision>:<hash>
```
*/
MultiWikiClientAdaptor.prototype.parseEtag = function(etag) {
var firstSlash = etag.indexOf("/"),
lastSlash = etag.lastIndexOf("/"),
colon = etag.lastIndexOf(":");
if(firstSlash === -1 || lastSlash === -1 || colon === -1) {
return null;
} else {
return {
bag: $tw.utils.decodeURIComponentSafe(etag.substring(1,firstSlash)),
title: $tw.utils.decodeURIComponentSafe(etag.substring(firstSlash + 1,lastSlash)),
revision: etag.substring(lastSlash + 1,colon)
};
}
};
if($tw.browser && document.location.protocol.substr(0,4) === "http" ) {
exports.adaptorClass = MultiWikiClientAdaptor;
}
})();

View File

@ -0,0 +1,7 @@
{
"title": "$:/plugins/tiddlywiki/multiwikiclient",
"name": "MultiWikiClient",
"description": "Synchronise changes from the browser to TiddlyWiki ~MultiWikiServer",
"list": "readme",
"plugin-priority": 10
}

View File

@ -0,0 +1,8 @@
title: $:/plugins/tiddlywiki/multiwikiclient/readme
This plugin runs in the browser to synchronise tiddler changes to and from a TiddlyWiki server running ~MultiWikiServer.
This plugin is inert when run under Node.js. Disabling this plugin via the browser can not be undone via the browser since this plugin provides the mechanism to synchronize settings with the server.
Changes made while offline are saved in memory and automatically synchonised with the server when the connection is re-established. However, if the browser tab is closed or another URL is loaded, the in-memory changes will be lost. The [[https://tiddlywiki.com/#BrowserStorage Plugin]] may be added to provide temporary filesystem storage of tiddler changes made while offline and enable them to be synchronised with the server the next time the wiki is loaded in the same browser.

View File

@ -0,0 +1,27 @@
title: $:/plugins/tiddlywiki/multiwikiclient/readonly
tags: [[$:/tags/Stylesheet]]
\define button-selector(title)
button.$title$, .tc-drop-down button.$title$, div.$title$
\end
\define hide-edit-controls()
<$reveal state="$:/status/IsReadOnly" type="match" text="yes" default="yes">
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fclone>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fdelete>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fedit>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-here>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-journal-here>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fimport>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fmanager>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-image>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-journal>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-tiddler>> `{
display: none;
}`
</$reveal>
\end
\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline macrocallblock
<<hide-edit-controls>>

View File

@ -0,0 +1,7 @@
title: $:/plugins/tiddlywiki/multiwikiclient/save/offline
\import [subfilter{$:/core/config/GlobalImportFilter}]
\define saveTiddlerFilter()
[is[tiddler]] -[[$:/boot/boot.css]] -[prefix[$:/HistoryList]] -[status[pending]plugin-type[import]] -[type[application/javascript]library[yes]] -[[$:/boot/boot.js]] -[[$:/boot/bootprefix.js]] -[[$:/plugins/tiddlywiki/filesystem]] -[[$:/plugins/tiddlywiki/multiwikiclient]] -[prefix[$:/temp/]] +[sort[title]] $(publishFilter)$
\end
{{$:/core/templates/tiddlywiki5.html}}

View File

@ -0,0 +1,26 @@
title: $:/core/ui/Buttons/save-wiki
tags: $:/tags/PageControls
caption: {{$:/plugins/tiddlywiki/multiwikiclient/icon/cloud}} Server status
description: Status of synchronisation with server
\whitespace trim
\define config-title()
$:/config/PageControlButtons/Visibility/$(listItem)$
\end
<$button popup=<<qualify "$:/state/popup/save-wiki">> tooltip="Status of synchronisation with server" aria-label="Server status" class=<<tv-config-toolbar-class>> selectedClass="tc-selected">
<span class="tc-dirty-indicator">
<$list filter="[<tv-config-toolbar-icons>match[yes]]">
{{$:/plugins/tiddlywiki/multiwikiclient/icon/cloud}}
</$list>
<$list filter="[<tv-config-toolbar-text>match[yes]]">
<span class="tc-btn-text"><$text text="Server status"/></span>
</$list>
</span>
</$button>
<$reveal state=<<qualify "$:/state/popup/save-wiki">> type="popup" position="belowleft" animate="yes">
<div class="tc-drop-down">
<$list filter="[all[shadows+tiddlers]tag[$:/tags/SyncerDropdown]!has[draft.of]]" variable="listItem">
<$transclude tiddler=<<listItem>>/>
</$list>
</div>
</$reveal>

View File

@ -0,0 +1,44 @@
title: $:/plugins/tiddlywiki/multiwikiclient/styles
tags: [[$:/tags/Stylesheet]]
\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline macrocallblock
body.tc-dirty span.tc-dirty-indicator svg {
transition: fill 250ms ease-in-out;
}
body .tc-image-cloud-idle {
fill: <<colour background>>;
transition: opacity 250ms ease-in-out;
opacity: 1;
display: unset;
}
body.tc-dirty .tc-image-cloud-idle {
opacity: 0;
display: none;
}
body .tc-image-cloud-progress {
transition: opacity 250ms ease-in-out;
transform-origin: 50% 50%;
transform: rotate(359deg);
animation: animation-rotate-slow 2s infinite linear;
fill: <<colour background>>;
display: none;
opacity: 0;
}
body.tc-dirty .tc-image-cloud-progress {
opacity: 1;
display: unset;
}
@keyframes animation-rotate-slow {
from {
transform: rotate(0deg);
}
to {
transform: scale(359deg);
}
}

View File

@ -0,0 +1,6 @@
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/copy-logs
tags: $:/tags/SyncerDropdown
<$button message="tm-copy-syncer-logs-to-clipboard" class="tc-btn-invisible">
{{$:/core/images/copy-clipboard}} Copy syncer logs to clipboard
</$button>

View File

@ -0,0 +1,9 @@
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login-status
tags: $:/tags/SyncerDropdown
<$reveal state="$:/status/IsLoggedIn" type="match" text="yes">
<div class="tc-drop-down-info">
You are logged in<$reveal state="$:/status/UserName" type="nomatch" text="" default=""> as <strong><$text text={{$:/status/UserName}}/></strong></$reveal><$reveal state="$:/status/IsReadOnly" type="match" text="yes" default="no"> (read-only)</$reveal>
</div>
<hr/>
</$reveal>

View File

@ -0,0 +1,8 @@
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login
tags: $:/tags/SyncerDropdown
<$reveal state="$:/status/IsLoggedIn" type="nomatch" text="yes">
<$button message="tm-login" class="tc-btn-invisible">
{{$:/core/images/unlocked-padlock}} Login
</$button>
</$reveal>

View File

@ -0,0 +1,8 @@
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/logout
tags: $:/tags/SyncerDropdown
<$reveal state="$:/status/IsLoggedIn" type="match" text="yes">
<$button message="tm-logout" class="tc-btn-invisible">
{{$:/core/images/cancel-button}} Logout
</$button>
</$reveal>

View File

@ -0,0 +1,9 @@
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/refresh
tags: $:/tags/SyncerDropdown
<$reveal state="$:/status/IsLoggedIn" type="match" text="yes">
<$button tooltip="Get latest changes from the server" aria-label="Refresh from server" class="tc-btn-invisible">
<$action-sendmessage $message="tm-server-refresh"/>
{{$:/core/images/refresh-button}}<span class="tc-btn-text"><$text text="Get latest changes from the server"/></span>
</$button>
</$reveal>

View File

@ -0,0 +1,9 @@
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/save-snapshot
tags: $:/tags/SyncerDropdown
<$button class="tc-btn-invisible">
<$wikify name="site-title" text={{$:/config/SaveWikiButton/Filename}}>
<$action-sendmessage $message="tm-download-file" $param={{$:/config/SaveWikiButton/Template}} filename=<<site-title>>/>
</$wikify>
{{$:/core/images/download-button}} Save snapshot for offline use
</$button>

View File

@ -0,0 +1,2 @@
title: $:/tags/SyncerDropdown
list: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login-status $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/refresh $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/logout $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/save-snapshot $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/copy-logs

View File

@ -113,14 +113,15 @@ TestRunner.prototype.runTest = function(testSpec,callback) {
const testSpecs = [
{
description: "Check server status",
description: "Check index page",
method: "GET",
path: "/wiki/recipe-alpha/status",
path: "/",
headers: {
accept: "*/*"
},
expectedResult: (jsonData,data) => {
return jsonData.username === "Joe Bloggs";
expectedResult: (jsonData,data,headers) => {
console.log(JSON.stringify(data).slice(1,100))
return JSON.stringify(data).slice(1,100) === "\\n<!doctype html>\\n<head>\\n\\t<meta http-equiv=\\\"Content-Type\\\" content=\\\"text/html;charset=utf-8\\\" ";
}
},
{

View File

@ -0,0 +1,35 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/delete-bag-tiddler.js
type: application/javascript
module-type: mws-route
DELETE /bags/:bag_name/tiddler/:title
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "DELETE";
exports.path = /^\/bags\/([^\/]+)\/tiddlers\/(.+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
title = $tw.utils.decodeURIComponentSafe(state.params[1]);
if(bag_name) {
$tw.mws.store.deleteTiddler(title,bag_name);
response.writeHead(204, "OK", {
"Content-Type": "text/plain"
});
response.end();
} else {
response.writeHead(404);
response.end();
}
};
}());

View File

@ -1,39 +0,0 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/delete-recipe-tiddler.js
type: application/javascript
module-type: mws-route
DELETE /wiki/: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.mws.store.getRecipeBags(recipe_name);
if(recipeBags.indexOf(bag_name) !== -1) {
$tw.mws.store.deleteTiddler(title,bag_name);
response.writeHead(204, "OK", {
"Content-Type": "text/plain"
});
response.end();
} else {
response.writeHead(404);
response.end();
}
};
}());

View File

@ -3,7 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-bag-tiddler-blo
type: application/javascript
module-type: mws-route
GET /wiki/:bag_name/bags/:bag_name/tiddler/:title/blob
GET /bags/:bag_name/tiddler/:title/blob
\*/
(function() {
@ -14,14 +14,13 @@ GET /wiki/:bag_name/bags/:bag_name/tiddler/:title/blob
exports.method = "GET";
exports.path = /^\/wiki\/([^\/]+)\/bags\/([^\/]+)\/tiddlers\/([^\/]+)\/blob$/;
exports.path = /^\/bags\/([^\/]+)\/tiddlers\/([^\/]+)\/blob$/;
exports.handler = function(request,response,state) {
// Get the parameters
const bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
bag_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]),
title = $tw.utils.decodeURIComponentSafe(state.params[2]);
if(bag_name === bag_name_2) {
title = $tw.utils.decodeURIComponentSafe(state.params[1]);
if(bag_name) {
const result = $tw.mws.store.getBagTiddlerStream(title,bag_name);
if(result) {
response.writeHead(200, "OK",{

View File

@ -3,9 +3,8 @@ title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-bag-tiddler.js
type: application/javascript
module-type: mws-route
GET /wiki/:bag_name/bags/:bag_name/tiddler/:title
GET /bags/:bag_name/tiddler/:title
NOTE: Urls currently include the bag name twice. This is temporary to minimise the changes to the TiddlyWeb plugin
\*/
(function() {
@ -16,15 +15,14 @@ NOTE: Urls currently include the bag name twice. This is temporary to minimise t
exports.method = "GET";
exports.path = /^\/wiki\/([^\/]+)\/bags\/([^\/]+)\/tiddlers\/(.+)$/;
exports.path = /^\/bags\/([^\/]+)\/tiddlers\/(.+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
bag_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]),
title = $tw.utils.decodeURIComponentSafe(state.params[2]),
result = bag_name === bag_name_2 && $tw.mws.store.getBagTiddler(title,bag_name);
if(bag_name === bag_name_2 && result) {
title = $tw.utils.decodeURIComponentSafe(state.params[1]),
result = bag_name && $tw.mws.store.getBagTiddler(title,bag_name);
if(bag_name && result) {
// If application/json is requested then this is an API request, and gets the response in JSON
if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) {
var tiddlerFields = {},

View File

@ -3,10 +3,8 @@ title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-bag.js
type: application/javascript
module-type: mws-route
GET /wiki/:bag_name/bags/:bag_name/
GET /wiki/:bag_name/bags/:bag_name
NOTE: Urls currently include the bag name twice. This is temporary to minimise the changes to the TiddlyWeb plugin
GET /bags/:bag_name/
GET /bags/:bag_name
\*/
(function() {
@ -17,19 +15,18 @@ NOTE: Urls currently include the bag name twice. This is temporary to minimise t
exports.method = "GET";
exports.path = /^\/wiki\/([^\/]+)\/bags\/([^\/]+)(\/?)$/;
exports.path = /^\/bags\/([^\/]+)(\/?)$/;
exports.handler = function(request,response,state) {
// Redirect if there is no trailing slash. We do this so that the relative URL specified in the upload form works correctly
if(state.params[2] !== "/") {
if(state.params[1] !== "/") {
state.redirect(301,state.urlInfo.path + "/");
return;
}
// Get the parameters
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
bag_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]),
bagTiddlers = bag_name === bag_name_2 && $tw.mws.store.getBagTiddlers(bag_name);
if(bag_name === bag_name_2 && bagTiddlers) {
bagTiddlers = bag_name && $tw.mws.store.getBagTiddlers(bag_name);
if(bag_name && bagTiddlers) {
// If application/json is requested then this is an API request, and gets the response in JSON
if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) {
state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(bagTiddlers),"utf8");

View File

@ -3,9 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-recipe-tiddler.
type: application/javascript
module-type: mws-route
GET /wiki/: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
GET /recipes/:recipe_name/tiddler/:title
\*/
(function() {
@ -16,15 +14,14 @@ NOTE: Urls currently include the recipe name twice. This is temporary to minimis
exports.method = "GET";
exports.path = /^\/wiki\/([^\/]+)\/recipes\/([^\/]+)\/tiddlers\/(.+)$/;
exports.path = /^\/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]),
tiddlerInfo = recipe_name === recipe_name_2 && $tw.mws.store.getRecipeTiddler(title,recipe_name);
if(recipe_name === recipe_name_2 && tiddlerInfo && tiddlerInfo.tiddler) {
title = $tw.utils.decodeURIComponentSafe(state.params[1]),
tiddlerInfo = recipe_name && $tw.mws.store.getRecipeTiddler(title,recipe_name);
if(recipe_name && tiddlerInfo && tiddlerInfo.tiddler) {
// If application/json is requested then this is an API request, and gets the response in JSON
if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) {
var tiddlerFields = {},

View File

@ -3,9 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-recipe-tiddlers
type: application/javascript
module-type: mws-route
GET /wiki/: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
GET /recipes/:recipe_name/tiddlers.json?filter=:filter
\*/
(function() {
@ -16,13 +14,12 @@ NOTE: Urls currently include the recipe name twice. This is temporary to minimis
exports.method = "GET";
exports.path = /^\/wiki\/([^\/]+)\/recipes\/([^\/]+)\/tiddlers.json$/;
exports.path = /^\/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) {
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]);
if(recipe_name) {
// Get the tiddlers in the recipe
var recipeTiddlers = $tw.mws.store.getRecipeTiddlers(recipe_name);
// Get a skinny version of each tiddler

View File

@ -36,7 +36,7 @@ exports.handler = function(request,response,state) {
$:/boot/bootprefix.js
$:/core
$:/library/sjcl.js
$:/plugins/tiddlywiki/tiddlyweb
$:/plugins/tiddlywiki/multiwikiclient
$:/themes/tiddlywiki/snowwhite
$:/themes/tiddlywiki/vanilla
`
@ -57,7 +57,7 @@ exports.handler = function(request,response,state) {
response.write(",\n")
}
});
response.write(JSON.stringify({title: "$:/config/tiddlyweb/host",text: "$protocol$//$host$$pathname$/"}));
response.write(JSON.stringify({title: "$:/config/multiwikiclient/recipe",text: recipe_name}));
response.write(",\n")
response.write(template.substring(markerPos + marker.length))
// Finish response

View File

@ -1,42 +0,0 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-status.js
type: application/javascript
module-type: mws-route
GET /wiki/: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

@ -3,9 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-bag-tiddlers.j
type: application/javascript
module-type: mws-route
POST /wiki/:bag_name/bags/:bag_name/tiddlers/
NOTE: Urls currently include the bag name twice. This is temporary to minimise the changes to the TiddlyWeb plugin
POST /bags/:bag_name/tiddlers/
\*/
(function() {
@ -16,7 +14,7 @@ NOTE: Urls currently include the bag name twice. This is temporary to minimise t
exports.method = "POST";
exports.path = /^\/wiki\/([^\/]+)\/bags\/([^\/]+)\/tiddlers\/$/;
exports.path = /^\/bags\/([^\/]+)\/tiddlers\/$/;
exports.bodyFormat = "stream";
@ -27,12 +25,7 @@ exports.handler = function(request,response,state) {
fs = require("fs"),
processIncomingStream = require("$:/plugins/tiddlywiki/multiwikiserver/routes/helpers/multipart-forms.js").processIncomingStream;
// Get the parameters
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
bag_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]);
// Require the bag names to match
if(bag_name !== bag_name_2) {
return state.sendResponse(400,{"Content-Type": "text/plain"},"Bad Request: bag names do not match");
}
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]);
// Process the incoming data
processIncomingStream({
store: $tw.mws.store,

View File

@ -3,9 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/put-bag.js
type: application/javascript
module-type: mws-route
PUT /wiki/:bag_name/bags/:bag_name
NOTE: Urls currently include the bag name twice. This is temporary to minimise the changes to the TiddlyWeb plugin
PUT /bags/:bag_name
\*/
(function() {
@ -16,14 +14,13 @@ NOTE: Urls currently include the bag name twice. This is temporary to minimise t
exports.method = "PUT";
exports.path = /^\/wiki\/([^\/]+)\/bags\/(.+)$/;
exports.path = /^\/bags\/(.+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
bag_name_2 = $tw.utils.decodeURIComponentSafe(state.params[1]),
data = $tw.utils.parseJSONSafe(state.data);
if(bag_name === bag_name_2 && data) {
if(bag_name && data) {
const result = $tw.mws.store.createBag(bag_name,data.description);
if(!result) {
state.sendResponse(204,{

View File

@ -3,9 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/put-recipe-tiddler.
type: application/javascript
module-type: mws-route
PUT /wiki/: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
PUT /recipes/:recipe_name/tiddlers/:title
\*/
(function() {
@ -16,13 +14,12 @@ NOTE: Urls currently include the recipe name twice. This is temporary to minimis
exports.method = "PUT";
exports.path = /^\/wiki\/([^\/]+)\/recipes\/([^\/]+)\/tiddlers\/(.+)$/;
exports.path = /^\/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]),
title = $tw.utils.decodeURIComponentSafe(state.params[1]),
fields = $tw.utils.parseJSONSafe(state.data);
// Pull up any subfields in the `fields` object
if(typeof fields.fields === "object") {
@ -37,8 +34,7 @@ exports.handler = function(request,response,state) {
fields[name] = $tw.utils.stringifyList(value);
}
});
// Require the recipe names to match
if(recipe_name === recipe_name_2) {
if(recipe_name) {
var result = $tw.mws.store.saveRecipeTiddler(fields,recipe_name);
if(result) {
response.writeHead(204, "OK",{

View File

@ -3,9 +3,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/put-recipe.js
type: application/javascript
module-type: mws-route
PUT /wiki/:recipe_name/recipes/:recipe_name
NOTE: Urls currently include the recipe name twice. This is temporary to minimise the changes to the TiddlyWeb plugin
PUT /recipes/:recipe_name
\*/
(function() {
@ -16,14 +14,13 @@ NOTE: Urls currently include the recipe name twice. This is temporary to minimis
exports.method = "PUT";
exports.path = /^\/wiki\/([^\/]+)\/recipes\/(.+)$/;
exports.path = /^\/recipes\/(.+)$/;
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]),
data = $tw.utils.parseJSONSafe(state.data);
if(recipe_name === recipe_name_2 && data) {
if(recipe_name && data) {
const result = $tw.mws.store.createRecipe(recipe_name,data.bag_names,data.description);
if(!result) {
state.sendResponse(204,{

View File

@ -92,7 +92,7 @@ SqlTiddlerStore.prototype.processOutgoingTiddler = function(tiddlerFields,tiddle
});
if(attachment_blob !== null) {
delete fields.text;
fields._canonical_uri = `/wiki/${encodeURIComponent(bag_name)}/bags/${encodeURIComponent(bag_name)}/tiddlers/${encodeURIComponent(tiddlerFields.title)}/blob`;
fields._canonical_uri = `/bags/${encodeURIComponent(bag_name)}/tiddlers/${encodeURIComponent(tiddlerFields.title)}/blob`;
}
return fields;
};

View File

@ -98,11 +98,6 @@ body {
color: currentcolor;
}
.mws-favicon.tc-image-loading, .mws-favicon-small.tc-image-loading,
.mws-favicon.tc-image-error, .mws-favicon-small.tc-image-error {
visibility: hidden;
}
.mws-favicon {
object-fit: contain;
width: 4em;

View File

@ -1,7 +1,7 @@
title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-bag
! <$image
source=`/wiki/${ [<bag-name>encodeuricomponent[]] }$/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
source=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
class="mws-favicon-small"
width="32px"
>
@ -43,7 +43,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-bag
<ul>
<$list filter="[<bag-titles>jsonget[]sort[]]">
<li>
<a href=`/wiki/${ [<bag-name>encodeuricomponent[]] }$/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/${ [<currentTiddler>encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
<a href=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/${ [<currentTiddler>encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
<$text text=<<currentTiddler>>/>
</a>
</li>

View File

@ -3,9 +3,9 @@ title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-index
\procedure bagPill(element-tag:"span",is-topmost:"no")
\whitespace trim
<$genesis $type=<<element-tag>> class={{{ mws-bag-pill [<is-topmost>match[yes]then[mws-bag-pill-topmost]] +[join[ ]] }}}>
<a class="mws-bag-pill-link" href=`/wiki/${ [<bag-name>encodeuricomponent[]] }$/bags/${ [<bag-name>encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
<a class="mws-bag-pill-link" href=`/bags/${ [<bag-name>encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
<img
src=`/wiki/${ [<bag-name>encodeuricomponent[]] }$/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
src=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
class="mws-favicon-small"
/>
<span class="mws-bag-pill-label">
@ -29,7 +29,7 @@ title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-index
>
<div class="mws-wiki-card-image">
<img
src=`/wiki/${ [<recipe-name>encodeuricomponent[]] }$/recipes/${ [<recipe-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
src=`/recipes/${ [<recipe-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
class="mws-favicon"
/>
</div>

View File

@ -1,7 +1,7 @@
title: $:/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers
! <$image
source=`/wiki/${ [<bag-name>encodeuricomponent[]] }$/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
source=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
class="mws-favicon-small"
width="32px"
>
@ -23,7 +23,7 @@ The following tiddlers were successfully imported:
<ul>
<$list filter="[<imported-titles>jsonget[]sort[]]">
<li>
<a href=`/wiki/${ [<bag-name>encodeuricomponent[]] }$/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/${ [<currentTiddler>encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
<a href=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/${ [<currentTiddler>encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
<$text text=<<currentTiddler>>/>
</a>
</li>