/*\ title: $:/core/modules/wiki.js type: application/javascript module-type: wikimethod Extension methods for the $tw.Wiki object Adds the following properties to the wiki object: * `eventListeners` is a hashmap by type of arrays of listener functions * `changedTiddlers` is a hashmap describing changes to named tiddlers since wiki change events were last dispatched. Each entry is a hashmap containing two fields: modified: true/false deleted: true/false * `changeCount` is a hashmap by tiddler title containing a numerical index that starts at zero and is incremented each time a tiddler is created changed or deleted * `caches` is a hashmap by tiddler title containing a further hashmap of named cache objects. Caches are automatically cleared when a tiddler is modified or deleted * `globalCache` is a hashmap by cache name of cache objects that are cleared whenever any tiddler change occurs \*/ (function(){ /*jslint node: true, browser: true */ /*global $tw: false */ "use strict"; var widget = require("$:/core/modules/widgets/widget.js"); var USER_NAME_TITLE = "$:/status/UserName"; /* Get the value of a text reference. Text references can have any of these forms: !! !! - specifies a field of the current tiddlers ## */ exports.getTextReference = function(textRef,defaultText,currTiddlerTitle) { var tr = $tw.utils.parseTextReference(textRef), title = tr.title || currTiddlerTitle; if(tr.field) { var tiddler = this.getTiddler(title); if(tr.field === "title") { // Special case so we can return the title of a non-existent tiddler return title; } else if(tiddler && $tw.utils.hop(tiddler.fields,tr.field)) { return tiddler.getFieldString(tr.field); } else { return defaultText; } } else if(tr.index) { return this.extractTiddlerDataItem(title,tr.index,defaultText); } else { return this.getTiddlerText(title,defaultText); } }; exports.setTextReference = function(textRef,value,currTiddlerTitle) { var tr = $tw.utils.parseTextReference(textRef), title = tr.title || currTiddlerTitle; this.setText(title,tr.field,tr.index,value); }; exports.setText = function(title,field,index,value,options) { options = options || {}; var creationFields = options.suppressTimestamp ? {} : this.getCreationFields(), modificationFields = options.suppressTimestamp ? {} : this.getModificationFields(); // Check if it is a reference to a tiddler field if(index) { var data = this.getTiddlerData(title,Object.create(null)); if(value !== undefined) { data[index] = value; } else { delete data[index]; } this.setTiddlerData(title,data,modificationFields); } else { var tiddler = this.getTiddler(title), fields = {title: title}; fields[field || "text"] = value; this.addTiddler(new $tw.Tiddler(creationFields,tiddler,fields,modificationFields)); } }; exports.deleteTextReference = function(textRef,currTiddlerTitle) { var tr = $tw.utils.parseTextReference(textRef), title,tiddler,fields; // Check if it is a reference to a tiddler if(tr.title && !tr.field) { this.deleteTiddler(tr.title); // Else check for a field reference } else if(tr.field) { title = tr.title || currTiddlerTitle; tiddler = this.getTiddler(title); if(tiddler && $tw.utils.hop(tiddler.fields,tr.field)) { fields = Object.create(null); fields[tr.field] = undefined; this.addTiddler(new $tw.Tiddler(tiddler,fields,this.getModificationFields())); } } }; exports.addEventListener = function(type,listener) { this.eventListeners = this.eventListeners || {}; this.eventListeners[type] = this.eventListeners[type] || []; this.eventListeners[type].push(listener); }; exports.removeEventListener = function(type,listener) { var listeners = this.eventListeners[type]; if(listeners) { var p = listeners.indexOf(listener); if(p !== -1) { listeners.splice(p,1); } } }; exports.dispatchEvent = function(type /*, args */) { var args = Array.prototype.slice.call(arguments,1), listeners = this.eventListeners[type]; if(listeners) { for(var p=0; p 0) { self.dispatchEvent("change",changes); } }); this.eventsTriggered = true; } }; exports.getSizeOfTiddlerEventQueue = function() { return $tw.utils.count(this.changedTiddlers); }; exports.clearTiddlerEventQueue = function() { this.changedTiddlers = Object.create(null); this.changeCount = Object.create(null); }; exports.getChangeCount = function(title) { this.changeCount = this.changeCount || Object.create(null); if($tw.utils.hop(this.changeCount,title)) { return this.changeCount[title]; } else { return 0; } }; /* Generate an unused title from the specified base */ exports.generateNewTitle = function(baseTitle,options) { options = options || {}; var c = 0, title = baseTitle; while(this.tiddlerExists(title) || this.isShadowTiddler(title) || this.findDraft(title)) { title = baseTitle + (options.prefix || " ") + (++c); } return title; }; exports.isSystemTiddler = function(title) { return title && title.indexOf("$:/") === 0; }; exports.isTemporaryTiddler = function(title) { return title && title.indexOf("$:/temp/") === 0; }; exports.isImageTiddler = function(title) { var tiddler = this.getTiddler(title); if(tiddler) { var contentTypeInfo = $tw.config.contentTypeInfo[tiddler.fields.type || "text/vnd.tiddlywiki"]; return !!contentTypeInfo && contentTypeInfo.flags.indexOf("image") !== -1; } else { return null; } }; /* Like addTiddler() except it will silently reject any plugin tiddlers that are older than the currently loaded version. Returns true if the tiddler was imported */ exports.importTiddler = function(tiddler) { var existingTiddler = this.getTiddler(tiddler.fields.title); // Check if we're dealing with a plugin if(tiddler && tiddler.hasField("plugin-type") && tiddler.hasField("version") && existingTiddler && existingTiddler.hasField("plugin-type") && existingTiddler.hasField("version")) { // Reject the incoming plugin if it is older if(!$tw.utils.checkVersions(tiddler.fields.version,existingTiddler.fields.version)) { return false; } } // Fall through to adding the tiddler this.addTiddler(tiddler); return true; }; /* Return a hashmap of the fields that should be set when a tiddler is created */ exports.getCreationFields = function() { var fields = { created: new Date() }, creator = this.getTiddlerText(USER_NAME_TITLE); if(creator) { fields.creator = creator; } return fields; }; /* Return a hashmap of the fields that should be set when a tiddler is modified */ exports.getModificationFields = function() { var fields = Object.create(null), modifier = this.getTiddlerText(USER_NAME_TITLE); fields.modified = new Date(); if(modifier) { fields.modifier = modifier; } return fields; }; /* Return a sorted array of tiddler titles. Options include: sortField: field to sort by excludeTag: tag to exclude includeSystem: whether to include system tiddlers (defaults to false) */ exports.getTiddlers = function(options) { options = options || Object.create(null); var self = this, sortField = options.sortField || "title", tiddlers = [], t, titles = []; this.each(function(tiddler,title) { if(options.includeSystem || !self.isSystemTiddler(title)) { if(!options.excludeTag || !tiddler.hasTag(options.excludeTag)) { tiddlers.push(tiddler); } } }); tiddlers.sort(function(a,b) { var aa = a.fields[sortField].toLowerCase() || "", bb = b.fields[sortField].toLowerCase() || ""; if(aa < bb) { return -1; } else { if(aa > bb) { return 1; } else { return 0; } } }); for(t=0; t= 2 ? arguments[arg++] : {}, callback = arguments[arg++], titles = this.getTiddlers(options), t, tiddler; for(t=0; t= 0) { ++newPos; } } if(newPos === -1) { newPos = currPos; } if(newPos !== currPos) { titles.splice(currPos,1); if(newPos >= currPos) { newPos--; } titles.splice(newPos,0,title); } } } return titles; } }; exports.getSubTiddler = function(title,subTiddlerTitle) { var bundleInfo = this.getPluginInfo(title) || this.getTiddlerDataCached(title); if(bundleInfo && bundleInfo.tiddlers) { var subTiddler = bundleInfo.tiddlers[subTiddlerTitle]; if(subTiddler) { return new $tw.Tiddler(subTiddler); } } return null; }; /* Retrieve a tiddler as a JSON string of the fields */ exports.getTiddlerAsJson = function(title) { var tiddler = this.getTiddler(title); if(tiddler) { var fields = Object.create(null); $tw.utils.each(tiddler.fields,function(value,name) { fields[name] = tiddler.getFieldString(name); }); return JSON.stringify(fields); } else { return JSON.stringify({title: title}); } }; /* Get the content of a tiddler as a JavaScript object. How this is done depends on the type of the tiddler: application/json: the tiddler JSON is parsed into an object application/x-tiddler-dictionary: the tiddler is parsed as sequence of name:value pairs Other types currently just return null. titleOrTiddler: string tiddler title or a tiddler object defaultData: default data to be returned if the tiddler is missing or doesn't contain data Note that the same value is returned for repeated calls for the same tiddler data. The value is frozen to prevent modification; otherwise modifications would be visible to all callers */ exports.getTiddlerDataCached = function(titleOrTiddler,defaultData) { var self = this, tiddler = titleOrTiddler; if(!(tiddler instanceof $tw.Tiddler)) { tiddler = this.getTiddler(tiddler); } if(tiddler) { return this.getCacheForTiddler(tiddler.fields.title,"data",function() { // Return the frozen value var value = self.getTiddlerData(tiddler.fields.title,defaultData); $tw.utils.deepFreeze(value); return value; }); } else { return defaultData; } }; /* Alternative, uncached version of getTiddlerDataCached(). The return value can be mutated freely and reused */ exports.getTiddlerData = function(titleOrTiddler,defaultData) { var tiddler = titleOrTiddler, data; if(!(tiddler instanceof $tw.Tiddler)) { tiddler = this.getTiddler(tiddler); } if(tiddler && tiddler.fields.text) { switch(tiddler.fields.type) { case "application/json": // JSON tiddler try { data = JSON.parse(tiddler.fields.text); } catch(ex) { return defaultData; } return data; case "application/x-tiddler-dictionary": return $tw.utils.parseFields(tiddler.fields.text); } } return defaultData; }; /* Extract an indexed field from within a data tiddler */ exports.extractTiddlerDataItem = function(titleOrTiddler,index,defaultText) { var data = this.getTiddlerData(titleOrTiddler,Object.create(null)), text; if(data && $tw.utils.hop(data,index)) { text = data[index]; } if(typeof text === "string" || typeof text === "number") { return text.toString(); } else { return defaultText; } }; /* Set a tiddlers content to a JavaScript object. Currently this is done by setting the tiddler's type to "application/json" and setting the text to the JSON text of the data. title: title of tiddler data: object that can be serialised to JSON fields: optional hashmap of additional tiddler fields to be set */ exports.setTiddlerData = function(title,data,fields) { var existingTiddler = this.getTiddler(title), newFields = { title: title }; if(existingTiddler && existingTiddler.fields.type === "application/x-tiddler-dictionary") { newFields.text = $tw.utils.makeTiddlerDictionary(data); } else { newFields.type = "application/json"; newFields.text = JSON.stringify(data,null,$tw.config.preferences.jsonSpaces); } this.addTiddler(new $tw.Tiddler(this.getCreationFields(),existingTiddler,fields,newFields,this.getModificationFields())); }; /* Return the content of a tiddler as an array containing each line */ exports.getTiddlerList = function(title,field,index) { if(index) { return $tw.utils.parseStringArray(this.extractTiddlerDataItem(title,index,"")); } field = field || "list"; var tiddler = this.getTiddler(title); if(tiddler) { return ($tw.utils.parseStringArray(tiddler.fields[field]) || []).slice(0); } return []; }; // Return a named global cache object. Global cache objects are cleared whenever a tiddler change occurs exports.getGlobalCache = function(cacheName,initializer) { this.globalCache = this.globalCache || Object.create(null); if($tw.utils.hop(this.globalCache,cacheName)) { return this.globalCache[cacheName]; } else { this.globalCache[cacheName] = initializer(); return this.globalCache[cacheName]; } }; exports.clearGlobalCache = function() { this.globalCache = Object.create(null); }; // Return the named cache object for a tiddler. If the cache doesn't exist then the initializer function is invoked to create it exports.getCacheForTiddler = function(title,cacheName,initializer) { this.caches = this.caches || Object.create(null); var caches = this.caches[title]; if(caches && caches[cacheName]) { return caches[cacheName]; } else { if(!caches) { caches = Object.create(null); this.caches[title] = caches; } caches[cacheName] = initializer(); return caches[cacheName]; } }; // Clear all caches associated with a particular tiddler, or, if the title is null, clear all the caches for all the tiddlers exports.clearCache = function(title) { if(title) { this.caches = this.caches || Object.create(null); if($tw.utils.hop(this.caches,title)) { delete this.caches[title]; } } else { this.caches = Object.create(null); } }; exports.initParsers = function(moduleType) { // Install the parser modules $tw.Wiki.parsers = {}; var self = this; $tw.modules.forEachModuleOfType("parser",function(title,module) { for(var f in module) { if($tw.utils.hop(module,f)) { $tw.Wiki.parsers[f] = module[f]; // Store the parser class } } }); }; /* Parse a block of text of a specified MIME type type: content type of text to be parsed text: text options: see below Options include: parseAsInline: if true, the text of the tiddler will be parsed as an inline run _canonical_uri: optional string of the canonical URI of this content */ exports.parseText = function(type,text,options) { text = text || ""; options = options || {}; // Select a parser var Parser = $tw.Wiki.parsers[type]; if(!Parser && $tw.utils.getFileExtensionInfo(type)) { Parser = $tw.Wiki.parsers[$tw.utils.getFileExtensionInfo(type).type]; } if(!Parser) { Parser = $tw.Wiki.parsers[options.defaultType || "text/vnd.tiddlywiki"]; } if(!Parser) { return null; } // Return the parser instance return new Parser(type,text,{ parseAsInline: options.parseAsInline, wiki: this, _canonical_uri: options._canonical_uri }); }; /* Parse a tiddler according to its MIME type */ exports.parseTiddler = function(title,options) { options = $tw.utils.extend({},options); var cacheType = options.parseAsInline ? "inlineParseTree" : "blockParseTree", tiddler = this.getTiddler(title), self = this; return tiddler ? this.getCacheForTiddler(title,cacheType,function() { if(tiddler.hasField("_canonical_uri")) { options._canonical_uri = tiddler.fields._canonical_uri; } return self.parseText(tiddler.fields.type,tiddler.fields.text,options); }) : null; }; exports.parseTextReference = function(title,field,index,options) { var tiddler,text; if(options.subTiddler) { tiddler = this.getSubTiddler(title,options.subTiddler); } else { tiddler = this.getTiddler(title); if(field === "text" || (!field && !index)) { this.getTiddlerText(title); // Force the tiddler to be lazily loaded return this.parseTiddler(title,options); } } if(field === "text" || (!field && !index)) { if(tiddler && tiddler.fields) { return this.parseText(tiddler.fields.type || "text/vnd.tiddlywiki",tiddler.fields.text,options); } else { return null; } } else if(field) { if(field === "title") { text = title; } else { if(!tiddler || !tiddler.hasField(field)) { return null; } text = tiddler.fields[field]; } return this.parseText("text/vnd.tiddlywiki",text.toString(),options); } else if(index) { this.getTiddlerText(title); // Force the tiddler to be lazily loaded text = this.extractTiddlerDataItem(tiddler,index,undefined); if(text === undefined) { return null; } return this.parseText("text/vnd.tiddlywiki",text,options); } }; /* Make a widget tree for a parse tree parser: parser object options: see below Options include: document: optional document to use variables: hashmap of variables to set parentWidget: optional parent widget for the root node */ exports.makeWidget = function(parser,options) { options = options || {}; var widgetNode = { type: "widget", children: [] }, currWidgetNode = widgetNode; // Create set variable widgets for each variable $tw.utils.each(options.variables,function(value,name) { var setVariableWidget = { type: "set", attributes: { name: {type: "string", value: name}, value: {type: "string", value: value} }, children: [] }; currWidgetNode.children = [setVariableWidget]; currWidgetNode = setVariableWidget; }); // Add in the supplied parse tree nodes currWidgetNode.children = parser ? parser.tree : []; // Create the widget return new widget.widget(widgetNode,{ wiki: this, document: options.document || $tw.fakeDocument, parentWidget: options.parentWidget }); }; /* Make a widget tree for transclusion title: target tiddler title options: as for wiki.makeWidget() plus: options.field: optional field to transclude (defaults to "text") options.mode: transclusion mode "inline" or "block" options.children: optional array of children for the transclude widget options.importVariables: optional importvariables filter string for macros to be included options.importPageMacros: optional boolean; if true, equivalent to passing "[[$:/core/ui/PageMacros]] [all[shadows+tiddlers]tag[$:/tags/Macro]!has[draft.of]]" to options.importVariables */ exports.makeTranscludeWidget = function(title,options) { options = options || {}; var parseTreeDiv = {tree: [{ type: "element", tag: "div", children: []}]}, parseTreeImportVariables = { type: "importvariables", attributes: { filter: { name: "filter", type: "string" } }, isBlock: false, children: []}, parseTreeTransclude = { type: "transclude", attributes: { tiddler: { name: "tiddler", type: "string", value: title}}, isBlock: !options.parseAsInline}; if(options.importVariables || options.importPageMacros) { if(options.importVariables) { parseTreeImportVariables.attributes.filter.value = options.importVariables; } else if(options.importPageMacros) { parseTreeImportVariables.attributes.filter.value = "[[$:/core/ui/PageMacros]] [all[shadows+tiddlers]tag[$:/tags/Macro]!has[draft.of]]"; } parseTreeDiv.tree[0].children.push(parseTreeImportVariables); parseTreeImportVariables.children.push(parseTreeTransclude); } else { parseTreeDiv.tree[0].children.push(parseTreeTransclude); } if(options.field) { parseTreeTransclude.attributes.field = {type: "string", value: options.field}; } if(options.mode) { parseTreeTransclude.attributes.mode = {type: "string", value: options.mode}; } if(options.children) { parseTreeTransclude.children = options.children; } return $tw.wiki.makeWidget(parseTreeDiv,options); }; /* Parse text in a specified format and render it into another format outputType: content type for the output textType: content type of the input text text: input text options: see below Options include: variables: hashmap of variables to set parentWidget: optional parent widget for the root node */ exports.renderText = function(outputType,textType,text,options) { options = options || {}; var parser = this.parseText(textType,text,options), widgetNode = this.makeWidget(parser,options); var container = $tw.fakeDocument.createElement("div"); widgetNode.render(container,null); return outputType === "text/html" ? container.innerHTML : container.textContent; }; /* Parse text from a tiddler and render it into another format outputType: content type for the output title: title of the tiddler to be rendered options: see below Options include: variables: hashmap of variables to set parentWidget: optional parent widget for the root node */ exports.renderTiddler = function(outputType,title,options) { options = options || {}; var parser = this.parseTiddler(title,options), widgetNode = this.makeWidget(parser,options); var container = $tw.fakeDocument.createElement("div"); widgetNode.render(container,null); return outputType === "text/html" ? container.innerHTML : (outputType === "text/plain-formatted" ? container.formattedTextContent : container.textContent); }; /* Return an array of tiddler titles that match a search string text: The text string to search for options: see below Options available: source: an iterator function for the source tiddlers, called source(iterator), where iterator is called as iterator(tiddler,title) exclude: An array of tiddler titles to exclude from the search invert: If true returns tiddlers that do not contain the specified string caseSensitive: If true forces a case sensitive search literal: If true, searches for literal string, rather than separate search terms field: If specified, restricts the search to the specified field */ exports.search = function(text,options) { options = options || {}; var self = this, t, invert = !!options.invert; // Convert the search string into a regexp for each term var terms, searchTermsRegExps, flags = options.caseSensitive ? "" : "i"; if(options.literal) { if(text.length === 0) { searchTermsRegExps = null; } else { searchTermsRegExps = [new RegExp("(" + $tw.utils.escapeRegExp(text) + ")",flags)]; } } else { terms = text.split(/ +/); if(terms.length === 1 && terms[0] === "") { searchTermsRegExps = null; } else { searchTermsRegExps = []; for(t=0; t