diff --git a/core/images/network-activity.tid b/core/images/network-activity.tid
new file mode 100644
index 000000000..2efdfd4d4
--- /dev/null
+++ b/core/images/network-activity.tid
@@ -0,0 +1,11 @@
+title: $:/core/images/network-activity
+tags: $:/tags/Image
+
+
\ No newline at end of file
diff --git a/core/language/en-GB/Buttons.multids b/core/language/en-GB/Buttons.multids
index 85a71ac08..fa769d117 100644
--- a/core/language/en-GB/Buttons.multids
+++ b/core/language/en-GB/Buttons.multids
@@ -67,6 +67,8 @@ More/Caption: more
More/Hint: More actions
NewHere/Caption: new here
NewHere/Hint: Create a new tiddler tagged with this one
+NetworkActivity/Caption: network activity
+NetworkActivity/Hint: Cancel all network activity
NewJournal/Caption: new journal
NewJournal/Hint: Create a new journal tiddler
NewJournalHere/Caption: new journal here
diff --git a/core/modules/startup/rootwidget.js b/core/modules/startup/rootwidget.js
index 1175f6f25..f5d90afb5 100644
--- a/core/modules/startup/rootwidget.js
+++ b/core/modules/startup/rootwidget.js
@@ -20,6 +20,38 @@ exports.before = ["story"];
exports.synchronous = true;
exports.startup = function() {
+ // Install the HTTP client event handler
+ $tw.httpClient = new $tw.utils.HttpClient();
+ var getPropertiesWithPrefix = function(properties,prefix) {
+ var result = Object.create(null);
+ $tw.utils.each(properties,function(value,name) {
+ if(name.indexOf(prefix) === 0) {
+ result[name.substring(prefix.length)] = properties[name];
+ }
+ });
+ return result;
+ };
+ $tw.rootWidget.addEventListener("tm-http-request",function(event) {
+ var params = event.paramObject || {};
+ $tw.httpClient.initiateHttpRequest({
+ wiki: event.widget.wiki,
+ url: params.url,
+ method: params.method,
+ body: params.body,
+ oncompletion: params.oncompletion,
+ onprogress: params.onprogress,
+ bindStatus: params["bind-status"],
+ bindProgress: params["bind-progress"],
+ variables: getPropertiesWithPrefix(params,"var-"),
+ headers: getPropertiesWithPrefix(params,"header-"),
+ passwordHeaders: getPropertiesWithPrefix(params,"password-header-"),
+ queryStrings: getPropertiesWithPrefix(params,"query-"),
+ passwordQueryStrings: getPropertiesWithPrefix(params,"password-query-")
+ });
+ });
+ $tw.rootWidget.addEventListener("tm-http-cancel-all-requests",function(event) {
+ $tw.httpClient.cancelAllHttpRequests();
+ });
// Install the modal message mechanism
$tw.modal = new $tw.utils.Modal($tw.wiki);
$tw.rootWidget.addEventListener("tm-modal",function(event) {
diff --git a/core/modules/utils/dom/http.js b/core/modules/utils/dom/http.js
index 6e07b1040..ba4b3d2a1 100644
--- a/core/modules/utils/dom/http.js
+++ b/core/modules/utils/dom/http.js
@@ -3,7 +3,7 @@ title: $:/core/modules/utils/dom/http.js
type: application/javascript
module-type: utils
-Browser HTTP support
+HTTP support
\*/
(function(){
@@ -13,11 +13,204 @@ Browser HTTP support
"use strict";
/*
-A quick and dirty HTTP function; to be refactored later. Options are:
+Manage tm-http-request events. Options include:
+wiki: Reference to the wiki to be used for state tiddler tracking
+stateTrackerTitle: Title of tiddler to be used for state tiddler tracking
+*/
+function HttpClient(options) {
+ options = options || {};
+ this.nextId = 1;
+ this.wiki = options.wiki || $tw.wiki;
+ this.stateTrackerTitle = options.stateTrackerTitle || "$:/state/http-requests";
+ this.requests = []; // Array of {id: string,request: HttpClientRequest}
+ this.updateRequestTracker();
+}
+
+/*
+Return the index into this.requests[] corresponding to a given ID. Returns null if not found
+*/
+HttpClient.prototype.getRequestIndex = function(targetId) {
+ var targetIndex = null;
+ $tw.utils.each(this.requests,function(requestInfo,index) {
+ if(requestInfo.id === targetId) {
+ targetIndex = index;
+ }
+ });
+ return targetIndex;
+};
+
+/*
+Update the state tiddler that is tracking the outstanding requests
+*/
+HttpClient.prototype.updateRequestTracker = function() {
+ this.wiki.addTiddler({title: this.stateTrackerTitle, text: "" + this.requests.length});
+};
+
+HttpClient.prototype.initiateHttpRequest = function(options) {
+ var self = this,
+ id = this.nextId,
+ request = new HttpClientRequest(options);
+ this.nextId += 1;
+ this.requests.push({id: id, request: request});
+ this.updateRequestTracker();
+ request.send(function(err) {
+ var targetIndex = self.getRequestIndex(id);
+ if(targetIndex !== null) {
+ self.requests.splice(targetIndex,1);
+ self.updateRequestTracker();
+ }
+ });
+ return id;
+};
+
+HttpClient.prototype.cancelAllHttpRequests = function() {
+ var self = this;
+ if(this.requests.length > 0) {
+ for(var t=this.requests.length - 1; t--; t>=0) {
+ var requestInfo = this.requests[t];
+ requestInfo.request.cancel();
+ }
+ }
+ this.requests = [];
+ this.updateRequestTracker();
+};
+
+HttpClient.prototype.cancelHttpRequest = function(targetId) {
+ var targetIndex = this.getRequestIndex(targetId);
+ if(targetIndex !== null) {
+ this.requests[targetIndex].request.cancel();
+ this.requests.splice(targetIndex,1);
+ this.updateRequestTracker();
+ }
+};
+
+/*
+Initiate an HTTP request. Options:
+wiki: wiki to be used for executing action strings
+url: URL for request
+method: method eg GET, POST
+body: text of request body
+oncompletion: action string to be invoked on completion
+onprogress: action string to be invoked on progress updates
+bindStatus: optional title of tiddler to which status ("pending", "complete", "error") should be written
+bindProgress: optional title of tiddler to which the progress of the request (0 to 100) should be bound
+variables: hashmap of variable name to string value passed to action strings
+headers: hashmap of header name to header value to be sent with the request
+passwordHeaders: hashmap of header name to password store name to be sent with the request
+queryStrings: hashmap of query string parameter name to parameter value to be sent with the request
+passwordQueryStrings: hashmap of query string parameter name to password store name to be sent with the request
+*/
+function HttpClientRequest(options) {
+ var self = this;
+ console.log("Initiating an HTTP request",options)
+ this.wiki = options.wiki;
+ this.completionActions = options.oncompletion;
+ this.progressActions = options.onprogress;
+ this.bindStatus = options["bind-status"];
+ this.bindProgress = options["bind-progress"];
+ this.method = options.method || "GET";
+ this.body = options.body || "";
+ this.variables = options.variables;
+ var url = options.url;
+ $tw.utils.each(options.queryStrings,function(value,name) {
+ url = $tw.utils.setQueryStringParameter(url,name,value);
+ });
+ $tw.utils.each(options.passwordQueryStrings,function(value,name) {
+ url = $tw.utils.setQueryStringParameter(url,name,$tw.utils.getPassword(value) || "");
+ });
+ this.url = url;
+ this.requestHeaders = {};
+ $tw.utils.each(options.headers,function(value,name) {
+ self.requestHeaders[name] = value;
+ });
+ $tw.utils.each(options.passwordHeaders,function(value,name) {
+ self.requestHeaders[name] = $tw.utils.getPassword(value) || "";
+ });
+}
+
+HttpClientRequest.prototype.send = function(callback) {
+ var self = this,
+ setBinding = function(title,text) {
+ if(title) {
+ this.wiki.addTiddler(new $tw.Tiddler({title: title, text: text}));
+ }
+ };
+ if(this.url) {
+ setBinding(this.bindStatus,"pending");
+ setBinding(this.bindProgress,"0");
+ // Set the request tracker tiddler
+ var requestTrackerTitle = this.wiki.generateNewTitle("$:/temp/HttpRequest");
+ this.wiki.addTiddler({
+ title: requestTrackerTitle,
+ tags: "$:/tags/HttpRequest",
+ text: JSON.stringify({
+ url: this.url,
+ type: this.method,
+ status: "inprogress",
+ headers: this.requestHeaders,
+ data: this.body
+ })
+ });
+ this.xhr = $tw.utils.httpRequest({
+ url: this.url,
+ type: this.method,
+ headers: this.requestHeaders,
+ data: this.body,
+ callback: function(err,data,xhr) {
+ var hasSucceeded = xhr.status >= 200 && xhr.status < 300,
+ completionCode = hasSucceeded ? "complete" : "error",
+ headers = {};
+ $tw.utils.each(xhr.getAllResponseHeaders().split("\r\n"),function(line) {
+ var pos = line.indexOf(":");
+ if(pos !== -1) {
+ headers[line.substr(0,pos)] = line.substr(pos + 1).trim();
+ }
+ });
+ setBinding(self.bindStatus,completionCode);
+ setBinding(self.bindProgress,"100");
+ var resultVariables = {
+ status: xhr.status.toString(),
+ statusText: xhr.statusText,
+ error: (err || "").toString(),
+ data: (data || "").toString(),
+ headers: JSON.stringify(headers)
+ };
+ self.wiki.addTiddler(new $tw.Tiddler(self.wiki.getTiddler(requestTrackerTitle),{
+ status: completionCode,
+ }));
+ self.wiki.invokeActionString(self.completionActions,undefined,$tw.utils.extend({},self.variables,resultVariables),{parentWidget: $tw.rootWidget});
+ callback(hasSucceeded ? null : xhr.statusText);
+ // console.log("Back!",err,data,xhr);
+ },
+ progress: function(lengthComputable,loaded,total) {
+ if(lengthComputable) {
+ setBinding(self.bindProgress,"" + Math.floor((loaded/total) * 100))
+ }
+ self.wiki.invokeActionString(self.progressActions,undefined,{
+ lengthComputable: lengthComputable ? "yes" : "no",
+ loaded: loaded,
+ total: total
+ },{parentWidget: $tw.rootWidget});
+ }
+ });
+ }
+};
+
+HttpClientRequest.prototype.cancel = function() {
+ if(this.xhr) {
+ this.xhr.abort();
+ }
+};
+
+exports.HttpClient = HttpClient;
+
+/*
+Make an HTTP request. Options are:
url: URL to retrieve
headers: hashmap of headers to send
type: GET, PUT, POST etc
callback: function invoked with (err,data,xhr)
+ progress: optional function invoked with (lengthComputable,loaded,total)
returnProp: string name of the property to return as first argument of callback
*/
exports.httpRequest = function(options) {
@@ -83,8 +276,16 @@ exports.httpRequest = function(options) {
options.callback($tw.language.getString("Error/XMLHttpRequest") + ": " + this.status,null,this);
}
};
+ // Handle progress
+ if(options.progress) {
+ request.onprogress = function(event) {
+ console.log("Progress event",event)
+ options.progress(event.lengthComputable,event.loaded,event.total);
+ };
+ }
// Make the request
request.open(type,url,true);
+ // Headers
if(headers) {
$tw.utils.each(headers,function(header,headerTitle,object) {
request.setRequestHeader(headerTitle,header);
@@ -96,6 +297,7 @@ exports.httpRequest = function(options) {
if(!hasHeader("X-Requested-With") && !isSimpleRequest(type,headers)) {
request.setRequestHeader("X-Requested-With","TiddlyWiki");
}
+ // Send data
try {
request.send(data);
} catch(e) {
@@ -104,4 +306,19 @@ exports.httpRequest = function(options) {
return request;
};
+exports.setQueryStringParameter = function(url,paramName,paramValue) {
+ var URL = $tw.browser ? window.URL : require("url").URL,
+ newUrl;
+ try {
+ newUrl = new URL(url);
+ } catch(e) {
+ }
+ if(newUrl && paramName) {
+ newUrl.searchParams.set(paramName,paramValue || "");
+ return newUrl.toString();
+ } else {
+ return url;
+ }
+};
+
})();
diff --git a/core/modules/wiki.js b/core/modules/wiki.js
index 8cb12cc39..ca31da8d2 100755
--- a/core/modules/wiki.js
+++ b/core/modules/wiki.js
@@ -1415,6 +1415,14 @@ exports.checkTiddlerText = function(title,targetText,options) {
return text === targetText;
}
+/*
+Execute an action string without an associated context widget
+*/
+exports.invokeActionString = function(actions,event,variables,options) {
+ var widget = this.makeWidget(null,{parentWidget: options.parentWidget});
+ widget.invokeActionString(actions,null,event,variables);
+};
+
/*
Read an array of browser File objects, invoking callback(tiddlerFieldsArray) once they're all read
*/
diff --git a/core/palettes/Vanilla.tid b/core/palettes/Vanilla.tid
index d84b4ec83..4c660e912 100644
--- a/core/palettes/Vanilla.tid
+++ b/core/palettes/Vanilla.tid
@@ -54,6 +54,7 @@ modal-footer-background: #f5f5f5
modal-footer-border: #dddddd
modal-header-border: #eeeeee
muted-foreground: #bbb
+network-activity-foreground: #448844
notification-background: #ffffdd
notification-border: #999999
page-background: #f4f4f4
diff --git a/core/ui/PageControls/network-activity.tid b/core/ui/PageControls/network-activity.tid
new file mode 100644
index 000000000..763365f37
--- /dev/null
+++ b/core/ui/PageControls/network-activity.tid
@@ -0,0 +1,16 @@
+title: $:/core/ui/Buttons/network-activity
+tags: $:/tags/PageControls
+caption: {{$:/core/images/network-activity}} {{$:/language/Buttons/NetworkActivity/Caption}}
+description: {{$:/language/Buttons/NetworkActivity/Hint}}
+
+\whitespace trim
+<$button message="tm-http-cancel-all-requests" tooltip={{$:/language/Buttons/NetworkActivity/Hint}} aria-label={{$:/language/Buttons/NetworkActivity/Caption}} class=<>>
+<$list filter="[match[yes]]">
+{{$:/core/images/network-activity}}
+$list>
+<$list filter="[match[yes]]">
+
+<$text text={{$:/language/Buttons/NetworkActivity/Caption}}/>
+
+$list>
+$button>
\ No newline at end of file
diff --git a/core/wiki/config/PageControlButtons.multids b/core/wiki/config/PageControlButtons.multids
index a437251f5..b66f11cc0 100644
--- a/core/wiki/config/PageControlButtons.multids
+++ b/core/wiki/config/PageControlButtons.multids
@@ -13,6 +13,7 @@ core/ui/Buttons/language: hide
core/ui/Buttons/tag-manager: hide
core/ui/Buttons/manager: hide
core/ui/Buttons/more-page-actions: hide
+core/ui/Buttons/network-activity: hide
core/ui/Buttons/new-journal: hide
core/ui/Buttons/new-image: hide
core/ui/Buttons/palette: hide
diff --git a/core/wiki/tags/PageControls.tid b/core/wiki/tags/PageControls.tid
index c6234751c..c0f1cb233 100644
--- a/core/wiki/tags/PageControls.tid
+++ b/core/wiki/tags/PageControls.tid
@@ -1,2 +1,2 @@
title: $:/tags/PageControls
-list: [[$:/core/ui/Buttons/home]] [[$:/core/ui/Buttons/close-all]] [[$:/core/ui/Buttons/fold-all]] [[$:/core/ui/Buttons/unfold-all]] [[$:/core/ui/Buttons/permaview]] [[$:/core/ui/Buttons/new-tiddler]] [[$:/core/ui/Buttons/new-journal]] [[$:/core/ui/Buttons/new-image]] [[$:/core/ui/Buttons/import]] [[$:/core/ui/Buttons/export-page]] [[$:/core/ui/Buttons/control-panel]] [[$:/core/ui/Buttons/advanced-search]] [[$:/core/ui/Buttons/manager]] [[$:/core/ui/Buttons/tag-manager]] [[$:/core/ui/Buttons/language]] [[$:/core/ui/Buttons/palette]] [[$:/core/ui/Buttons/theme]] [[$:/core/ui/Buttons/layout]] [[$:/core/ui/Buttons/storyview]] [[$:/core/ui/Buttons/encryption]] [[$:/core/ui/Buttons/timestamp]] [[$:/core/ui/Buttons/full-screen]] [[$:/core/ui/Buttons/print]] [[$:/core/ui/Buttons/save-wiki]] [[$:/core/ui/Buttons/refresh]] [[$:/core/ui/Buttons/more-page-actions]]
+list: [[$:/core/ui/Buttons/home]] [[$:/core/ui/Buttons/close-all]] [[$:/core/ui/Buttons/fold-all]] [[$:/core/ui/Buttons/unfold-all]] [[$:/core/ui/Buttons/permaview]] [[$:/core/ui/Buttons/new-tiddler]] [[$:/core/ui/Buttons/new-journal]] [[$:/core/ui/Buttons/new-image]] [[$:/core/ui/Buttons/import]] [[$:/core/ui/Buttons/export-page]] [[$:/core/ui/Buttons/control-panel]] [[$:/core/ui/Buttons/advanced-search]] [[$:/core/ui/Buttons/manager]] [[$:/core/ui/Buttons/tag-manager]] [[$:/core/ui/Buttons/language]] [[$:/core/ui/Buttons/palette]] [[$:/core/ui/Buttons/theme]] [[$:/core/ui/Buttons/layout]] [[$:/core/ui/Buttons/storyview]] [[$:/core/ui/Buttons/encryption]] [[$:/core/ui/Buttons/timestamp]] [[$:/core/ui/Buttons/full-screen]] [[$:/core/ui/Buttons/print]] [[$:/core/ui/Buttons/save-wiki]] [[$:/core/ui/Buttons/refresh]] [[$:/core/ui/Buttons/network-activity]] [[$:/core/ui/Buttons/more-page-actions]]
diff --git a/editions/tw5.com/tiddlers/messages/WidgetMessage_ tm-http-cancel-all-requests.tid b/editions/tw5.com/tiddlers/messages/WidgetMessage_ tm-http-cancel-all-requests.tid
new file mode 100644
index 000000000..df94e5a0b
--- /dev/null
+++ b/editions/tw5.com/tiddlers/messages/WidgetMessage_ tm-http-cancel-all-requests.tid
@@ -0,0 +1,12 @@
+caption: tm-http-cancel-all-requests
+created: 20230429161453032
+modified: 20230429161453032
+tags: Messages
+title: WidgetMessage: tm-http-cancel-all-requests
+type: text/vnd.tiddlywiki
+
+The ''tm-http-cancel-all-requests'' message is used to cancel all outstanding HTTP requests initiated with [[WidgetMessage: tm-http-request]].
+
+Note that the state tiddler $:/state/http-requests contains a number representing the number of outstanding HTTP requests in progress.
+
+It does not take any parameters.
diff --git a/editions/tw5.com/tiddlers/messages/WidgetMessage_ tm-http-request Example Zotero.tid b/editions/tw5.com/tiddlers/messages/WidgetMessage_ tm-http-request Example Zotero.tid
new file mode 100644
index 000000000..ea64dd3a2
--- /dev/null
+++ b/editions/tw5.com/tiddlers/messages/WidgetMessage_ tm-http-request Example Zotero.tid
@@ -0,0 +1,115 @@
+title: WidgetMessage: tm-http-request Example - Zotero
+tags: $:/tags/Macro
+
+\procedure select-zotero-group()
+Specify the Zotero group ID to import
+<$edit-text tiddler="$:/config/zotero-group" tag="input"/> or
+<$select tiddler="$:/config/zotero-group">
+
+
+
+$select>
+\end
+
+\procedure zotero-save-item(item)
+<$action-createtiddler
+ $basetitle={{{ =[[_zotero_import ]] =[- jsonget[key]] =[[ ]] =[
- jsonget[title]] +[join[]] }}}
+ text={{{ [
- jsonget[title]] }}}
+ tags="$:/tags/ZoteroImport"
+>
+ <$action-setmultiplefields $tiddler=<> $fields="[
- jsonindexes[]addprefix[zotero-]]" $values="[
- jsonindexes[]] :map[
- jsongetelse[.XXXXX.]]"/>
+ <$list filter="[
- jsonindexes[creators]]" variable="creatorIndex">
+ <$action-setmultiplefields $tiddler=<> $fields="[
- jsonget[creators],,[creatorType]addprefix[zotero-]]" $values="[
- jsonget[creators],,[lastName]] [
- jsonget[creators],,[firstName]] +[join[, ]] :else[
- jsonget[creators],,[name]] "/>
+ $list>
+$action-createtiddler>
+\end zotero-save-item
+
+\procedure zotero-save-items(data)
+<$list filter="[jsonindexes[]] :map[jsonextract,[data]]" variable="item">
+ <$macrocall $name="zotero-save-item" item=<
- >/>
+$list>
+\end zotero-save-items
+
+\procedure zotero-get-items(start:"0",limit:"25")
+
+\procedure completion()
+\import [[$:/core/ui/PageMacros]] [all[shadows+tiddlers]tag[$:/tags/Macro]!has[draft.of]]
+ <$action-log msg="In completion"/>
+ <$action-log/>
+
+ <$list filter="[compare:number:gteq[200]compare:number:lteq[299]]" variable="ignore">
+
+ <$macrocall $name="zotero-save-items" data=<>/>
+
+ <$list filter="[jsonget[total-results]subtractsubtractcompare:number:gt[0]]" variable="ignore">
+ <$macrocall $name="zotero-get-items" start={{{ [add] }}} limit=<>/>
+ $list>
+ $list>
+\end completion
+
+\procedure progress()
+\import [[$:/core/ui/PageMacros]] [all[shadows+tiddlers]tag[$:/tags/Macro]!has[draft.of]]
+ <$action-log message="In progress-actions"/>
+\end progress
+
+\procedure request-url()
+\rules only transcludeinline transcludeblock filteredtranscludeinline filteredtranscludeblock
+https://api.zotero.org/groups/{{$:/config/zotero-group}}/items/
+\end request-url
+
+<$wikify name="url" text=<>>
+ <$action-sendmessage
+ $message="tm-http-request"
+ url=<>
+ method="GET"
+ query-format="json"
+ query-sort="title"
+ query-start=<>
+ query-limit=<>
+ header-accept="application/json"
+ bind-status="$:/temp/zotero/status"
+ bind-progress="$:/temp/zotero/progress"
+ oncompletion=<>
+ onprogress=<