diff --git a/package.json b/package.json index 7c55bd5..2768b23 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "author": "FSpark", "license": "MIT", "dependencies": { - "tiddlywiki": "^5.3.3" + "tiddlywiki": "^5.3.5" }, "devDependencies": { "@babel/preset-env": "^7.21.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e865ece..5e1e18a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: tiddlywiki: - specifier: ^5.3.3 - version: 5.3.3 + specifier: ^5.3.5 + version: 5.3.5 devDependencies: '@babel/preset-env': @@ -2209,7 +2209,7 @@ packages: resolution: {integrity: sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==} engines: {node: '>= 4.0'} os: [darwin] - deprecated: The v1 package contains DANGEROUS / INSECURE binaries. Upgrade to safe fsevents v2 + deprecated: Upgrade to fsevents v2 to mitigate potential security issues requiresBuild: true dependencies: bindings: 1.5.0 @@ -3530,12 +3530,11 @@ packages: xtend: 4.0.2 dev: true - /tiddlywiki@5.3.3: - resolution: {integrity: sha512-PkgVfZNpFFHyMmfFw91igXOJn8Z7IWg3NGXOX5EBqJwzGNeYYOIUg4FqCNsWoqBece20HxtkDue/vTf2jDtdZQ==} + /tiddlywiki@5.3.5: + resolution: {integrity: sha512-8pTmnQdkcHbol9D86Op7OGK4sGDqm19HWT2qgpSxPHfDG0yJ2rSBUTRuOMuh9GoPP0Tcz9+1Pe8A1m6pvd/zYQ==} engines: {node: '>=0.8.2'} hasBin: true dev: false - bundledDependencies: [] /time-stamp@1.1.0: resolution: {integrity: sha512-gLCeArryy2yNTRzTGKbZbloctj64jkZ57hj5zdraXue6aFgd6PmvVtEyiUU+hvU0v7q08oVv8r8ev0tRo6bvgw==} diff --git a/src/override/system/boot/boot.js b/src/override/system/boot/boot.js index e9a9737..d514cea 100644 --- a/src/override/system/boot/boot.js +++ b/src/override/system/boot/boot.js @@ -10,1307 +10,1369 @@ On the server this file is executed directly to boot TiddlyWiki. In the browser, var _boot = (function($tw) { -/*jslint node: true, browser: true */ -/*global modules: false, $tw: false */ -"use strict"; - -// Include bootprefix if we're not given module data -if(!$tw) { - $tw = require("./bootprefix.js").bootprefix(); -} - -$tw.utils = $tw.utils || Object.create(null); - -/////////////////////////// Standard node.js libraries - -var fs, path, vm; -if($tw.node) { - fs = require("fs"); - path = require("path"); - vm = require("vm"); -} - -/////////////////////////// Utility functions - -$tw.boot.log = function(str) { - $tw.boot.logMessages = $tw.boot.logMessages || []; - $tw.boot.logMessages.push(str); -} - -/* -Check if an object has a property -*/ -$tw.utils.hop = function(object,property) { - return object ? Object.prototype.hasOwnProperty.call(object,property) : false; -}; - -/* -Determine if a value is an array -*/ -$tw.utils.isArray = function(value) { - return Object.prototype.toString.call(value) == "[object Array]"; -}; - -/* -Check if an array is equal by value and by reference. -*/ -$tw.utils.isArrayEqual = function(array1,array2) { - if(array1 === array2) { - return true; - } - array1 = array1 || []; - array2 = array2 || []; - if(array1.length !== array2.length) { - return false; - } - return array1.every(function(value,index) { - return value === array2[index]; - }); -}; - -/* -Add an entry to a sorted array if it doesn't already exist, while maintaining the sort order -*/ -$tw.utils.insertSortedArray = function(array,value) { - var low = 0, high = array.length - 1, mid, cmp; - while(low <= high) { - mid = (low + high) >> 1; - cmp = value.localeCompare(array[mid]); - if(cmp > 0) { - low = mid + 1; - } else if(cmp < 0) { - high = mid - 1; - } else { - return array; + /*jslint node: true, browser: true */ + /*global modules: false, $tw: false */ + "use strict"; + + // Include bootprefix if we're not given module data + if(!$tw) { + $tw = require("./bootprefix.js").bootprefix(); + } + + $tw.utils = $tw.utils || Object.create(null); + + /////////////////////////// Standard node.js libraries + + var fs, path, vm; + if($tw.node) { + fs = require("fs"); + path = require("path"); + vm = require("vm"); + } + + /////////////////////////// Utility functions + + $tw.boot.log = function(str) { + $tw.boot.logMessages = $tw.boot.logMessages || []; + $tw.boot.logMessages.push(str); + } + + /* + Check if an object has a property + */ + $tw.utils.hop = function(object,property) { + return object ? Object.prototype.hasOwnProperty.call(object,property) : false; + }; + + /* + Determine if a value is an array + */ + $tw.utils.isArray = function(value) { + return Object.prototype.toString.call(value) == "[object Array]"; + }; + + /* + Check if an array is equal by value and by reference. + */ + $tw.utils.isArrayEqual = function(array1,array2) { + if(array1 === array2) { + return true; } - } - array.splice(low,0,value); - return array; -}; - -/* -Push entries onto an array, removing them first if they already exist in the array - array: array to modify (assumed to be free of duplicates) - value: a single value to push or an array of values to push -*/ -$tw.utils.pushTop = function(array,value) { - var t,p; - if($tw.utils.isArray(value)) { - // Remove any array entries that are duplicated in the new values - if(value.length !== 0) { - if(array.length !== 0) { - if(value.length < array.length) { - for(t=0; t> 1; + cmp = value.localeCompare(array[mid]); + if(cmp > 0) { + low = mid + 1; + } else if(cmp < 0) { + high = mid - 1; + } else { + return array; + } + } + array.splice(low,0,value); + return array; + }; + + /* + Push entries onto an array, removing them first if they already exist in the array + array: array to modify (assumed to be free of duplicates) + value: a single value to push or an array of values to push + */ + $tw.utils.pushTop = function(array,value) { + var t,p; + if($tw.utils.isArray(value)) { + // Remove any array entries that are duplicated in the new values + if(value.length !== 0) { + if(array.length !== 0) { + if(value.length < array.length) { + for(t=0; t=0; t--) { - p = value.indexOf(array[t]); - if(p !== -1) { - array.splice(t,1); + } else { + for(t=array.length-1; t>=0; t--) { + p = value.indexOf(array[t]); + if(p !== -1) { + array.splice(t,1); + } } } } + // Push the values on top of the main array + array.push.apply(array,value); } - // Push the values on top of the main array - array.push.apply(array,value); - } - } else { - p = array.indexOf(value); - if(p !== -1) { - array.splice(p,1); - } - array.push(value); - } - return array; -}; - -/* -Determine if a value is a date -*/ -$tw.utils.isDate = function(value) { - return Object.prototype.toString.call(value) === "[object Date]"; -}; - -/* -Iterate through all the own properties of an object or array. Callback is invoked with (element,title,object) -*/ -$tw.utils.each = function(object,callback) { - var next,f,length; - if(object) { - if(Object.prototype.toString.call(object) == "[object Array]") { - for (f=0, length=object.length; f and """ to " -*/ -$tw.utils.htmlDecode = function(s) { - return s.toString().replace(/</mg,"<").replace(/ /mg,"\xA0").replace(/>/mg,">").replace(/"/mg,"\"").replace(/&/mg,"&"); -}; - -/* -Get the browser location.hash. We don't use location.hash because of the way that Firefox auto-urldecodes it (see http://stackoverflow.com/questions/1703552/encoding-of-window-location-hash) -*/ -$tw.utils.getLocationHash = function() { - var href = window.location.href; - var idx = href.indexOf('#'); - if(idx === -1) { - return "#"; - } else if(href.substr(idx + 1,1) === "#" || href.substr(idx + 1,3) === "%23") { - // Special case: ignore location hash if it itself starts with a # - return "#"; - } else { - return href.substring(idx); - } -}; - -/* -Pad a string to a given length with "0"s. Length defaults to 2 -*/ -$tw.utils.pad = function(value,length) { - length = length || 2; - var s = value.toString(); - if(s.length < length) { - s = "000000000000000000000000000".substr(0,length - s.length) + s; - } - return s; -}; - -// Convert a date into UTC YYYYMMDDHHMMSSmmm format -$tw.utils.stringifyDate = function(value) { - return value.getUTCFullYear() + - $tw.utils.pad(value.getUTCMonth() + 1) + - $tw.utils.pad(value.getUTCDate()) + - $tw.utils.pad(value.getUTCHours()) + - $tw.utils.pad(value.getUTCMinutes()) + - $tw.utils.pad(value.getUTCSeconds()) + - $tw.utils.pad(value.getUTCMilliseconds(),3); -}; - -// Parse a date from a UTC YYYYMMDDHHMMSSmmm format string -$tw.utils.parseDate = function(value) { - if(typeof value === "string") { - var negative = 1; - if(value.charAt(0) === "-") { - negative = -1; - value = value.substr(1); - } - var year = parseInt(value.substr(0,4),10) * negative, - d = new Date(Date.UTC(year, - parseInt(value.substr(4,2),10)-1, - parseInt(value.substr(6,2),10), - parseInt(value.substr(8,2)||"00",10), - parseInt(value.substr(10,2)||"00",10), - parseInt(value.substr(12,2)||"00",10), - parseInt(value.substr(14,3)||"000",10))); - d.setUTCFullYear(year); // See https://stackoverflow.com/a/5870822 - return d; - } else if($tw.utils.isDate(value)) { - return value; - } else { - return null; - } -}; - -// Stringify an array of tiddler titles into a list string -$tw.utils.stringifyList = function(value) { - if($tw.utils.isArray(value)) { - var result = new Array(value.length); - for(var t=0, l=value.length; t and """ to " + */ + $tw.utils.htmlDecode = function(s) { + return s.toString().replace(/</mg,"<").replace(/ /mg,"\xA0").replace(/>/mg,">").replace(/"/mg,"\"").replace(/&/mg,"&"); + }; + + /* + Get the browser location.hash. We don't use location.hash because of the way that Firefox auto-urldecodes it (see http://stackoverflow.com/questions/1703552/encoding-of-window-location-hash) + */ + $tw.utils.getLocationHash = function() { + var href = window.location.href; + var idx = href.indexOf('#'); + if(idx === -1) { + return "#"; + } else if(href.substr(idx + 1,1) === "#" || href.substr(idx + 1,3) === "%23") { + // Special case: ignore location hash if it itself starts with a # + return "#"; + } else { + return href.substring(idx); } - }); - return fields; -}; - -// Safely parse a string as JSON -$tw.utils.parseJSONSafe = function(text,defaultJSON) { - try { - return JSON.parse(text); - } catch(e) { - if(typeof defaultJSON === "function") { - return defaultJSON(e); + }; + + /* + Pad a string to a given length with "0"s. Length defaults to 2 + */ + $tw.utils.pad = function(value,length) { + length = length || 2; + var s = value.toString(); + if(s.length < length) { + s = "000000000000000000000000000".substr(0,length - s.length) + s; + } + return s; + }; + + // Convert a date into UTC YYYYMMDDHHMMSSmmm format + $tw.utils.stringifyDate = function(value) { + return value.getUTCFullYear() + + $tw.utils.pad(value.getUTCMonth() + 1) + + $tw.utils.pad(value.getUTCDate()) + + $tw.utils.pad(value.getUTCHours()) + + $tw.utils.pad(value.getUTCMinutes()) + + $tw.utils.pad(value.getUTCSeconds()) + + $tw.utils.pad(value.getUTCMilliseconds(),3); + }; + + // Parse a date from a UTC YYYYMMDDHHMMSSmmm format string + $tw.utils.parseDate = function(value) { + if(typeof value === "string") { + var negative = 1; + if(value.charAt(0) === "-") { + negative = -1; + value = value.substr(1); + } + var year = parseInt(value.substr(0,4),10) * negative, + d = new Date(Date.UTC(year, + parseInt(value.substr(4,2),10)-1, + parseInt(value.substr(6,2),10), + parseInt(value.substr(8,2)||"00",10), + parseInt(value.substr(10,2)||"00",10), + parseInt(value.substr(12,2)||"00",10), + parseInt(value.substr(14,3)||"000",10))); + d.setUTCFullYear(year); // See https://stackoverflow.com/a/5870822 + return d; + } else if($tw.utils.isDate(value)) { + return value; } else { - return defaultJSON || {}; + return null; } - } -}; - -/* -Resolves a source filepath delimited with `/` relative to a specified absolute root filepath. -In relative paths, the special folder name `..` refers to immediate parent directory, and the -name `.` refers to the current directory -*/ -$tw.utils.resolvePath = function(sourcepath,rootpath) { - // If the source path starts with ./ or ../ then it is relative to the root - if(sourcepath.substr(0,2) === "./" || sourcepath.substr(0,3) === "../" ) { - var src = sourcepath.split("/"), - root = rootpath.split("/"); - // Remove the filename part of the root - root.splice(root.length-1,1); - // Process the source path bit by bit onto the end of the root path - while(src.length > 0) { - var c = src.shift(); - if(c === "..") { // Slice off the last root entry for a double dot - if(root.length > 0) { - root.splice(root.length-1,1); - } - } else if(c !== ".") { // Ignore dots - root.push(c); // Copy other elements across - } - } - return root.join("/"); - } else { - // If it isn't relative, just return the path - if(rootpath) { - var root = rootpath.split("/"); - // Remove the filename part of the root - root.splice(root.length - 1, 1); - return root.join("/") + "/" + sourcepath; + }; + + // Stringify an array of tiddler titles into a list string + $tw.utils.stringifyList = function(value) { + if($tw.utils.isArray(value)) { + var result = new Array(value.length); + for(var t=0, l=value.length; t 0) || (diff[0] === 0 && diff[1] > 0) || (diff[0] === 0 & diff[1] === 0 & diff[2] > 0)) { - return +1; - } else if((diff[0] < 0) || (diff[0] === 0 && diff[1] < 0) || (diff[0] === 0 & diff[1] === 0 & diff[2] < 0)) { - return -1; - } else { - return 0; - } -}; - -/* -Returns true if the version string A is greater than the version string B. Returns true if the versions are the same -*/ -$tw.utils.checkVersions = function(versionStringA,versionStringB) { - return $tw.utils.compareVersions(versionStringA,versionStringB) !== -1; -}; - -/* -Register file type information -options: {flags: flags,deserializerType: deserializerType} - flags:"image" for image types - deserializerType: defaults to type if not specified -*/ -$tw.utils.registerFileType = function(type,encoding,extension,options) { - options = options || {}; - if($tw.utils.isArray(extension)) { - $tw.utils.each(extension,function(extension) { - $tw.config.fileExtensionInfo[extension] = {type: type}; - }); - extension = extension[0]; - } else { - $tw.config.fileExtensionInfo[extension] = {type: type}; - } - $tw.config.contentTypeInfo[type] = {encoding: encoding, extension: extension, flags: options.flags || [], deserializerType: options.deserializerType || type}; -}; - -/* -Given an extension, always access the $tw.config.fileExtensionInfo -using a lowercase extension only. -*/ -$tw.utils.getFileExtensionInfo = function(ext) { - return ext ? $tw.config.fileExtensionInfo[ext.toLowerCase()] : null; -} - -/* -Given an extension, get the correct encoding for that file. -defaults to utf8 -*/ -$tw.utils.getTypeEncoding = function(ext) { - var extensionInfo = $tw.utils.getFileExtensionInfo(ext), - type = extensionInfo ? extensionInfo.type : null, - typeInfo = type ? $tw.config.contentTypeInfo[type] : null; - return typeInfo ? typeInfo.encoding : "utf8"; -}; - -var globalCheck =[ - " Object.defineProperty(Object.prototype, '__temp__', {", - " get: function () { return this; },", - " configurable: true", - " });", - " if(Object.keys(__temp__).length){", - " console.log(\"Warning: Global assignment detected\",Object.keys(__temp__));", - " delete Object.prototype.__temp__;", - " }", - " delete Object.prototype.__temp__;", -].join('\n'); - -/* -Run code globally with specified context variables in scope -*/ -$tw.utils.evalGlobal = function(code,context,filename,sandbox,allowGlobals) { - var contextCopy = $tw.utils.extend(Object.create(null),context); - // Get the context variables as a pair of arrays of names and values - var contextNames = [], contextValues = []; - $tw.utils.each(contextCopy,function(value,name) { - contextNames.push(name); - contextValues.push(value); - }); - // Add the code prologue and epilogue - code = [ - "(function(" + contextNames.join(",") + ") {", - " (function(){" + code + "\n;})();\n", - (!$tw.browser && sandbox && !allowGlobals) ? globalCheck : "", - "\nreturn exports;\n", - "})" - ].join(""); - - // Compile the code into a function - var fn; - if($tw.browser) { - fn = window["eval"](code + "\n\n//# sourceURL=" + filename); - } else { - if(sandbox){ - fn = vm.runInContext(code,sandbox,filename) + }; + + // Parse a string array from a bracketted list. For example "OneTiddler [[Another Tiddler]] LastOne" + $tw.utils.parseStringArray = function(value, allowDuplicate) { + if(typeof value === "string") { + var memberRegExp = /(?:^|[^\S\xA0])(?:\[\[(.*?)\]\])(?=[^\S\xA0]|$)|([\S\xA0]+)/mg, + results = [], names = {}, + match; + do { + match = memberRegExp.exec(value); + if(match) { + var item = match[1] || match[2]; + if(item !== undefined && (!$tw.utils.hop(names,item) || allowDuplicate)) { + results.push(item); + names[item] = true; + } + } + } while(match); + return results; + } else if($tw.utils.isArray(value)) { + return value; } else { - fn = vm.runInThisContext(code,filename); - } - } - // Call the function and return the exports - return fn.apply(null,contextValues); -}; -$tw.utils.sandbox = !$tw.browser ? vm.createContext({}) : undefined; -/* -Run code in a sandbox with only the specified context variables in scope -*/ -$tw.utils.evalSandboxed = $tw.browser ? $tw.utils.evalGlobal : function(code,context,filename,allowGlobals) { - return $tw.utils.evalGlobal( - code,context,filename, - allowGlobals ? vm.createContext({}) : $tw.utils.sandbox, - allowGlobals - ); -}; - -/* -Creates a PasswordPrompt object -*/ -$tw.utils.PasswordPrompt = function() { - // Store of pending password prompts - this.passwordPrompts = []; - // Create the wrapper - this.promptWrapper = $tw.utils.domMaker("div",{"class":"tc-password-wrapper"}); - document.body.appendChild(this.promptWrapper); - // Hide the empty wrapper - this.setWrapperDisplay(); -}; - -/* -Hides or shows the wrapper depending on whether there are any outstanding prompts -*/ -$tw.utils.PasswordPrompt.prototype.setWrapperDisplay = function() { - if(this.passwordPrompts.length) { - this.promptWrapper.style.display = "block"; - } else { - this.promptWrapper.style.display = "none"; - } -}; - -/* -Adds a new password prompt. Options are: -submitText: text to use for submit button (defaults to "Login") -serviceName: text of the human readable service name -noUserName: set true to disable username prompt -canCancel: set true to enable a cancel button (callback called with null) -repeatPassword: set true to prompt for the password twice -callback: function to be called on submission with parameter of object {username:,password:}. Callback must return `true` to remove the password prompt -*/ -$tw.utils.PasswordPrompt.prototype.createPrompt = function(options) { - // Create and add the prompt to the DOM - var self = this, - submitText = options.submitText || "Login", - dm = $tw.utils.domMaker, - children = [dm("h1",{text: options.serviceName})]; - if(!options.noUserName) { - children.push(dm("input",{ - attributes: {type: "text", name: "username", placeholder: $tw.language.getString("Encryption/Username")} - })); - } - children.push(dm("input",{ - attributes: { - type: "password", - name: "password", - placeholder: ( $tw.language == undefined ? "Password" : $tw.language.getString("Encryption/Password") ) - } - })); - if(options.repeatPassword) { - children.push(dm("input",{ - attributes: { - type: "password", - name: "password2", - placeholder: $tw.language.getString("Encryption/RepeatPassword") - } - })); - } - if(options.canCancel) { - children.push(dm("button",{ - text: $tw.language.getString("Encryption/Cancel"), - attributes: { - type: "button" - }, - eventListeners: [{ - name: "click", - handlerFunction: function(event) { - self.removePrompt(promptInfo); - options.callback(null); + return null; + } + }; + + // Parse a block of name:value fields. The `fields` object is used as the basis for the return value + $tw.utils.parseFields = function(text,fields) { + fields = fields || Object.create(null); + text.split(/\r?\n/mg).forEach(function(line) { + if(line.charAt(0) !== "#") { + var p = line.indexOf(":"); + if(p !== -1) { + var field = line.substr(0, p).trim(), + value = line.substr(p+1).trim(); + if(field) { + fields[field] = value; } - }] - })); - } - children.push(dm("button",{ - attributes: {type: "submit"}, - text: submitText - })); - var form = dm("form",{ - attributes: {autocomplete: "off"}, - children: children - }); - this.promptWrapper.appendChild(form); - window.setTimeout(function() { - form.elements[0].focus(); - },10); - // Add a submit event handler - var self = this; - form.addEventListener("submit",function(event) { - // Collect the form data - var data = {},t; - $tw.utils.each(form.elements,function(element) { - if(element.name && element.value) { - data[element.name] = element.value; + } } }); - // Check that the passwords match - if(options.repeatPassword && data.password !== data.password2) { - alert($tw.language.getString("Encryption/PasswordNoMatch")); - } else { - // Call the callback - if(options.callback(data)) { - // Remove the prompt if the callback returned true - self.removePrompt(promptInfo); + return fields; + }; + + // Safely parse a string as JSON + $tw.utils.parseJSONSafe = function(text,defaultJSON) { + try { + return JSON.parse(text); + } catch(e) { + if(typeof defaultJSON === "function") { + return defaultJSON(e); } else { - // Clear the password if the callback returned false - $tw.utils.each(form.elements,function(element) { - if(element.name === "password" || element.name === "password2") { - element.value = ""; - } - }); + return defaultJSON || {}; } } - event.preventDefault(); - return false; - },true); - // Add the prompt to the list - var promptInfo = { - serviceName: options.serviceName, - callback: options.callback, - form: form, - owner: this - }; - this.passwordPrompts.push(promptInfo); - // Make sure the wrapper is displayed - this.setWrapperDisplay(); - return promptInfo; -}; - -$tw.utils.PasswordPrompt.prototype.removePrompt = function(promptInfo) { - var i = this.passwordPrompts.indexOf(promptInfo); - if(i !== -1) { - this.passwordPrompts.splice(i,1); - promptInfo.form.parentNode.removeChild(promptInfo.form); - this.setWrapperDisplay(); - } -} - -/* -Crypto helper object for encrypted content. It maintains the password text in a closure, and provides methods to change -the password, and to encrypt/decrypt a block of text -*/ -$tw.utils.Crypto = function() { - var sjcl = $tw.node ? (global.sjcl || require("./sjcl.js")) : window.sjcl, - currentPassword = null, - callSjcl = function(method,inputText,password) { - password = password || currentPassword; - var outputText; - try { - if(password) { - outputText = sjcl[method](password,inputText); + }; + + /* + Resolves a source filepath delimited with `/` relative to a specified absolute root filepath. + In relative paths, the special folder name `..` refers to immediate parent directory, and the + name `.` refers to the current directory + */ + $tw.utils.resolvePath = function(sourcepath,rootpath) { + // If the source path starts with ./ or ../ then it is relative to the root + if(sourcepath.substr(0,2) === "./" || sourcepath.substr(0,3) === "../" ) { + var src = sourcepath.split("/"), + root = rootpath.split("/"); + // Remove the filename part of the root + root.splice(root.length-1,1); + // Process the source path bit by bit onto the end of the root path + while(src.length > 0) { + var c = src.shift(); + if(c === "..") { // Slice off the last root entry for a double dot + if(root.length > 0) { + root.splice(root.length-1,1); + } + } else if(c !== ".") { // Ignore dots + root.push(c); // Copy other elements across } - } catch(ex) { - console.log("Crypto error:" + ex); - outputText = null; } - return outputText; - }; - this.setPassword = function(newPassword) { - currentPassword = newPassword; - this.updateCryptoStateTiddler(); - }; - this.updateCryptoStateTiddler = function() { - if($tw.wiki) { - var state = currentPassword ? "yes" : "no", - tiddler = $tw.wiki.getTiddler("$:/isEncrypted"); - if(!tiddler || tiddler.fields.text !== state) { - $tw.wiki.addTiddler(new $tw.Tiddler({title: "$:/isEncrypted", text: state})); + return root.join("/"); + } else { + // If it isn't relative, just return the path + if(rootpath) { + var root = rootpath.split("/"); + // Remove the filename part of the root + root.splice(root.length - 1, 1); + return root.join("/") + "/" + sourcepath; + } else { + return sourcepath; } } }; - this.hasPassword = function() { - return !!currentPassword; - } - this.encrypt = function(text,password) { - return callSjcl("encrypt",text,password); - }; - this.decrypt = function(text,password) { - return callSjcl("decrypt",text,password); - }; -}; - -$tw.utils.CSE = function () { - var currentPassword = null; - this.setPassword = function(newPassword) { - currentPassword = newPassword; - if($tw.CSE.launched){ - var isRemembered = $tw.wiki.getTiddlerData("$:/plugins/FSpark/TW5-CSE/metaconfig.json") - if(isRemembered && isRemembered["RmbPwd"]==="yes"){ - this.rememberPassword(); - } + + /* + Parse a semantic version string into its constituent parts -- see https://semver.org + */ + $tw.utils.parseVersion = function(version) { + var match = /^v?((\d+)\.(\d+)\.(\d+))(?:-([\dA-Za-z\-]+(?:\.[\dA-Za-z\-]+)*))?(?:\+([\dA-Za-z\-]+(?:\.[\dA-Za-z\-]+)*))?$/.exec(version); + if(match) { + return { + version: match[1], + major: parseInt(match[2],10), + minor: parseInt(match[3],10), + patch: parseInt(match[4],10), + prerelease: match[5], + build: match[6] + }; + } else { + return null; } - this.updateCryptoStateTiddler(); }; - this.rememberPassword = function (){ - if(window && window.localStorage){ - window.localStorage.setItem("tw5-cse-pwd", currentPassword); + + /* + Returns +1 if the version string A is greater than the version string B, 0 if they are the same, and +1 if B is greater than A. + Missing or malformed version strings are parsed as 0.0.0 + */ + $tw.utils.compareVersions = function(versionStringA,versionStringB) { + var defaultVersion = { + major: 0, + minor: 0, + patch: 0 + }, + versionA = $tw.utils.parseVersion(versionStringA) || defaultVersion, + versionB = $tw.utils.parseVersion(versionStringB) || defaultVersion, + diff = [ + versionA.major - versionB.major, + versionA.minor - versionB.minor, + versionA.patch - versionB.patch + ]; + if((diff[0] > 0) || (diff[0] === 0 && diff[1] > 0) || (diff[0] === 0 & diff[1] === 0 & diff[2] > 0)) { + return +1; + } else if((diff[0] < 0) || (diff[0] === 0 && diff[1] < 0) || (diff[0] === 0 & diff[1] === 0 & diff[2] < 0)) { + return -1; + } else { + return 0; } }; - this.forgetPassword = function (){ - if(window && window.localStorage){ - window.localStorage.removeItem("tw5-cse-pwd"); - } + + /* + Returns true if the version string A is greater than the version string B. Returns true if the versions are the same + */ + $tw.utils.checkVersions = function(versionStringA,versionStringB) { + return $tw.utils.compareVersions(versionStringA,versionStringB) !== -1; }; - this.updateCryptoStateTiddler = function() { - if($tw.wiki) { - var state = currentPassword ? "yes" : "no", - tiddler = $tw.wiki.getTiddler("$:/isCSEncrypted"); - if(!tiddler || tiddler.fields.text !== state) { - $tw.wiki.addTiddler(new $tw.Tiddler({title: "$:/isCSEncrypted", text: state})); - } + + /* + Register file type information + options: {flags: flags,deserializerType: deserializerType} + flags:"image" for image types + deserializerType: defaults to type if not specified + */ + $tw.utils.registerFileType = function(type,encoding,extension,options) { + options = options || {}; + if($tw.utils.isArray(extension)) { + $tw.utils.each(extension,function(extension) { + $tw.config.fileExtensionInfo[extension] = {type: type}; + }); + extension = extension[0]; + } else { + $tw.config.fileExtensionInfo[extension] = {type: type}; } + $tw.config.contentTypeInfo[type] = {encoding: encoding, extension: extension, flags: options.flags || [], deserializerType: options.deserializerType || type}; }; - this.forcePush = function (filter, widget) { - debugger; - filter = filter || $tw.wiki.getTiddlerText('$:/config/TW5-CSE/EncryptFilter',"[all[]!is[system]]") - - widget = widget || $tw.rootWidget - - var filterTiddlers = $tw.wiki.filterTiddlers(filter, widget) - var filterTiddlersLength = filterTiddlers.length - - filterTiddlers.forEach(function (title) { - if($tw.utils.hop($tw.wiki.changeCount, title)) { - $tw.wiki.changeCount[title]++; + + /* + Given an extension, always access the $tw.config.fileExtensionInfo + using a lowercase extension only. + */ + $tw.utils.getFileExtensionInfo = function(ext) { + return ext ? $tw.config.fileExtensionInfo[ext.toLowerCase()] : null; + } + + /* + Given an extension, get the correct encoding for that file. + defaults to utf8 + */ + $tw.utils.getTypeEncoding = function(ext) { + var extensionInfo = $tw.utils.getFileExtensionInfo(ext), + type = extensionInfo ? extensionInfo.type : null, + typeInfo = type ? $tw.config.contentTypeInfo[type] : null; + return typeInfo ? typeInfo.encoding : "utf8"; + }; + + var globalCheck =[ + " Object.defineProperty(Object.prototype, '__temp__', {", + " get: function () { return this; },", + " configurable: true", + " });", + " if(Object.keys(__temp__).length){", + " console.log(\"Warning: Global assignment detected\",Object.keys(__temp__));", + " delete Object.prototype.__temp__;", + " }", + " delete Object.prototype.__temp__;", + ].join('\n'); + + /* + Run code globally with specified context variables in scope + */ + $tw.utils.evalGlobal = function(code,context,filename,sandbox,allowGlobals) { + var contextCopy = $tw.utils.extend(Object.create(null),context); + // Get the context variables as a pair of arrays of names and values + var contextNames = [], contextValues = []; + $tw.utils.each(contextCopy,function(value,name) { + contextNames.push(name); + contextValues.push(value); + }); + // Add the code prologue and epilogue + code = [ + "(function(" + contextNames.join(",") + ") {", + " (function(){" + code + "\n;})();\n", + (!$tw.browser && sandbox && !allowGlobals) ? globalCheck : "", + "\nreturn exports;\n", + "})" + ].join(""); + + // Compile the code into a function + var fn; + if($tw.browser) { + fn = window["eval"](code + "\n\n//# sourceURL=" + filename); + } else { + if(sandbox){ + fn = vm.runInContext(code,sandbox,filename) } else { - $tw.wiki.changeCount[title] = 1; + fn = vm.runInThisContext(code,filename); } - }) - - var id = $tw.wiki.getTiddlerText('$:/temp/CSE-IntervalID') - if(id) clearTimeout(parseInt(id)) - var self = this; - $tw.modal.display("$:/plugins/FSpark/TW5-CSE/ui/PushingModal") - var startTime = Date.now(); - id = setInterval(function() { - if($tw.syncer.isDirty()){ - // Filter out unsynced - filterTiddlers = filterTiddlers.filter(function (title) { - return !$tw.syncer.tiddlerInfo[title] || $tw.wiki.getChangeCount(title) > $tw.syncer.tiddlerInfo[title].changeCount - }) - var syncedTiddlers = filterTiddlersLength - filterTiddlers.length - var percentComplete = (syncedTiddlers * 100 / filterTiddlersLength).toFixed(2) + '%' - - var endTime = Date.now(); - var timeElapsed = (endTime - startTime) / 1000; - var syncSpeed = syncedTiddlers / timeElapsed; - var remainingTiddlersSize = filterTiddlers.length; - var remainingTime = remainingTiddlersSize / syncSpeed; - - var hours = Math.floor(remainingTime / 3600).toString().padStart(2, '0'); - var minutes = Math.floor((remainingTime % 3600) / 60).toString().padStart(2, '0'); - var seconds = Math.floor(remainingTime % 60).toString().padStart(2, '0'); - - $tw.wiki.addTiddler({ - title: "$:/temp/CSENumTasksInProgress", - text: `${syncedTiddlers}/${filterTiddlersLength} ${percentComplete}` }) - $tw.wiki.addTiddler({ - title: "$:/temp/CSESyncEstimatedTimeLeft", - text: `Estimated time left: ${hours}:${minutes}:${seconds}` }) - }else{ - // debugger; - $tw.wiki.addTiddler({title: "$:/state/cse-modal-close", text: "yes"}) - clearTimeout(id) - $tw.wiki.deleteTiddler("$:/temp/CSE-IntervalID") - $tw.wiki.deleteTiddler("$:/temp/CSENumTasksInProgress") - } - }, 500); - $tw.wiki.addTiddler({title: "$:/temp/CSE-IntervalID",text: id.toString()}) - } - this.saveTiddler = function (tiddler, fields) { - debugger; - $tw.wiki.addTiddler( - new $tw.Tiddler( - // $tw.wiki.getModificationFields(), - tiddler, - this.clearNonStandardFields(tiddler), - fields - ) + } + // Call the function and return the exports + return fn.apply(null,contextValues); + }; + $tw.utils.sandbox = !$tw.browser ? vm.createContext({}) : undefined; + /* + Run code in a sandbox with only the specified context variables in scope + */ + $tw.utils.evalSandboxed = $tw.browser ? $tw.utils.evalGlobal : function(code,context,filename,allowGlobals) { + return $tw.utils.evalGlobal( + code,context,filename, + allowGlobals ? vm.createContext({}) : $tw.utils.sandbox, + allowGlobals ); }; - this.encryptFields = function (title, password) { - password = password || currentPassword; - var jsonData = $tw.wiki.getTiddlerAsJson(title); - return $tw.crypto.encrypt(jsonData, password); + + /* + Creates a PasswordPrompt object + */ + $tw.utils.PasswordPrompt = function() { + // Store of pending password prompts + this.passwordPrompts = []; + // Create the wrapper + this.promptWrapper = $tw.utils.domMaker("div",{"class":"tc-password-wrapper"}); + document.body.appendChild(this.promptWrapper); + // Hide the empty wrapper + this.setWrapperDisplay(); }; - - this.decryptFields = function (fields, password) { - password = password || currentPassword; - var JSONfields = $tw.crypto.decrypt(fields.encrypted, password); - if(!!JSONfields) { - return JSON.parse(JSONfields); - } - console.log( - "Error decrypting " + fields.title + ". Probably bad password" - ); - return false; + + /* + Hides or shows the wrapper depending on whether there are any outstanding prompts + */ + $tw.utils.PasswordPrompt.prototype.setWrapperDisplay = function() { + if(this.passwordPrompts.length) { + this.promptWrapper.style.display = "block"; + } else { + this.promptWrapper.style.display = "none"; + } }; - this.clearNonStandardFields = function (tiddler) { - var standardFieldNames = - "title tags modified modifier created creator".split(" "); - var clearFields = {}; - for(var fieldName in tiddler.fields) { - if(standardFieldNames.indexOf(fieldName) === -1) { - clearFields[fieldName] = undefined; + + /* + Adds a new password prompt. Options are: + submitText: text to use for submit button (defaults to "Login") + serviceName: text of the human readable service name + noUserName: set true to disable username prompt + canCancel: set true to enable a cancel button (callback called with null) + repeatPassword: set true to prompt for the password twice + callback: function to be called on submission with parameter of object {username:,password:}. Callback must return `true` to remove the password prompt + */ + $tw.utils.PasswordPrompt.prototype.createPrompt = function(options) { + // Create and add the prompt to the DOM + var self = this, + submitText = options.submitText || "Login", + dm = $tw.utils.domMaker, + children = [dm("h1",{text: options.serviceName})]; + if(!options.noUserName) { + children.push(dm("input",{ + attributes: {type: "text", name: "username", placeholder: $tw.language.getString("Encryption/Username")} + })); + } + children.push(dm("input",{ + attributes: { + type: "password", + name: "password", + placeholder: ( $tw.language == undefined ? "Password" : $tw.language.getString("Encryption/Password") ) } + })); + if(options.repeatPassword) { + children.push(dm("input",{ + attributes: { + type: "password", + name: "password2", + placeholder: $tw.language.getString("Encryption/RepeatPassword") + } + })); + } + if(options.canCancel) { + children.push(dm("button",{ + text: $tw.language.getString("Encryption/Cancel"), + attributes: { + type: "button" + }, + eventListeners: [{ + name: "click", + handlerFunction: function(event) { + self.removePrompt(promptInfo); + options.callback(null); + } + }] + })); } - console.log("Cleared fields " + JSON.stringify(clearFields)); - return clearFields; + children.push(dm("button",{ + attributes: {type: "submit"}, + text: submitText + })); + var form = dm("form",{ + attributes: {autocomplete: "off"}, + children: children + }); + this.promptWrapper.appendChild(form); + window.setTimeout(function() { + form.elements[0].focus(); + },10); + // Add a submit event handler + var self = this; + form.addEventListener("submit",function(event) { + // Collect the form data + var data = {},t; + $tw.utils.each(form.elements,function(element) { + if(element.name && element.value) { + data[element.name] = element.value; + } + }); + // Check that the passwords match + if(options.repeatPassword && data.password !== data.password2) { + alert($tw.language.getString("Encryption/PasswordNoMatch")); + } else { + // Call the callback + if(options.callback(data)) { + // Remove the prompt if the callback returned true + self.removePrompt(promptInfo); + } else { + // Clear the password if the callback returned false + $tw.utils.each(form.elements,function(element) { + if(element.name === "password" || element.name === "password2") { + element.value = ""; + } + }); + } + } + event.preventDefault(); + return false; + },true); + // Add the prompt to the list + var promptInfo = { + serviceName: options.serviceName, + callback: options.callback, + form: form, + owner: this + }; + this.passwordPrompts.push(promptInfo); + // Make sure the wrapper is displayed + this.setWrapperDisplay(); + return promptInfo; }; -} -/////////////////////////// Module mechanism - -/* -Execute the module named 'moduleName'. The name can optionally be relative to the module named 'moduleRoot' -*/ -$tw.modules.execute = function(moduleName,moduleRoot) { - var name = moduleName; - if(moduleName.charAt(0) === ".") { - name = $tw.utils.resolvePath(moduleName,moduleRoot) - } - if(!$tw.modules.titles[name]) { - if($tw.modules.titles[name + ".js"]) { - name = name + ".js"; - } else if($tw.modules.titles[name + "/index.js"]) { - name = name + "/index.js"; - } else if($tw.modules.titles[moduleName]) { - name = moduleName; - } else if($tw.modules.titles[moduleName + ".js"]) { - name = moduleName + ".js"; - } else if($tw.modules.titles[moduleName + "/index.js"]) { - name = moduleName + "/index.js"; + + $tw.utils.PasswordPrompt.prototype.removePrompt = function(promptInfo) { + var i = this.passwordPrompts.indexOf(promptInfo); + if(i !== -1) { + this.passwordPrompts.splice(i,1); + promptInfo.form.parentNode.removeChild(promptInfo.form); + this.setWrapperDisplay(); + } + } + + /* + Crypto helper object for encrypted content. It maintains the password text in a closure, and provides methods to change + the password, and to encrypt/decrypt a block of text + */ + $tw.utils.Crypto = function() { + var sjcl = $tw.node ? (global.sjcl || require("./sjcl.js")) : window.sjcl, + currentPassword = null, + callSjcl = function(method,inputText,password) { + password = password || currentPassword; + var outputText; + try { + if(password) { + outputText = sjcl[method](password,inputText); + } + } catch(ex) { + console.log("Crypto error:" + ex); + outputText = null; + } + return outputText; + }; + $tw.sjcl = sjcl; + this.setPassword = function(newPassword) { + currentPassword = newPassword; + this.updateCryptoStateTiddler(); + }; + this.updateCryptoStateTiddler = function() { + if($tw.wiki) { + var state = currentPassword ? "yes" : "no", + tiddler = $tw.wiki.getTiddler("$:/isEncrypted"); + if(!tiddler || tiddler.fields.text !== state) { + $tw.wiki.addTiddler(new $tw.Tiddler({title: "$:/isEncrypted", text: state})); + } + } + }; + this.hasPassword = function() { + return !!currentPassword; } - } - var moduleInfo = $tw.modules.titles[name], - tiddler = $tw.wiki.getTiddler(name), - _exports = {}, - sandbox = { - module: {exports: _exports}, - //moduleInfo: moduleInfo, - exports: _exports, - console: console, - setInterval: setInterval, - clearInterval: clearInterval, - setTimeout: setTimeout, - clearTimeout: clearTimeout, - Buffer: $tw.browser ? undefined : Buffer, - $tw: $tw, - require: function(title) { - return $tw.modules.execute(title, name); + this.encrypt = function(text,password) { + return callSjcl("encrypt",text,password); + }; + this.decrypt = function(text,password) { + return callSjcl("decrypt",text,password); + }; + }; + + $tw.utils.CSE = function () { + var currentPassword = null; + this.setPassword = function(newPassword) { + currentPassword = newPassword; + if($tw.CSE.launched){ + var isRemembered = $tw.wiki.getTiddlerData("$:/plugins/FSpark/TW5-CSE/metaconfig.json") + if(isRemembered && isRemembered["RmbPwd"]==="yes"){ + this.rememberPassword(); + } } + this.updateCryptoStateTiddler(); }; - - Object.defineProperty(sandbox.module, "id", { - value: name, - writable: false, - enumerable: true, - configurable: false - }); - - if(!$tw.browser) { - $tw.utils.extend(sandbox,{ - process: process - }); - } else { - /* - CommonJS optional require.main property: - In a browser we offer a fake main module which points back to the boot function - (Theoretically, this may allow TW to eventually load itself as a module in the browser) - */ - Object.defineProperty(sandbox.require, "main", { - value: (typeof(require) !== "undefined") ? require.main : {TiddlyWiki: _boot}, + this.rememberPassword = function (){ + if(window && window.localStorage){ + window.localStorage.setItem("tw5-cse-pwd", currentPassword); + } + }; + this.forgetPassword = function (){ + if(window && window.localStorage){ + window.localStorage.removeItem("tw5-cse-pwd"); + } + }; + this.updateCryptoStateTiddler = function() { + if($tw.wiki) { + var state = currentPassword ? "yes" : "no", + tiddler = $tw.wiki.getTiddler("$:/isCSEncrypted"); + if(!tiddler || tiddler.fields.text !== state) { + $tw.wiki.addTiddler(new $tw.Tiddler({title: "$:/isCSEncrypted", text: state})); + } + } + }; + this.forcePush = function (filter, widget) { + debugger; + filter = filter || $tw.wiki.getTiddlerText('$:/config/TW5-CSE/EncryptFilter',"[all[]!is[system]]") + + widget = widget || $tw.rootWidget + + var filterTiddlers = $tw.wiki.filterTiddlers(filter, widget) + var filterTiddlersLength = filterTiddlers.length + + filterTiddlers.forEach(function (title) { + if($tw.utils.hop($tw.wiki.changeCount, title)) { + $tw.wiki.changeCount[title]++; + } else { + $tw.wiki.changeCount[title] = 1; + } + }) + + var id = $tw.wiki.getTiddlerText('$:/temp/CSE-IntervalID') + if(id) clearTimeout(parseInt(id)) + var self = this; + $tw.modal.display("$:/plugins/FSpark/TW5-CSE/ui/PushingModal") + var startTime = Date.now(); + id = setInterval(function() { + if($tw.syncer.isDirty()){ + // Filter out unsynced + filterTiddlers = filterTiddlers.filter(function (title) { + return !$tw.syncer.tiddlerInfo[title] || $tw.wiki.getChangeCount(title) > $tw.syncer.tiddlerInfo[title].changeCount + }) + var syncedTiddlers = filterTiddlersLength - filterTiddlers.length + var percentComplete = (syncedTiddlers * 100 / filterTiddlersLength).toFixed(2) + '%' + + var endTime = Date.now(); + var timeElapsed = (endTime - startTime) / 1000; + var syncSpeed = syncedTiddlers / timeElapsed; + var remainingTiddlersSize = filterTiddlers.length; + var remainingTime = remainingTiddlersSize / syncSpeed; + + var hours = Math.floor(remainingTime / 3600).toString().padStart(2, '0'); + var minutes = Math.floor((remainingTime % 3600) / 60).toString().padStart(2, '0'); + var seconds = Math.floor(remainingTime % 60).toString().padStart(2, '0'); + + $tw.wiki.addTiddler({ + title: "$:/temp/CSENumTasksInProgress", + text: `${syncedTiddlers}/${filterTiddlersLength} ${percentComplete}` }) + $tw.wiki.addTiddler({ + title: "$:/temp/CSESyncEstimatedTimeLeft", + text: `Estimated time left: ${hours}:${minutes}:${seconds}` }) + }else{ + // debugger; + $tw.wiki.addTiddler({title: "$:/state/cse-modal-close", text: "yes"}) + clearTimeout(id) + $tw.wiki.deleteTiddler("$:/temp/CSE-IntervalID") + $tw.wiki.deleteTiddler("$:/temp/CSENumTasksInProgress") + } + }, 500); + $tw.wiki.addTiddler({title: "$:/temp/CSE-IntervalID",text: id.toString()}) + } + this.saveTiddler = function (tiddler, fields) { + debugger; + $tw.wiki.addTiddler( + new $tw.Tiddler( + // $tw.wiki.getModificationFields(), + tiddler, + this.clearNonStandardFields(tiddler), + fields + ) + ); + }; + this.encryptFields = function (title, password) { + password = password || currentPassword; + var jsonData = $tw.wiki.getTiddlerAsJson(title); + return $tw.crypto.encrypt(jsonData, password); + }; + + this.decryptFields = function (fields, password) { + password = password || currentPassword; + var JSONfields = $tw.crypto.decrypt(fields.encrypted, password); + if(!!JSONfields) { + return JSON.parse(JSONfields); + } + console.log( + "Error decrypting " + fields.title + ". Probably bad password" + ); + return false; + }; + this.clearNonStandardFields = function (tiddler) { + var standardFieldNames = + "title tags modified modifier created creator".split(" "); + var clearFields = {}; + for(var fieldName in tiddler.fields) { + if(standardFieldNames.indexOf(fieldName) === -1) { + clearFields[fieldName] = undefined; + } + } + console.log("Cleared fields " + JSON.stringify(clearFields)); + return clearFields; + }; + } + /////////////////////////// Module mechanism + + /* + Execute the module named 'moduleName'. The name can optionally be relative to the module named 'moduleRoot' + */ + $tw.modules.execute = function(moduleName,moduleRoot) { + var name = moduleName; + if(moduleName.charAt(0) === ".") { + name = $tw.utils.resolvePath(moduleName,moduleRoot) + } + if(!$tw.modules.titles[name]) { + if($tw.modules.titles[name + ".js"]) { + name = name + ".js"; + } else if($tw.modules.titles[name + "/index.js"]) { + name = name + "/index.js"; + } else if($tw.modules.titles[moduleName]) { + name = moduleName; + } else if($tw.modules.titles[moduleName + ".js"]) { + name = moduleName + ".js"; + } else if($tw.modules.titles[moduleName + "/index.js"]) { + name = moduleName + "/index.js"; + } + } + var moduleInfo = $tw.modules.titles[name], + tiddler = $tw.wiki.getTiddler(name), + _exports = {}, + sandbox = { + module: {exports: _exports}, + //moduleInfo: moduleInfo, + exports: _exports, + console: console, + setInterval: setInterval, + clearInterval: clearInterval, + setTimeout: setTimeout, + clearTimeout: clearTimeout, + Buffer: $tw.browser ? undefined : Buffer, + $tw: $tw, + require: function(title) { + return $tw.modules.execute(title, name); + } + }; + + Object.defineProperty(sandbox.module, "id", { + value: name, writable: false, enumerable: true, configurable: false }); - } - if(!moduleInfo) { - // We could not find the module on this path - // Try to defer to browserify etc, or node - var deferredModule; - if($tw.browser) { - if(window.require) { - try { - return window.require(moduleName); - } catch(e) {} - } - throw "Cannot find module named '" + moduleName + "' required by module '" + moduleRoot + "', resolved to " + name; + + if(!$tw.browser) { + $tw.utils.extend(sandbox,{ + process: process + }); } else { - // If we don't have a module with that name, let node.js try to find it - return require(moduleName); + /* + CommonJS optional require.main property: + In a browser we offer a fake main module which points back to the boot function + (Theoretically, this may allow TW to eventually load itself as a module in the browser) + */ + Object.defineProperty(sandbox.require, "main", { + value: (typeof(require) !== "undefined") ? require.main : {TiddlyWiki: _boot}, + writable: false, + enumerable: true, + configurable: false + }); } - } - // Execute the module if we haven't already done so - if(!moduleInfo.exports) { - try { - // Check the type of the definition - if(typeof moduleInfo.definition === "function") { // Function - moduleInfo.exports = _exports; - moduleInfo.definition(moduleInfo,moduleInfo.exports,sandbox.require); - } else if(typeof moduleInfo.definition === "string") { // String - moduleInfo.exports = _exports; - $tw.utils.evalSandboxed(moduleInfo.definition,sandbox,tiddler.fields.title); - if(sandbox.module.exports) { - moduleInfo.exports = sandbox.module.exports; //more codemirror workaround - } - } else { // Object - moduleInfo.exports = moduleInfo.definition; - } - } catch(e) { - if (e instanceof SyntaxError) { - var line = e.lineNumber || e.line; // Firefox || Safari - if (typeof(line) != "undefined" && line !== null) { - $tw.utils.error("Syntax error in boot module " + name + ":" + line + ":\n" + e.stack); - } else if(!$tw.browser) { - // this is the only way to get node.js to display the line at which the syntax error appeared, - // and $tw.utils.error would exit anyway - // cf. https://bugs.chromium.org/p/v8/issues/detail?id=2589 - throw e; - } else { - // Opera: line number is included in e.message - // Chrome/IE: there's currently no way to get the line number - $tw.utils.error("Syntax error in boot module " + name + ": " + e.message + "\n" + e.stack); + if(!moduleInfo) { + // We could not find the module on this path + // Try to defer to browserify etc, or node + var deferredModule; + if($tw.browser) { + if(window.require) { + try { + return window.require(moduleName); + } catch(e) {} } + throw "Cannot find module named '" + moduleName + "' required by module '" + moduleRoot + "', resolved to " + name; } else { - // line number should be included in e.stack for runtime errors - $tw.utils.error("Error executing boot module " + name + ": " + String(e) + "\n\n" + e.stack); + // If we don't have a module with that name, let node.js try to find it + return require(moduleName); } } - } - // Return the exports of the module - return moduleInfo.exports; -}; - -/* -Apply a callback to each module of a particular type - moduleType: type of modules to enumerate - callback: function called as callback(title,moduleExports) for each module -*/ -$tw.modules.forEachModuleOfType = function(moduleType,callback) { - var modules = $tw.modules.types[moduleType]; - $tw.utils.each(modules,function(element,title) { - callback(title,$tw.modules.execute(title)); - }); -}; - -/* -Get all the modules of a particular type in a hashmap by their `name` field -*/ -$tw.modules.getModulesByTypeAsHashmap = function(moduleType,nameField) { - nameField = nameField || "name"; - var results = Object.create(null); - $tw.modules.forEachModuleOfType(moduleType,function(title,module) { - results[module[nameField]] = module; - }); - return results; -}; - -/* -Apply the exports of the modules of a particular type to a target object -*/ -$tw.modules.applyMethods = function(moduleType,targetObject) { - if(!targetObject) { - targetObject = Object.create(null); - } - $tw.modules.forEachModuleOfType(moduleType,function(title,module) { - $tw.utils.each(module,function(element,title,object) { - targetObject[title] = module[title]; - }); - }); - return targetObject; -}; - -/* -Return a class created from a modules. The module should export the properties to be added to those of the optional base class -*/ -$tw.modules.createClassFromModule = function(moduleExports,baseClass) { - var newClass = function() {}; - if(baseClass) { - newClass.prototype = new baseClass(); - newClass.prototype.constructor = baseClass; - } - $tw.utils.extend(newClass.prototype,moduleExports); - return newClass; -}; - -/* -Return an array of classes created from the modules of a specified type. Each module should export the properties to be added to those of the optional base class -*/ -$tw.modules.createClassesFromModules = function(moduleType,subType,baseClass) { - var classes = Object.create(null); - $tw.modules.forEachModuleOfType(moduleType,function(title,moduleExports) { - if(!subType || moduleExports.types[subType]) { - classes[moduleExports.name] = $tw.modules.createClassFromModule(moduleExports,baseClass); - } - }); - return classes; -}; - -/////////////////////////// Barebones tiddler object - -/* -Construct a tiddler object from a hashmap of tiddler fields. If multiple hasmaps are provided they are merged, -taking precedence to the right -*/ -$tw.Tiddler = function(/* [fields,] fields */) { - this.fields = Object.create(null); - this.cache = Object.create(null); - for(var c=0; c=0; t--) { + var tiddler = pluginTiddlers[t]; + 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 results; - }; - - // Get plugin info for a plugin - this.getPluginInfo = function(title) { - return pluginInfo[title]; - }; - - // 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 || 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); - } + return unregisteredTitles; + }; + + // Unpack the currently registered plugins, creating shadow tiddlers for their constituent tiddlers + this.unpackPluginTiddlers = function() { + var self = this; + // Sort the plugin titles by the `plugin-priority` field + pluginTiddlers.sort(function(a,b) { + if("plugin-priority" in a.fields && "plugin-priority" in b.fields) { + return a.fields["plugin-priority"] - b.fields["plugin-priority"]; + } else if("plugin-priority" in a.fields) { + return -1; + } else if("plugin-priority" in b.fields) { + return +1; + } else if(a.fields.title < b.fields.title) { + return -1; + } else if(a.fields.title === b.fields.title) { + return 0; + } else { + return +1; } - }; - if(titles) { - $tw.utils.each(titles,function(title) { - checkTiddler(self.getTiddler(title),title); }); - } else { - this.each(function(tiddler,title) { - checkTiddler(tiddler,title); + // Now go through the plugins in ascending order and assign the shadows + shadowTiddlers = Object.create(null); + $tw.utils.each(pluginTiddlers,function(tiddler) { + // Extract the constituent tiddlers + if($tw.utils.hop(pluginInfo,tiddler.fields.title)) { + $tw.utils.each(pluginInfo[tiddler.fields.title].tiddlers,function(constituentTiddler,constituentTitle) { + // Save the tiddler object + if(constituentTitle) { + shadowTiddlers[constituentTitle] = { + source: tiddler.fields.title, + tiddler: new $tw.Tiddler(constituentTiddler,{title: constituentTitle}) + }; + } + }); + } + }); + shadowTiddlerTitles = null; + this.clearCache(null); + this.clearGlobalCache(); + $tw.utils.each(indexers,function(indexer) { + indexer.rebuild(); }); + }; + + if(this.addIndexersToWiki) { + this.addIndexersToWiki(); } - return registeredTitles; }; - - // 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, - 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 || tiddler.fields["plugin-type"] === pluginType) && (!titles || titles.indexOf(tiddler.fields.title) !== -1)) { - unregisteredTitles.push(tiddler.fields.title); - pluginTiddlers.splice(t,1); - } + + // Dummy methods that will be filled in after boot + $tw.Wiki.prototype.clearCache = + $tw.Wiki.prototype.clearGlobalCache = + $tw.Wiki.prototype.enqueueTiddlerEvent = function() {}; + + // Add an array of tiddlers + $tw.Wiki.prototype.addTiddlers = function(tiddlers) { + for(var t=0; t= 1) { + fields = $tw.utils.parseFields(split[0],fields); + } + if(split.length >= 2) { + fields.text = split.slice(1).join("\n\n"); + } + return [fields]; } }); - // Assemble a report tiddler - var titleReportTiddler = "TiddlyWiki Safe Mode", - report = []; - report.push("TiddlyWiki has been started in [[safe mode|https://tiddlywiki.com/static/SafeMode.html]]. All plugins are temporarily disabled. Most customisations have been disabled by renaming the following tiddlers:") - // Delete the overrides - overrides.forEach(function(title) { - var tiddler = self.getTiddler(title), - newTitle = "SAFE: " + title; - self.deleteTiddler(title); - self.addTiddler(new $tw.Tiddler(tiddler, {title: newTitle})); - report.push("* [[" + title + "|" + newTitle + "]]"); - }); - report.push() - this.addTiddler(new $tw.Tiddler({title: titleReportTiddler, text: report.join("\n\n")})); - // Set $:/DefaultTiddlers to point to our report - this.addTiddler(new $tw.Tiddler({title: "$:/DefaultTiddlers", text: "[[" + titleReportTiddler + "]]"})); -}; - -/* -Extracts tiddlers from a typed block of text, specifying default field values -*/ -$tw.Wiki.prototype.deserializeTiddlers = function(type,text,srcFields,options) { - srcFields = srcFields || Object.create(null); - options = options || {}; - var deserializer = $tw.Wiki.tiddlerDeserializerModules[options.deserializer], - fields = Object.create(null); - if(!deserializer) { - deserializer = $tw.Wiki.tiddlerDeserializerModules[type]; - } - if(!deserializer && $tw.utils.getFileExtensionInfo(type)) { - // If we didn't find the serializer, try converting it from an extension to a content type - type = $tw.utils.getFileExtensionInfo(type).type; - deserializer = $tw.Wiki.tiddlerDeserializerModules[type]; - } - if(!deserializer && $tw.config.contentTypeInfo[type]) { - // see if this type has a different deserializer registered with it - type = $tw.config.contentTypeInfo[type].deserializerType; - deserializer = $tw.Wiki.tiddlerDeserializerModules[type]; - } - if(!deserializer) { - // If we still don't have a deserializer, treat it as plain text - deserializer = $tw.Wiki.tiddlerDeserializerModules["text/plain"]; - } - for(var f in srcFields) { - fields[f] = srcFields[f]; - } - if(deserializer) { - return deserializer.call(this,text,fields,type); - } else { - // Return a raw tiddler for unknown types - fields.text = text; - return [fields]; - } -}; - -/* -Register the built in tiddler deserializer modules -*/ -var deserializeHeaderComment = function(text,fields) { - var headerCommentRegExp = new RegExp($tw.config.jsModuleHeaderRegExpString,"mg"), - match = headerCommentRegExp.exec(text); - fields.text = text; - if(match) { - fields = $tw.utils.parseFields(match[1].split(/\r?\n\r?\n/mg)[0],fields); - } - return [fields]; - }; -$tw.modules.define("$:/boot/tiddlerdeserializer/js","tiddlerdeserializer",{ - "application/javascript": deserializeHeaderComment -}); -$tw.modules.define("$:/boot/tiddlerdeserializer/css","tiddlerdeserializer",{ - "text/css": deserializeHeaderComment -}); -$tw.modules.define("$:/boot/tiddlerdeserializer/tid","tiddlerdeserializer",{ - "application/x-tiddler": function(text,fields) { - var split = text.split(/\r?\n\r?\n/mg); - if(split.length >= 1) { - fields = $tw.utils.parseFields(split[0],fields); - } - if(split.length >= 2) { - fields.text = split.slice(1).join("\n\n"); - } - return [fields]; - } -}); -$tw.modules.define("$:/boot/tiddlerdeserializer/tids","tiddlerdeserializer",{ - "application/x-tiddlers": function(text,fields) { - var titles = [], - tiddlers = [], - match = /\r?\n\r?\n/mg.exec(text); - if(match) { - fields = $tw.utils.parseFields(text.substr(0,match.index),fields); - var lines = text.substr(match.index + match[0].length).split(/\r?\n/mg); - for(var t=0; t= 0; i--) { + tiddler[attrs[i].name] = attrs[i].value; + } + return [tiddler]; + } else { + return null; + } + }, + extractModuleTiddlers = function(node) { + if(node.hasAttribute && node.hasAttribute("data-tiddler-title")) { + var text = node.innerHTML, + s = text.indexOf("{"), + e = text.lastIndexOf("}"); + if(node.hasAttribute("data-module") && s !== -1 && e !== -1) { + text = text.substring(s+1,e); + } + var fields = {text: text}, + attributes = node.attributes; + for(var a=0; a= 0; i--) { - tiddler[attrs[i].name] = attrs[i].value; - } - return [tiddler]; + }; + + /* + A default set of files for TiddlyWiki to ignore during load. + This matches what NPM ignores, and adds "*.meta" to ignore tiddler + metadata files. + */ + $tw.boot.excludeRegExp = /^\.DS_Store$|^.*\.meta$|^\..*\.swp$|^\._.*$|^\.git$|^\.github$|^\.vscode$|^\.hg$|^\.lock-wscript$|^\.svn$|^\.wafpickle-.*$|^CVS$|^npm-debug\.log$/; + + /* + Load all the tiddlers recursively from a directory, including honouring `tiddlywiki.files` files for drawing in external files. Returns an array of {filepath:,type:,tiddlers: [{..fields...}],hasMetaFile:}. Note that no file information is returned for externally loaded tiddlers, just the `tiddlers` property. + */ + $tw.loadTiddlersFromPath = function(filepath,excludeRegExp) { + excludeRegExp = excludeRegExp || $tw.boot.excludeRegExp; + var tiddlers = []; + if(fs.existsSync(filepath)) { + var stat = fs.statSync(filepath); + if(stat.isDirectory()) { + var files = fs.readdirSync(filepath); + // Look for a tiddlywiki.files file + if(files.indexOf("tiddlywiki.files") !== -1) { + Array.prototype.push.apply(tiddlers,$tw.loadTiddlersFromSpecification(filepath,excludeRegExp)); } else { - return null; + // If not, read all the files in the directory + $tw.utils.each(files,function(file) { + if(!excludeRegExp.test(file) && file !== "plugin.info") { + tiddlers.push.apply(tiddlers,$tw.loadTiddlersFromPath(filepath + path.sep + file,excludeRegExp)); + } + }); } - }, - extractModuleTiddlers = function(node) { - if(node.hasAttribute && node.hasAttribute("data-tiddler-title")) { - var text = node.innerHTML, - s = text.indexOf("{"), - e = text.lastIndexOf("}"); - if(node.hasAttribute("data-module") && s !== -1 && e !== -1) { - text = text.substring(s+1,e); + } else if(stat.isFile()) { + tiddlers.push($tw.loadTiddlersFromFile(filepath,{title: filepath})); + } + } + return tiddlers; + }; + + /* + Load all the tiddlers defined by a `tiddlywiki.files` specification file + filepath: pathname of the directory containing the specification file + */ + $tw.loadTiddlersFromSpecification = function(filepath,excludeRegExp) { + var tiddlers = []; + // Read the specification + var filesInfo = $tw.utils.parseJSONSafe(fs.readFileSync(filepath + path.sep + "tiddlywiki.files","utf8")); + // Helper to process a file + var processFile = function(filename,isTiddlerFile,fields,isEditableFile,rootPath) { + var extInfo = $tw.config.fileExtensionInfo[path.extname(filename)], + type = (extInfo || {}).type || fields.type || "text/plain", + typeInfo = $tw.config.contentTypeInfo[type] || {}, + pathname = path.resolve(filepath,filename), + text = fs.readFileSync(pathname,typeInfo.encoding || "utf8"), + metadata = $tw.loadMetadataForFile(pathname) || {}, + fileTiddlers; + if(isTiddlerFile) { + fileTiddlers = $tw.wiki.deserializeTiddlers(path.extname(pathname),text,metadata) || []; + } else { + fileTiddlers = [$tw.utils.extend({text: text},metadata)]; + } + var combinedFields = $tw.utils.extend({},fields,metadata); + $tw.utils.each(fileTiddlers,function(tiddler) { + $tw.utils.each(combinedFields,function(fieldInfo,name) { + if(typeof fieldInfo === "string" || $tw.utils.isArray(fieldInfo)) { + tiddler[name] = fieldInfo; + } else { + var value = tiddler[name]; + switch(fieldInfo.source) { + case "subdirectories": + value = path.relative(rootPath, filename).split(path.sep).slice(0, -1); + break; + case "filepath": + value = path.relative(rootPath, filename).split(path.sep).join('/'); + break; + case "filename": + value = path.basename(filename); + break; + case "filename-uri-decoded": + value = $tw.utils.decodeURIComponentSafe(path.basename(filename)); + break; + case "basename": + value = path.basename(filename,path.extname(filename)); + break; + case "basename-uri-decoded": + value = $tw.utils.decodeURIComponentSafe(path.basename(filename,path.extname(filename))); + break; + case "extname": + value = path.extname(filename); + break; + case "created": + value = new Date(fs.statSync(pathname).birthtime); + break; + case "modified": + value = new Date(fs.statSync(pathname).mtime); + break; + } + if(fieldInfo.prefix) { + value = fieldInfo.prefix + value; + } + if(fieldInfo.suffix) { + value = value + fieldInfo.suffix; + } + tiddler[name] = value; } - var fields = {text: text}, - attributes = node.attributes; - for(var a=0; a 0){ + $tw.wiki.addTiddler({title: "$:/config/OriginalTiddlerPaths", type: "application/json", text: JSON.stringify(output)}); } } - }); - return tiddlers; -}; - -/* -Load the tiddlers from a plugin folder, and package them up into a proper JSON plugin tiddler -*/ -$tw.loadPluginFolder = function(filepath,excludeRegExp) { - excludeRegExp = excludeRegExp || $tw.boot.excludeRegExp; - var infoPath = filepath + path.sep + "plugin.info"; - if(fs.existsSync(filepath) && fs.statSync(filepath).isDirectory()) { - // Read the plugin information - if(!fs.existsSync(infoPath) || !fs.statSync(infoPath).isFile()) { - console.log("Warning: missing plugin.info file in " + filepath); - return null; - } - var pluginInfo = $tw.utils.parseJSONSafe(fs.readFileSync(infoPath,"utf8"),function() {return null;}); - if(!pluginInfo) { - console.log("warning: invalid JSON in plugin.info file at " + infoPath); - pluginInfo = {}; - } - // Read the plugin files - var pluginFiles = $tw.loadTiddlersFromPath(filepath,excludeRegExp); - // Save the plugin tiddlers into the plugin info - pluginInfo.tiddlers = pluginInfo.tiddlers || Object.create(null); - for(var f=0; f 0){ - $tw.wiki.addTiddler({title: "$:/config/OriginalTiddlerPaths", type: "application/json", text: JSON.stringify(output)}); - } - } - // Load any plugins within the wiki folder - var wikiPluginsPath = path.resolve(wikiPath,$tw.config.wikiPluginsSubDir); - if(fs.existsSync(wikiPluginsPath)) { - var pluginFolders = fs.readdirSync(wikiPluginsPath); - for(var t=0; t