mirror of
https://github.com/Jermolene/TiddlyWiki5
synced 2024-09-18 10:19:44 +00:00
34afe4e143
* Introduce new widget helper function to evaluate variables.Functions are evaluated as parameterised filter strings, macros as text with textual substitution of parameters and variables, and procedures and widgets as plain text * Refactor the function operator and unknown operator to use the new helper * Use the new helper to evaluate variables within filter strings, thus fixing a bug whereby functions called in such a way were being returned as plain text instead of being evaluated * Refactor the transclude widget to use the new helper * Update tests
370 lines
12 KiB
JavaScript
370 lines
12 KiB
JavaScript
/*\
|
|
title: $:/core/modules/filters.js
|
|
type: application/javascript
|
|
module-type: wikimethod
|
|
|
|
Adds tiddler filtering methods to the $tw.Wiki object.
|
|
|
|
\*/
|
|
(function(){
|
|
|
|
/*jslint node: true, browser: true */
|
|
/*global $tw: false */
|
|
"use strict";
|
|
|
|
/* Maximum permitted filter recursion depth */
|
|
var MAX_FILTER_DEPTH = 300;
|
|
|
|
/*
|
|
Parses an operation (i.e. a run) within a filter string
|
|
operators: Array of array of operator nodes into which results should be inserted
|
|
filterString: filter string
|
|
p: start position within the string
|
|
Returns the new start position, after the parsed operation
|
|
*/
|
|
function parseFilterOperation(operators,filterString,p) {
|
|
var nextBracketPos, operator;
|
|
// Skip the starting square bracket
|
|
if(filterString.charAt(p++) !== "[") {
|
|
throw "Missing [ in filter expression";
|
|
}
|
|
// Process each operator in turn
|
|
do {
|
|
operator = {};
|
|
// Check for an operator prefix
|
|
if(filterString.charAt(p) === "!") {
|
|
operator.prefix = filterString.charAt(p++);
|
|
}
|
|
// Get the operator name
|
|
nextBracketPos = filterString.substring(p).search(/[\[\{<\/]/);
|
|
if(nextBracketPos === -1) {
|
|
throw "Missing [ in filter expression";
|
|
}
|
|
nextBracketPos += p;
|
|
var bracket = filterString.charAt(nextBracketPos);
|
|
operator.operator = filterString.substring(p,nextBracketPos);
|
|
// Any suffix?
|
|
var colon = operator.operator.indexOf(':');
|
|
if(colon > -1) {
|
|
// The raw suffix for older filters
|
|
operator.suffix = operator.operator.substring(colon + 1);
|
|
operator.operator = operator.operator.substring(0,colon) || "field";
|
|
// The processed suffix for newer filters
|
|
operator.suffixes = [];
|
|
$tw.utils.each(operator.suffix.split(":"),function(subsuffix) {
|
|
operator.suffixes.push([]);
|
|
$tw.utils.each(subsuffix.split(","),function(entry) {
|
|
entry = $tw.utils.trim(entry);
|
|
if(entry) {
|
|
operator.suffixes[operator.suffixes.length - 1].push(entry);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
// Empty operator means: title
|
|
else if(operator.operator === "") {
|
|
operator.operator = "title";
|
|
}
|
|
operator.operands = [];
|
|
var parseOperand = function(bracketType) {
|
|
var operand = {};
|
|
switch (bracketType) {
|
|
case "{": // Curly brackets
|
|
operand.indirect = true;
|
|
nextBracketPos = filterString.indexOf("}",p);
|
|
break;
|
|
case "[": // Square brackets
|
|
nextBracketPos = filterString.indexOf("]",p);
|
|
break;
|
|
case "<": // Angle brackets
|
|
operand.variable = true;
|
|
nextBracketPos = filterString.indexOf(">",p);
|
|
break;
|
|
case "/": // regexp brackets
|
|
var rex = /^((?:[^\\\/]|\\.)*)\/(?:\(([mygi]+)\))?/g,
|
|
rexMatch = rex.exec(filterString.substring(p));
|
|
if(rexMatch) {
|
|
operator.regexp = new RegExp(rexMatch[1], rexMatch[2]);
|
|
// DEPRECATION WARNING
|
|
console.log("WARNING: Filter",operator.operator,"has a deprecated regexp operand",operator.regexp);
|
|
nextBracketPos = p + rex.lastIndex - 1;
|
|
}
|
|
else {
|
|
throw "Unterminated regular expression in filter expression";
|
|
}
|
|
break;
|
|
}
|
|
|
|
if(nextBracketPos === -1) {
|
|
throw "Missing closing bracket in filter expression";
|
|
}
|
|
if(operator.regexp) {
|
|
operand.text = "";
|
|
} else {
|
|
operand.text = filterString.substring(p,nextBracketPos);
|
|
}
|
|
operator.operands.push(operand);
|
|
p = nextBracketPos + 1;
|
|
}
|
|
|
|
p = nextBracketPos + 1;
|
|
parseOperand(bracket);
|
|
|
|
// Check for multiple operands
|
|
while(filterString.charAt(p) === ",") {
|
|
p++;
|
|
if(/^[\[\{<\/]/.test(filterString.substring(p))) {
|
|
nextBracketPos = p;
|
|
p++;
|
|
parseOperand(filterString.charAt(nextBracketPos));
|
|
} else {
|
|
throw "Missing [ in filter expression";
|
|
}
|
|
}
|
|
|
|
// Push this operator
|
|
operators.push(operator);
|
|
} while(filterString.charAt(p) !== "]");
|
|
// Skip the ending square bracket
|
|
if(filterString.charAt(p++) !== "]") {
|
|
throw "Missing ] in filter expression";
|
|
}
|
|
// Return the parsing position
|
|
return p;
|
|
}
|
|
|
|
/*
|
|
Parse a filter string
|
|
*/
|
|
exports.parseFilter = function(filterString) {
|
|
filterString = filterString || "";
|
|
var results = [], // Array of arrays of operator nodes {operator:,operand:}
|
|
p = 0, // Current position in the filter string
|
|
match;
|
|
var whitespaceRegExp = /(\s+)/mg,
|
|
operandRegExp = /((?:\+|\-|~|=|\:(\w+)(?:\:([\w\:, ]*))?)?)(?:(\[)|(?:"([^"]*)")|(?:'([^']*)')|([^\s\[\]]+))/mg;
|
|
while(p < filterString.length) {
|
|
// Skip any whitespace
|
|
whitespaceRegExp.lastIndex = p;
|
|
match = whitespaceRegExp.exec(filterString);
|
|
if(match && match.index === p) {
|
|
p = p + match[0].length;
|
|
}
|
|
// Match the start of the operation
|
|
if(p < filterString.length) {
|
|
operandRegExp.lastIndex = p;
|
|
match = operandRegExp.exec(filterString);
|
|
if(!match || match.index !== p) {
|
|
throw $tw.language.getString("Error/FilterSyntax");
|
|
}
|
|
var operation = {
|
|
prefix: "",
|
|
operators: []
|
|
};
|
|
if(match[1]) {
|
|
operation.prefix = match[1];
|
|
p = p + operation.prefix.length;
|
|
if(match[2]) {
|
|
operation.namedPrefix = match[2];
|
|
}
|
|
if(match[3]) {
|
|
operation.suffixes = [];
|
|
$tw.utils.each(match[3].split(":"),function(subsuffix) {
|
|
operation.suffixes.push([]);
|
|
$tw.utils.each(subsuffix.split(","),function(entry) {
|
|
entry = $tw.utils.trim(entry);
|
|
if(entry) {
|
|
operation.suffixes[operation.suffixes.length -1].push(entry);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
if(match[4]) { // Opening square bracket
|
|
p = parseFilterOperation(operation.operators,filterString,p);
|
|
} else {
|
|
p = match.index + match[0].length;
|
|
}
|
|
if(match[5] || match[6] || match[7]) { // Double quoted string, single quoted string or unquoted title
|
|
operation.operators.push(
|
|
{operator: "title", operands: [{text: match[5] || match[6] || match[7]}]}
|
|
);
|
|
}
|
|
results.push(operation);
|
|
}
|
|
}
|
|
return results;
|
|
};
|
|
|
|
exports.getFilterOperators = function() {
|
|
if(!this.filterOperators) {
|
|
$tw.Wiki.prototype.filterOperators = {};
|
|
$tw.modules.applyMethods("filteroperator",this.filterOperators);
|
|
}
|
|
return this.filterOperators;
|
|
};
|
|
|
|
exports.getFilterRunPrefixes = function() {
|
|
if(!this.filterRunPrefixes) {
|
|
$tw.Wiki.prototype.filterRunPrefixes = {};
|
|
$tw.modules.applyMethods("filterrunprefix",this.filterRunPrefixes);
|
|
}
|
|
return this.filterRunPrefixes;
|
|
}
|
|
|
|
exports.filterTiddlers = function(filterString,widget,source) {
|
|
var fn = this.compileFilter(filterString);
|
|
return fn.call(this,source,widget);
|
|
};
|
|
|
|
/*
|
|
Compile a filter into a function with the signature fn(source,widget) where:
|
|
source: an iterator function for the source tiddlers, called source(iterator), where iterator is called as iterator(tiddler,title)
|
|
widget: an optional widget node for retrieving the current tiddler etc.
|
|
*/
|
|
exports.compileFilter = function(filterString) {
|
|
if(!this.filterCache) {
|
|
this.filterCache = Object.create(null);
|
|
this.filterCacheCount = 0;
|
|
}
|
|
if(this.filterCache[filterString] !== undefined) {
|
|
return this.filterCache[filterString];
|
|
}
|
|
var filterParseTree;
|
|
try {
|
|
filterParseTree = this.parseFilter(filterString);
|
|
} catch(e) {
|
|
// We do not cache this result, so it adjusts along with localization changes
|
|
return function(source,widget) {
|
|
return [$tw.language.getString("Error/Filter") + ": " + e];
|
|
};
|
|
}
|
|
// Get the hashmap of filter operator functions
|
|
var filterOperators = this.getFilterOperators();
|
|
// Assemble array of functions, one for each operation
|
|
var operationFunctions = [];
|
|
// Step through the operations
|
|
var self = this;
|
|
$tw.utils.each(filterParseTree,function(operation) {
|
|
// Create a function for the chain of operators in the operation
|
|
var operationSubFunction = function(source,widget) {
|
|
var accumulator = source,
|
|
results = [],
|
|
currTiddlerTitle = widget && widget.getVariable("currentTiddler");
|
|
$tw.utils.each(operation.operators,function(operator) {
|
|
var operands = [],
|
|
operatorFunction;
|
|
if(!operator.operator) {
|
|
// Use the "title" operator if no operator is specified
|
|
operatorFunction = filterOperators.title;
|
|
} else if(!filterOperators[operator.operator]) {
|
|
// Unknown operators treated as "[unknown]" - at run time we can distinguish between a custom operator and falling back to the default "field" operator
|
|
operatorFunction = filterOperators["[unknown]"];
|
|
} else {
|
|
// Use the operator function
|
|
operatorFunction = filterOperators[operator.operator];
|
|
}
|
|
$tw.utils.each(operator.operands,function(operand) {
|
|
if(operand.indirect) {
|
|
operand.value = self.getTextReference(operand.text,"",currTiddlerTitle);
|
|
} else if(operand.variable) {
|
|
var varTree = $tw.utils.parseFilterVariable(operand.text);
|
|
operand.value = widget.evaluateVariable(varTree.name,{params: varTree.params, source: source})[0] || "";
|
|
} else {
|
|
operand.value = operand.text;
|
|
}
|
|
operands.push(operand.value);
|
|
});
|
|
|
|
// Invoke the appropriate filteroperator module
|
|
results = operatorFunction(accumulator,{
|
|
operator: operator.operator,
|
|
operand: operands.length > 0 ? operands[0] : undefined,
|
|
operands: operands,
|
|
prefix: operator.prefix,
|
|
suffix: operator.suffix,
|
|
suffixes: operator.suffixes,
|
|
regexp: operator.regexp
|
|
},{
|
|
wiki: self,
|
|
widget: widget
|
|
});
|
|
if($tw.utils.isArray(results)) {
|
|
accumulator = self.makeTiddlerIterator(results);
|
|
} else {
|
|
accumulator = results;
|
|
}
|
|
});
|
|
if($tw.utils.isArray(results)) {
|
|
return results;
|
|
} else {
|
|
var resultArray = [];
|
|
results(function(tiddler,title) {
|
|
resultArray.push(title);
|
|
});
|
|
return resultArray;
|
|
}
|
|
};
|
|
var filterRunPrefixes = self.getFilterRunPrefixes();
|
|
// Wrap the operator functions in a wrapper function that depends on the prefix
|
|
operationFunctions.push((function() {
|
|
var options = {wiki: self, suffixes: operation.suffixes || []};
|
|
switch(operation.prefix || "") {
|
|
case "": // No prefix means that the operation is unioned into the result
|
|
return filterRunPrefixes["or"](operationSubFunction, options);
|
|
case "=": // The results of the operation are pushed into the result without deduplication
|
|
return filterRunPrefixes["all"](operationSubFunction, options);
|
|
case "-": // The results of this operation are removed from the main result
|
|
return filterRunPrefixes["except"](operationSubFunction, options);
|
|
case "+": // This operation is applied to the main results so far
|
|
return filterRunPrefixes["and"](operationSubFunction, options);
|
|
case "~": // This operation is unioned into the result only if the main result so far is empty
|
|
return filterRunPrefixes["else"](operationSubFunction, options);
|
|
default:
|
|
if(operation.namedPrefix && filterRunPrefixes[operation.namedPrefix]) {
|
|
return filterRunPrefixes[operation.namedPrefix](operationSubFunction, options);
|
|
} else {
|
|
return function(results,source,widget) {
|
|
results.clear();
|
|
results.push($tw.language.getString("Error/FilterRunPrefix"));
|
|
};
|
|
}
|
|
}
|
|
})());
|
|
});
|
|
// Return a function that applies the operations to a source iterator of tiddler titles
|
|
var fnMeasured = $tw.perf.measure("filter: " + filterString,function filterFunction(source,widget) {
|
|
if(!source) {
|
|
source = self.each;
|
|
} else if(typeof source === "object") { // Array or hashmap
|
|
source = self.makeTiddlerIterator(source);
|
|
}
|
|
if(!widget) {
|
|
widget = $tw.rootWidget;
|
|
}
|
|
var results = new $tw.utils.LinkedList();
|
|
self.filterRecursionCount = (self.filterRecursionCount || 0) + 1;
|
|
if(self.filterRecursionCount < MAX_FILTER_DEPTH) {
|
|
$tw.utils.each(operationFunctions,function(operationFunction) {
|
|
operationFunction(results,source,widget);
|
|
});
|
|
} else {
|
|
results.push("/**-- Excessive filter recursion --**/");
|
|
}
|
|
self.filterRecursionCount = self.filterRecursionCount - 1;
|
|
return results.toArray();
|
|
});
|
|
if(this.filterCacheCount >= 2000) {
|
|
// To prevent memory leak, we maintain an upper limit for cache size.
|
|
// Reset if exceeded. This should give us 95% of the benefit
|
|
// that no cache limit would give us.
|
|
this.filterCache = Object.create(null);
|
|
this.filterCacheCount = 0;
|
|
}
|
|
this.filterCache[filterString] = fnMeasured;
|
|
this.filterCacheCount++;
|
|
return fnMeasured;
|
|
};
|
|
|
|
})();
|