Merge branch 'master' into colour-improvements

This commit is contained in:
Jeremy Ruston
2026-08-06 10:53:18 +01:00
39 changed files with 1018 additions and 322 deletions
+16 -2
View File
@@ -348,13 +348,14 @@ exports.generateTiddlerFilepath = function(title,options) {
//Use the originalpath without the extension
var ext = path.extname(originalpath);
filepath = originalpath.substring(0,originalpath.length - ext.length);
// normalise "\" to "/" so the sanitiser keeps subdirectories
// (it strips "\", not "/") instead of flattening them.
filepath = filepath.split(path.sep).join("/");
} else if(!filepath) {
filepath = title;
// Remove any forward or backward slashes so we don't create directories
filepath = filepath.replace(/\/|\\/g,"_");
}
// Replace any Windows control codes
filepath = filepath.replace(/^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i,"_$1_");
// Replace any leading spaces with the same number of underscores
filepath = filepath.replace(/^ +/,function (u) { return u.replace(/ /g, "_");});
//If the path does not start with "." or ".." && a path seperator, then
@@ -368,6 +369,19 @@ exports.generateTiddlerFilepath = function(title,options) {
if(!pinFilepath) {
filepath = $tw.utils.transliterate(filepath.replace(/<|>|~|\:|\"|\||\?|\*|\^|\\/g,"_"));
}
// Per segment (catches "sub/CON"), MUST be after transliterate which can create a
// reserved name. Reserved names are wrapped on every path incl pinned;
// trailing dots/spaces (Windows strips) fixed for generated names, not pinned.
filepath = filepath.split("/").map(function(segment) {
if(segment === "" || segment === "." || segment === "..") {
return segment;
}
segment = segment.replace(/^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i,"_$1_");
if(!pinFilepath) {
segment = segment.replace(/[. ]+$/,function(u) { return u.replace(/[. ]/g,"_"); });
}
return segment;
}).join("/");
// Replace any dots or spaces at the end of the extension with the same number of underscores
extension = extension.replace(/[\. ]+$/, function (u) { return u.replace(/[\. ]/g, "_");});
// Truncate the extension if it is too long
@@ -13,14 +13,15 @@ Export our filter prefix function
exports.intersection = function(operationSubFunction) {
return function(results,source,widget) {
if(results.length !== 0) {
var secondRunResults = operationSubFunction(source,widget);
var firstRunResults = results.toArray();
const secondRunResults = operationSubFunction(source,widget),
secondRunSet = new Set(secondRunResults),
firstRunResults = results.toArray();
results.clear();
$tw.utils.each(firstRunResults,function(title) {
if(secondRunResults.indexOf(title) !== -1) {
firstRunResults.forEach((title) => {
if(secondRunSet.has(title)) {
results.push(title);
}
});
}
};
};
};
+15 -20
View File
@@ -13,33 +13,28 @@ Export our filter prefix function
exports.sort = function(operationSubFunction,options) {
return function(results,source,widget) {
if(results.length > 0) {
var suffixes = options.suffixes,
const suffixes = options.suffixes,
sortType = (suffixes[0] && suffixes[0][0]) ? suffixes[0][0] : "string",
invert = suffixes[1] ? (suffixes[1].indexOf("reverse") !== -1) : false,
isCaseSensitive = suffixes[1] ? (suffixes[1].indexOf("casesensitive") !== -1) : false,
invert = suffixes[1] ? suffixes[1].includes("reverse") : false,
isCaseSensitive = suffixes[1] ? suffixes[1].includes("casesensitive") : false,
inputTitles = results.toArray(),
sortKeys = [],
indexes = new Array(inputTitles.length),
compareFn;
results.each(function(title) {
var key = operationSubFunction(options.wiki.makeTiddlerIterator([title]),widget.makeFakeWidgetWithVariables({
"currentTiddler": "" + title,
"..currentTiddler": widget.getVariable("currentTiddler",{defaultValue:""})
}));
compareFn = $tw.utils.makeCompareFunction(sortType,{defaultType: "string", invert:invert, isCaseSensitive:isCaseSensitive});
results.each((title) => {
const key = operationSubFunction(
options.wiki.makeTiddlerIterator([title]),
widget.makeFakeWidgetWithVariables({
"currentTiddler": "" + title,
"..currentTiddler": widget.getVariable("currentTiddler",{defaultValue:""})
})
);
sortKeys.push(key[0] || "");
});
results.clear();
// Prepare an array of indexes to sort
for(var t=0; t<inputTitles.length; t++) {
indexes[t] = t;
}
// Sort the indexes
compareFn = $tw.utils.makeCompareFunction(sortType,{defaultType: "string", invert:invert, isCaseSensitive:isCaseSensitive});
indexes = indexes.sort(function(a,b) {
return compareFn(sortKeys[a],sortKeys[b]);
});
// Add to results in correct order
$tw.utils.each(indexes,function(index) {
let indexes = Array.from(inputTitles.keys());
indexes.sort((a,b) => compareFn(sortKeys[a],sortKeys[b]));
indexes.forEach((index) => {
results.push(inputTitles[index]);
});
}
+1 -1
View File
@@ -306,7 +306,7 @@ exports.compileFilter = function(filterString,options) {
var varTree = $tw.utils.parseFilterVariable(operand.text);
var resultList = widgetClass.evaluateVariable(widget,varTree.name,{params: varTree.params, source: source});
if((resultList.length > 0 && resultList[0] !== undefined) || resultList.length === 0) {
operand.multiValue = widgetClass.evaluateVariable(widget,varTree.name,{params: varTree.params, source: source}) || [];
operand.multiValue = resultList || [];
operand.value = operand.multiValue[0] || "";
} else {
operand.value = "";
+30 -54
View File
@@ -9,69 +9,56 @@ Filter operators for manipulating the current selection list
"use strict";
/*
Fetch titles from the current list
*/
const prepare_results = (source) => {
const results = [];
source((tiddler,title) => results.push(title));
return results;
};
/*
Order a list
*/
exports.order = function(source,operator,options) {
var results = [];
if(operator.operand.toLowerCase() === "reverse") {
source(function(tiddler,title) {
results.unshift(title);
});
} else {
source(function(tiddler,title) {
results.push(title);
});
}
return results;
const results = prepare_results(source);
return operator.operand.toLowerCase() === "reverse" ?
results.reverse() :
results;
};
/*
Reverse list
*/
exports.reverse = function(source,operator,options) {
var results = [];
source(function(tiddler,title) {
results.unshift(title);
});
return results;
return prepare_results(source).reverse();
};
/*
First entry/entries in list
*/
exports.first = function(source,operator,options) {
var count = $tw.utils.getInt(operator.operand,1),
results = [];
source(function(tiddler,title) {
results.push(title);
});
return results.slice(0,count);
const count = $tw.utils.getInt(operator.operand,1);
return prepare_results(source).slice(0,count);
};
/*
Last entry/entries in list
*/
exports.last = function(source,operator,options) {
var count = $tw.utils.getInt(operator.operand,1),
results = [];
if(count === 0) return results;
source(function(tiddler,title) {
results.push(title);
});
return results.slice(-count);
const count = $tw.utils.getInt(operator.operand,1);
return count === 0 ?
[] :
prepare_results(source).slice(-count);
};
/*
All but the first entry/entries of the list
*/
exports.rest = function(source,operator,options) {
var count = $tw.utils.getInt(operator.operand,1),
results = [];
source(function(tiddler,title) {
results.push(title);
});
return results.slice(count);
const count = $tw.utils.getInt(operator.operand,1);
return prepare_results(source).slice(count);
};
exports.butfirst = exports.rest;
exports.bf = exports.rest;
@@ -80,12 +67,9 @@ exports.bf = exports.rest;
All but the last entry/entries of the list
*/
exports.butlast = function(source,operator,options) {
var count = $tw.utils.getInt(operator.operand,1),
results = [];
source(function(tiddler,title) {
results.push(title);
});
var index = count === 0 ? results.length : -count;
const count = $tw.utils.getInt(operator.operand,1),
results = prepare_results(source),
index = count === 0 ? results.length : -count;
return results.slice(0,index);
};
exports.bl = exports.butlast;
@@ -94,22 +78,14 @@ exports.bl = exports.butlast;
The nth member of the list
*/
exports.nth = function(source,operator,options) {
var count = $tw.utils.getInt(operator.operand,1),
results = [];
source(function(tiddler,title) {
results.push(title);
});
return results.slice(count - 1,count);
const count = $tw.utils.getInt(operator.operand,1);
return prepare_results(source).slice(count - 1,count);
};
/*
The zero based nth member of the list
*/
exports.zth = function(source,operator,options) {
var count = $tw.utils.getInt(operator.operand,0),
results = [];
source(function(tiddler,title) {
results.push(title);
});
return results.slice(count,count + 1);
};
const count = $tw.utils.getInt(operator.operand,0);
return prepare_results(source).slice(count,count + 1);
};
+17 -32
View File
@@ -13,37 +13,22 @@ Filter operator for checking if a title starts with a prefix
Export our filter function
*/
exports.prefix = function(source,operator,options) {
var results = [],
suffixes = (operator.suffixes || [])[0] || [];
if(suffixes.indexOf("caseinsensitive") !== -1) {
var operand = operator.operand.toLowerCase();
if(operator.prefix === "!") {
source(function(tiddler,title) {
if(title.toLowerCase().substr(0,operand.length) !== operand) {
results.push(title);
}
});
} else {
source(function(tiddler,title) {
if(title.toLowerCase().substr(0,operand.length) === operand) {
results.push(title);
}
});
const results = [],
suffixes = (operator.suffixes || [])[0] || [],
caseInsensitive = suffixes.includes("caseinsensitive"),
negate = operator.prefix === "!";
const operand = caseInsensitive ?
operator.operand.toLowerCase() :
operator.operand;
source((tiddler,title) => {
const value = caseInsensitive ? title.toLowerCase() : title,
matches = value.startsWith(operand);
if(negate ? !matches : matches) {
results.push(title);
}
} else {
if(operator.prefix === "!") {
source(function(tiddler,title) {
if(title.substr(0,operator.operand.length) !== operator.operand) {
results.push(title);
}
});
} else {
source(function(tiddler,title) {
if(title.substr(0,operator.operand.length) === operator.operand) {
results.push(title);
}
});
}
}
});
return results;
};
};
+7 -12
View File
@@ -14,13 +14,13 @@ Export our filter function
*/
exports.sortsub = function(source,operator,options) {
// Compile the subfilter
var filterFn = options.wiki.compileFilter(operator.operand);
let filterFn = options.wiki.compileFilter(operator.operand);
// Collect the input titles and the corresponding sort keys
var inputTitles = [],
let inputTitles = [],
sortKeys = [];
source(function(tiddler,title) {
inputTitles.push(title);
var r = filterFn.call(options.wiki,function(iterator) {
let r = filterFn.call(options.wiki,function(iterator) {
iterator(options.wiki.getTiddler(title),title);
},options.widget.makeFakeWidgetWithVariables({
"currentTiddler": "" + title,
@@ -29,17 +29,12 @@ exports.sortsub = function(source,operator,options) {
sortKeys.push(r[0] || "");
});
// Rather than sorting the titles array, we'll sort the indexes so that we can consult both arrays
var indexes = new Array(inputTitles.length);
for(var t=0; t<inputTitles.length; t++) {
indexes[t] = t;
}
let indexes = Array.from(inputTitles.keys());
// Sort the indexes
var compareFn = $tw.utils.makeCompareFunction(operator.suffix,{defaultType: "string",invert: operator.prefix === "!"});
indexes = indexes.sort(function(a,b) {
return compareFn(sortKeys[a],sortKeys[b]);
});
let compareFn = $tw.utils.makeCompareFunction(operator.suffix,{defaultType: "string",invert: operator.prefix === "!"});
indexes = indexes.sort((a,b) => compareFn(sortKeys[a],sortKeys[b]));
// Make the results array in order
var results = [];
let results = [];
$tw.utils.each(indexes,function(index) {
results.push(inputTitles[index]);
});
+26 -13
View File
@@ -91,22 +91,35 @@ function diffLineWordMode(text1,text2,mode) {
return diffs;
}
exports.makepatches = function(source,operator,options) {
var suffix = operator.suffix || "",
result = [];
source(function(tiddler,title) {
let diffs, patches;
if(suffix === "lines" || suffix === "words") {
diffs = diffLineWordMode(title,operator.operand,suffix);
patches = dmp.patchMake(title,diffs);
exports.makepatches = function(source, operator, options) {
const suffixes = operator.suffixes || [],
[modeArg = [], formatArg = []] = suffixes,
modeSuffix = modeArg[0] || operator.suffix || "",
mode = ["lines", "words"].includes(modeSuffix) ? modeSuffix : "",
isJson = formatArg[0] === "json",
results = [];
source((tiddler, title) => {
if (isJson) {
const diffs = (mode === "lines" || mode === "words")
? diffLineWordMode(title, operator.operand, mode)
: dmp.diffMain(title, operator.operand);
const jsonOutput = diffs.map(([typeCode, text]) => ({
type: typeCode === 1 ? "insert" : (typeCode === -1 ? "delete" : "equal"),
text
}));
results.push(JSON.stringify(jsonOutput));
} else {
patches = dmp.patchMake(title,operator.operand);
const patches = (mode === "lines" || mode === "words")
? dmp.patchMake(title, diffLineWordMode(title, operator.operand, mode))
: dmp.patchMake(title, operator.operand);
results.push(dmp.patchToText(patches));
}
Array.prototype.push.apply(result,[dmp.patchToText(patches)]);
});
return result;
return results;
};
exports.applypatches = makeStringBinaryOperator(
@@ -235,4 +248,4 @@ exports.charcode = function(source,operator,options) {
}
});
return [chars.join("")];
};
};
+4 -3
View File
@@ -17,9 +17,10 @@ exports.subfilter = function(source,operator,options) {
defaultFilterRunPrefix = (suffixes[0] && suffixes[0][0]) || options.defaultFilterRunPrefix || "or";
var list = options.wiki.filterTiddlers(operator.operand,options.widget,source,{defaultFilterRunPrefix});
if(operator.prefix === "!") {
var results = [];
source(function(tiddler,title) {
if(list.indexOf(title) === -1) {
const results = [],
listSet = new Set(list);
source((tiddler,title) => {
if(!listSet.has(title)) {
results.push(title);
}
});
+17 -31
View File
@@ -13,41 +13,27 @@ Filter operator for checking if a title ends with a suffix
Export our filter function
*/
exports.suffix = function(source,operator,options) {
var results = [],
const results = [],
suffixes = (operator.suffixes || [])[0] || [];
if(!operator.operand) {
source(function(tiddler,title) {
source((tiddler,title) => {
results.push(title);
});
} else if(suffixes.indexOf("caseinsensitive") !== -1) {
var operand = operator.operand.toLowerCase();
if(operator.prefix === "!") {
source(function(tiddler,title) {
if(title.toLowerCase().substr(-operand.length) !== operand) {
results.push(title);
}
});
} else {
source(function(tiddler,title) {
if(title.toLowerCase().substr(-operand.length) === operand) {
results.push(title);
}
});
}
} else {
if(operator.prefix === "!") {
source(function(tiddler,title) {
if(title.substr(-operator.operand.length) !== operator.operand) {
results.push(title);
}
});
} else {
source(function(tiddler,title) {
if(title.substr(-operator.operand.length) === operator.operand) {
results.push(title);
}
});
}
return results;
}
const caseInsensitive = suffixes.indexOf("caseinsensitive") !== -1,
negate = operator.prefix === "!",
operand = caseInsensitive ? operator.operand.toLowerCase() : operator.operand;
source((tiddler,title) => {
const value = caseInsensitive ? title.toLowerCase() : title,
matches = value.endsWith(operand);
if(negate ? !matches : matches) {
results.push(title);
}
});
return results;
};
+70 -81
View File
@@ -11,20 +11,18 @@ Extended filter operators to manipulate the current list.
/*
Fetch titles from the current list
*/
var prepare_results = function (source) {
var results = [];
source(function (tiddler, title) {
results.push(title);
});
*/
const prepare_results = (source) => {
const results = [];
source((tiddler,title) => results.push(title));
return results;
};
/*
Moves a number of items from the tail of the current list before the item named in the operand
*/
exports.putbefore = function (source, operator) {
var results = prepare_results(source),
*/
exports.putbefore = function(source,operator) {
const results = prepare_results(source),
index = results.indexOf(operator.operand),
count = $tw.utils.getInt(operator.suffix,1);
return (index === -1) ?
@@ -34,9 +32,9 @@ exports.putbefore = function (source, operator) {
/*
Moves a number of items from the tail of the current list after the item named in the operand
*/
exports.putafter = function (source, operator) {
var results = prepare_results(source),
*/
exports.putafter = function(source,operator) {
const results = prepare_results(source),
index = results.indexOf(operator.operand),
count = $tw.utils.getInt(operator.suffix,1);
return (index === -1) ?
@@ -46,9 +44,9 @@ exports.putafter = function (source, operator) {
/*
Replaces the item named in the operand with a number of items from the tail of the current list
*/
exports.replace = function (source, operator) {
var results = prepare_results(source),
*/
exports.replace = function(source,operator) {
const results = prepare_results(source),
index = results.indexOf(operator.operand),
count = $tw.utils.getInt(operator.suffix,1);
return (index === -1) ?
@@ -58,39 +56,39 @@ exports.replace = function (source, operator) {
/*
Moves a number of items from the tail of the current list to the head of the list
*/
exports.putfirst = function (source, operator) {
var results = prepare_results(source),
*/
exports.putfirst = function(source,operator) {
const results = prepare_results(source),
count = $tw.utils.getInt(operator.suffix,1);
return results.slice(-count).concat(results.slice(0, -count));
return [...results.slice(-count), ...results.slice(0, -count)];
};
/*
Moves a number of items from the head of the current list to the tail of the list
*/
exports.putlast = function (source, operator) {
var results = prepare_results(source),
*/
exports.putlast = function(source,operator) {
const results = prepare_results(source),
count = $tw.utils.getInt(operator.suffix,1);
return results.slice(count).concat(results.slice(0, count));
return [...results.slice(count), ...results.slice(0, count)];
};
/*
Moves the item named in the operand a number of places forward or backward in the list
*/
exports.move = function (source, operator) {
var results = prepare_results(source),
*/
exports.move = function(source,operator) {
const results = prepare_results(source),
index = results.indexOf(operator.operand),
count = $tw.utils.getInt(operator.suffix,1),
marker = results.splice(index, 1),
offset = (index + count) > 0 ? index + count : 0;
offset = (index + count) > 0 ? index + count : 0;
return results.slice(0, offset).concat(marker).concat(results.slice(offset));
};
/*
Returns the items from the current list that are after the item named in the operand
*/
exports.allafter = function (source, operator) {
var results = prepare_results(source),
*/
exports.allafter = function(source,operator) {
const results = prepare_results(source),
index = results.indexOf(operator.operand);
return (index === -1) ? [] :
(operator.suffix) ? results.slice(index) :
@@ -99,9 +97,9 @@ exports.allafter = function (source, operator) {
/*
Returns the items from the current list that are before the item named in the operand
*/
exports.allbefore = function (source, operator) {
var results = prepare_results(source),
*/
exports.allbefore = function(source,operator) {
const results = prepare_results(source),
index = results.indexOf(operator.operand);
return (index === -1) ? [] :
(operator.suffix) ? results.slice(0, index + 1) :
@@ -110,9 +108,9 @@ exports.allbefore = function (source, operator) {
/*
Appends the items listed in the operand array to the tail of the current list
*/
exports.append = function (source, operator) {
var append = $tw.utils.parseStringArray(operator.operand, "true"),
*/
exports.append = function(source,operator) {
const append = $tw.utils.parseStringArray(operator.operand,"true"),
results = prepare_results(source),
count = parseInt(operator.suffix) || append.length;
return (append.length === 0) ? results :
@@ -122,9 +120,9 @@ exports.append = function (source, operator) {
/*
Prepends the items listed in the operand array to the head of the current list
*/
exports.prepend = function (source, operator) {
var prepend = $tw.utils.parseStringArray(operator.operand, "true"),
*/
exports.prepend = function(source,operator) {
const prepend = $tw.utils.parseStringArray(operator.operand,"true"),
results = prepare_results(source),
count = $tw.utils.getInt(operator.suffix,prepend.length);
return (prepend.length === 0) ? results :
@@ -134,21 +132,19 @@ exports.prepend = function (source, operator) {
/*
Returns all items from the current list except the items listed in the operand array
*/
exports.remove = function (source, operator) {
var array = $tw.utils.parseStringArray(operator.operand, "true"),
results = prepare_results(source),
count = parseInt(operator.suffix) || array.length,
p,
len,
index;
len = array.length - 1;
for(p = 0; p < count; ++p) {
if(operator.prefix) {
index = results.indexOf(array[len - p]);
} else {
index = results.indexOf(array[p]);
}
*/
exports.remove = function(source, operator) {
const array = $tw.utils.parseStringArray(operator.operand, "true"),
results = prepare_results(source);
if(array.length === 0) {
return results;
}
const count = parseInt(operator.suffix, 10) || array.length,
targetItems = operator.prefix ? array.slice(-count).reverse() : array.slice(0, count);
for(const item of targetItems) {
const index = results.indexOf(item);
if(index !== -1) {
results.splice(index, 1);
}
@@ -158,40 +154,32 @@ exports.remove = function (source, operator) {
/*
Returns all items from the current list sorted in the order of the items in the operand array
*/
exports.sortby = function (source, operator) {
var results = prepare_results(source);
*/
exports.sortby = function(source,operator) {
const results = prepare_results(source);
if(!results || results.length < 2) {
return results;
}
var lookup = $tw.utils.parseStringArray(operator.operand, "true");
results.sort(function (a, b) {
return lookup.indexOf(a) - lookup.indexOf(b);
});
return results;
const lookup = $tw.utils.parseStringArray(operator.operand,"true");
return results.sort((a,b) => lookup.indexOf(a) - lookup.indexOf(b));
};
/*
Removes all duplicate items from the current list
*/
exports.unique = function (source, operator) {
var results = prepare_results(source);
var set = results.reduce(function (a, b) {
if(a.indexOf(b) < 0) {
a.push(b);
}
return a;
}, []);
return set;
*/
exports.unique = function(source, operator) {
return Array.from(new Set(prepare_results(source)));
};
var cycleValueInArray = function(results,operands,stepSize) {
var resultsIndex,
const cycleValueInArray = function(results,operands,stepSize) {
let resultsIndex,
step = stepSize || 1,
i = 0,
opLength = operands.length,
nextOperandIndex;
for(i; i < opLength; i++) {
const opLength = operands.length;
for(; i < opLength; i++) {
resultsIndex = results.indexOf(operands[i]);
if(resultsIndex !== -1) {
break;
@@ -213,18 +201,19 @@ var cycleValueInArray = function(results,operands,stepSize) {
/*
Toggles an item in the current list.
*/
*/
exports.toggle = function(source,operator) {
return cycleValueInArray(prepare_results(source),operator.operands);
};
exports.cycle = function(source,operator) {
var results = prepare_results(source),
operands = (operator.operand.length ? $tw.utils.parseStringArray(operator.operand, "true") : [""]),
step = $tw.utils.getInt(operator.operands[1]||"",1);
const results = prepare_results(source),
operands = operator.operand.length ? $tw.utils.parseStringArray(operator.operand,"true") : [""];
let step = $tw.utils.getInt(operator.operands[1] || "",1);
if(step < 0) {
operands.reverse();
step = Math.abs(step);
}
return cycleValueInArray(results,operands,step);
};
};
+6 -5
View File
@@ -164,15 +164,16 @@ exports.forceLayout = function(element) {
Pulse an element for debugging purposes
*/
exports.pulseElement = function(element) {
var eventName = $tw.utils.convertEventName("animationEnd");
// Event handler to remove the class at the end
element.addEventListener($tw.browser.animationEnd,function handler(event) {
element.removeEventListener($tw.browser.animationEnd,handler,false);
$tw.utils.removeClass(element,"pulse");
element.addEventListener(eventName,function handler(event) {
element.removeEventListener(eventName,handler,false);
$tw.utils.removeClass(element,"tc-pulse");
},false);
// Apply the pulse class
$tw.utils.removeClass(element,"pulse");
$tw.utils.removeClass(element,"tc-pulse");
$tw.utils.forceLayout(element);
$tw.utils.addClass(element,"pulse");
$tw.utils.addClass(element,"tc-pulse");
};
/*
+19 -5
View File
@@ -151,10 +151,24 @@ ButtonWidget.prototype.getBoundingClientRect = function() {
};
ButtonWidget.prototype.isSelected = function() {
return this.setTitle ? (this.setField ? this.wiki.getTiddler(this.setTitle).getFieldString(this.setField) === this.setTo :
(this.setIndex ? this.wiki.extractTiddlerDataItem(this.setTitle,this.setIndex) === this.setTo :
this.wiki.getTiddlerText(this.setTitle))) || this.defaultSetValue || this.getVariable("currentTiddler") :
this.wiki.getTextReference(this.set,this.defaultSetValue,this.getVariable("currentTiddler")) === this.setTo;
var currentValue;
if(this.setTitle) {
if(this.setField) {
// The state tiddler usually does not exist until the button is first clicked
var tiddler = this.wiki.getTiddler(this.setTitle);
currentValue = tiddler ? tiddler.getFieldString(this.setField) : undefined;
} else if(this.setIndex) {
currentValue = this.wiki.extractTiddlerDataItem(this.setTitle,this.setIndex);
} else {
currentValue = this.wiki.getTiddlerText(this.setTitle);
}
if(!currentValue) {
currentValue = this.defaultSetValue || this.getVariable("currentTiddler");
}
} else {
currentValue = this.wiki.getTextReference(this.set,this.defaultSetValue,this.getVariable("currentTiddler"));
}
return currentValue === this.setTo;
};
ButtonWidget.prototype.isPoppedUp = function() {
@@ -267,7 +281,7 @@ Selectively refreshes the widget if needed. Returns true if the widget or any of
*/
ButtonWidget.prototype.refresh = function(changedTiddlers) {
var changedAttributes = this.computeAttributes();
if(changedAttributes.tooltip || changedAttributes.actions || changedAttributes.to || changedAttributes.message || changedAttributes.param || changedAttributes.set || changedAttributes.setTo || changedAttributes.popup || changedAttributes.hover || changedAttributes.selectedClass || changedAttributes.style || changedAttributes.dragFilter || changedAttributes.dragTiddler || (this.set && changedTiddlers[this.set]) || (this.popup && changedTiddlers[this.popup]) || (this.popupTitle && changedTiddlers[this.popupTitle]) || changedAttributes.popupAbsCoords || changedAttributes.setTitle || changedAttributes.setField || changedAttributes.setIndex || changedAttributes.popupTitle || changedAttributes.disabled || changedAttributes["default"]) {
if(changedAttributes.tooltip || changedAttributes.actions || changedAttributes.to || changedAttributes.message || changedAttributes.param || changedAttributes.set || changedAttributes.setTo || changedAttributes.popup || changedAttributes.hover || changedAttributes.selectedClass || changedAttributes.style || changedAttributes.dragFilter || changedAttributes.dragTiddler || (this.set && changedTiddlers[this.set]) || (this.setTitle && changedTiddlers[this.setTitle]) || (this.popup && changedTiddlers[this.popup]) || (this.popupTitle && changedTiddlers[this.popupTitle]) || changedAttributes.popupAbsCoords || changedAttributes.setTitle || changedAttributes.setField || changedAttributes.setIndex || changedAttributes.popupTitle || changedAttributes.disabled || changedAttributes["default"]) {
this.refreshSelf();
return true;
} else {
+3 -3
View File
@@ -1,6 +1,6 @@
title: $:/core/stylesheets/custom-properties
\rules only transcludeinline macrocallinline html transcludeblock
\rules only transcludeinline macrocallinline html transcludeblock filteredtranscludeinline
/* Tiddlywiki's CSS properties */
@@ -21,10 +21,10 @@ title: $:/core/stylesheets/custom-properties
--tp-story-left: {{$:/themes/tiddlywiki/vanilla/metrics/storyleft}};
--tp-story-top: {{$:/themes/tiddlywiki/vanilla/metrics/storytop}};
--tp-story-right: {{$:/themes/tiddlywiki/vanilla/metrics/storyright}};
--tp-story-width: {{$:/themes/tiddlywiki/vanilla/metrics/storyrwidth}};
--tp-story-width: {{$:/themes/tiddlywiki/vanilla/metrics/storywidth}};
--tp-tiddler-width: {{$:/themes/tiddlywiki/vanilla/metrics/tiddlerwidth}};
--tp-sidebar-breakpoint: {{$:/themes/tiddlywiki/vanilla/metrics/sidebarbreakpoint}};
--tp-sidebar-width: {{$:/themes/tiddlywiki/vanilla/metrics/sidebarwidth}};
--tp-animation-duration: {{{ [{$:/config/AnimationDuration}addsuffix[ms]] }}};
}
}
@@ -0,0 +1,12 @@
title: ButtonSelection/DefaultSelection
description: The default attribute selects a setTitle button while the state tiddler is missing
type: text/vnd.tiddlywiki-multiple
tags: [[$:/tags/wiki-test-spec]]
title: Output
<$button setTitle="$:/state/demo" setTo="Alpha" default="Beta" selectedClass="sel">Alpha</$button><$button setTitle="$:/state/demo" setTo="Beta" default="Beta" selectedClass="sel">Beta</$button>
+
title: ExpectedResult
<p><button aria-checked="false" class="">Alpha</button><button aria-checked="true" class=" sel">Beta</button></p>
@@ -0,0 +1,12 @@
title: ButtonSelection/MissingStateTiddler
description: A setField button renders when its state tiddler does not exist
type: text/vnd.tiddlywiki-multiple
tags: [[$:/tags/wiki-test-spec]]
title: Output
<$button setTitle="$:/state/demo" setField="selection" setTo="Alpha" selectedClass="sel">Alpha</$button>
+
title: ExpectedResult
<p><button aria-checked="false" class="">Alpha</button></p>
@@ -0,0 +1,15 @@
title: ButtonSelection/SetFieldSelection
description: Only the setField button whose setTo matches the state field is selected
type: text/vnd.tiddlywiki-multiple
tags: [[$:/tags/wiki-test-spec]]
title: $:/state/demo
selection: Beta
+
title: Output
<$button setTitle="$:/state/demo" setField="selection" setTo="Alpha" selectedClass="sel">Alpha</$button><$button setTitle="$:/state/demo" setField="selection" setTo="Beta" selectedClass="sel">Beta</$button>
+
title: ExpectedResult
<p><button aria-checked="false" class="">Alpha</button><button aria-checked="true" class=" sel">Beta</button></p>
@@ -0,0 +1,17 @@
title: ButtonSelection/SetIndexSelection
description: Only the setIndex button whose setTo matches the state index is selected
type: text/vnd.tiddlywiki-multiple
tags: [[$:/tags/wiki-test-spec]]
title: $:/state/demo
type: application/json
{"selection": "Beta"}
+
title: Output
<$button setTitle="$:/state/demo" setIndex="selection" setTo="Alpha" selectedClass="sel">Alpha</$button><$button setTitle="$:/state/demo" setIndex="selection" setTo="Beta" selectedClass="sel">Beta</$button>
+
title: ExpectedResult
<p><button aria-checked="false" class="">Alpha</button><button aria-checked="true" class=" sel">Beta</button></p>
@@ -0,0 +1,16 @@
title: ButtonSelection/SetSelection
description: Only the set button whose setTo matches the state is selected
type: text/vnd.tiddlywiki-multiple
tags: [[$:/tags/wiki-test-spec]]
title: $:/state/demo
Alpha
+
title: Output
<$button set="$:/state/demo" setTo="Alpha" selectedClass="sel">Alpha</$button><$button set="$:/state/demo" setTo="Beta" selectedClass="sel">Beta</$button>
+
title: ExpectedResult
<p><button aria-checked="true" class=" sel">Alpha</button><button aria-checked="false" class="">Beta</button></p>
@@ -0,0 +1,20 @@
title: ButtonSelection/SetTitleRefresh
description: The setTitle selection follows the state tiddler when it changes
type: text/vnd.tiddlywiki-multiple
tags: [[$:/tags/wiki-test-spec]]
title: $:/state/demo
Alpha
+
title: Output
<$button setTitle="$:/state/demo" setTo="Alpha" selectedClass="sel">Alpha</$button><$button setTitle="$:/state/demo" setTo="Beta" selectedClass="sel">Beta</$button>
+
title: Actions
<$action-setfield $tiddler="$:/state/demo" text="Beta"/>
+
title: ExpectedResult
<p><button aria-checked="false" class="">Alpha</button><button aria-checked="true" class=" sel">Beta</button></p>
@@ -0,0 +1,16 @@
title: ButtonSelection/SetTitleSelection
description: Only the setTitle button whose setTo matches the state is selected
type: text/vnd.tiddlywiki-multiple
tags: [[$:/tags/wiki-test-spec]]
title: $:/state/demo
Alpha
+
title: Output
<$button setTitle="$:/state/demo" setTo="Alpha" selectedClass="sel">Alpha</$button><$button setTitle="$:/state/demo" setTo="Beta" selectedClass="sel">Beta</$button>
+
title: ExpectedResult
<p><button aria-checked="true" class=" sel">Alpha</button><button aria-checked="false" class="">Beta</button></p>
@@ -0,0 +1,22 @@
title: Filters/DiffMergePatch4
description: Tests for diff-merge-patch derived operators
type: text/vnd.tiddlywiki-multiple
tags: [[$:/tags/wiki-test-spec]]
title: Output
\whitespace trim
\define text1()
The quick brown fox
\end
\define text2()
The fast brown fox
\end
<$text text={{{ [<text1>makepatches::json<text2>] }}}/>
+
title: ExpectedResult
[{"type":"equal","text":"The "},{"type":"delete","text":"quick"},{"type":"insert","text":"fast"},{"type":"equal","text":" brown fox"}]
@@ -0,0 +1,14 @@
title: Filters/ListOps
description: Test listops operators
type: text/vnd.tiddlywiki-multiple
tags: [[$:/tags/wiki-test-spec]]
title: Output
\whitespace trim
(<$text text={{{ =[[E]] =[[A]] =[[B]] =[[C]] =[[C]] =[[D]] =[[C]] +[unique[]join[]] }}}/>)
+
title: ExpectedResult
<p>(EABCD)</p>
@@ -0,0 +1,173 @@
/*\
title: test-filesystem-adversarial.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Adversarial tests for generateTiddlerFilepath: hostile titles and recorded
originalpaths (path traversal, absolute paths, control codes, reserved device
names, over-long and non-ASCII input) must still yield a filename valid on every
supported OS, without throwing or leaking a forbidden character. Only the
filename is sanitised; the tiddler title itself is never altered.
generateTiddlerFilepath is node-only (utils-node), so reproduce from the CLI, not
the browser. Save as probe.js in the repo root and run `node probe.js`:
var path = require("path");
var $tw = require("./boot/boot.js").TiddlyWiki();
$tw.boot.argv = ["./editions/test"];
$tw.boot.boot(function() {
var dir = path.resolve("/tmp/tw5-probe");
function base(t) { return path.basename($tw.utils.generateTiddlerFilepath(t,{extension: ".tid", directory: dir, fileInfo: {}})); }
console.log(base("con")); // expected: _con_.tid (reserved device name wrapped)
console.log(base("a<b>c")); // expected: a_b_c.tid (forbidden chars replaced)
console.log(base("trailing ")); // expected: trailing_.tid (trailing space replaced)
process.exit(0);
});
Control codes in the specs are built with String.fromCharCode so this source
stays pure ASCII. All path-logic tests use the real-save default overwrite:false.
\*/
"use strict";
if($tw.node) {
var fs = require("fs");
var path = require("path");
describe("generateTiddlerFilepath (adversarial)", function() {
var directory = path.resolve("/tmp/tw5-test-filesystem-adv");
beforeEach(function() {
fs.rmSync(directory,{recursive: true, force: true});
fs.mkdirSync(directory,{recursive: true});
});
afterAll(function() {
fs.rmSync(directory,{recursive: true, force: true});
});
function fromTitle(title) {
return $tw.utils.generateTiddlerFilepath(title,{
extension: ".tid",
directory: directory,
fileInfo: {}
});
}
function fromOriginalpath(originalpath) {
return $tw.utils.generateTiddlerFilepath("plain-title",{
extension: ".tid",
directory: directory,
fileInfo: {originalpath: originalpath}
});
}
// A saved path segment must contain no character Windows forbids in a
// filename and no control code. "/" and "\" are separators between
// segments; "." and ".." are dot segments, not filenames.
function hasControlCode(seg) {
for(var i = 0; i < seg.length; i++) {
if(seg.charCodeAt(i) < 0x20) {
return true;
}
}
return false;
}
function eachSegment(result,callback) {
path.relative(directory,result).split(/[\\/]/).forEach(function(seg) {
if(seg !== "" && seg !== "." && seg !== "..") {
callback(seg);
}
});
}
function expectCleanSegments(result) {
eachSegment(result,function(seg) {
expect(seg).not.toMatch(/[<>:"|?*]/); // Windows-forbidden chars
expect(hasControlCode(seg)).toBe(false); // control codes
expect(seg).not.toMatch(/^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i); // reserved device name
expect(seg).not.toMatch(/[. ]$/); // trailing dot or space
});
}
// Control codes are built at runtime so the source stays pure ASCII.
var NUL = String.fromCharCode(0), BEL = String.fromCharCode(7), US = String.fromCharCode(0x1f);
// Battery of hostile inputs, fed through both the title and originalpath
// channels.
var hostile = [
"../../../etc/passwd",
"..\\..\\evil",
"a<b>c:d\"e|f?g*h",
"a" + NUL + "b" + BEL + "c" + US + "d",
"con", "PRN", "nul", "COM1", "LPT9",
"a/nul/b",
" leading",
"trailing ",
"dots...",
"café naïve",
"中文テスト",
new Array(400).join("z"),
"<>:\"|?*",
"//..//..//",
"a/b\\c/d",
"",
".",
"..",
"::::",
" "
];
it("never leaks a forbidden character or throws, for any hostile input", function() {
hostile.forEach(function(input) {
[fromTitle, function(t) { return fromOriginalpath(t + ".tid"); }].forEach(function(fn) {
var result;
expect(function() { result = fn(input); }).not.toThrow();
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
expectCleanSegments(result);
});
});
});
it("replaces control codes and NUL in the filename with underscore", function() {
expect(path.basename(fromTitle("a" + NUL + "b" + US + "c"))).toBe("a_b_c.tid");
});
it("wraps a reserved Windows device name used as the whole title", function() {
expect(path.basename(fromTitle("CON"))).toBe("_CON_.tid");
expect(path.basename(fromTitle("nul"))).toBe("_nul_.tid");
expect(path.basename(fromTitle("COM1"))).toBe("_COM1_.tid");
});
it("bounds the filename length for an over-long title", function() {
var result = fromTitle(new Array(400).join("z"));
expect(path.basename(result).length).toBeLessThanOrEqual(200 + ".tid".length);
});
it("transliterates non-ASCII to ASCII in every segment", function() {
expect(path.basename(fromTitle("café"))).toBe("cafe.tid");
eachSegment(fromOriginalpath("café/naïve.tid"),function(seg) {
for(var i = 0; i < seg.length; i++) {
expect(seg.charCodeAt(i)).toBeLessThan(128);
}
});
});
it("gives dots-only titles a valid, distinct, non-empty filename", function() {
// "." and ".." are legal tiddler titles but not legal bare filenames.
// The contract is a non-empty, forbidden-char-free name that is not a
// hidden dotfile, and two different titles must not map to one file.
// The exact fallback encoding is an implementation detail, not asserted.
var dot = path.basename(fromTitle("."));
var dotdot = path.basename(fromTitle(".."));
[dot,dotdot].forEach(function(name) {
expect(name.length).toBeGreaterThan(".tid".length);
expect(name).not.toMatch(/[<>:"|?*]/);
expect(name).not.toMatch(/^\.+/);
});
expect(dot).not.toBe(dotdot);
});
});
}
+307 -28
View File
@@ -3,25 +3,62 @@ title: test-filesystem.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Tests for $:/core-server filesystem utilities.
Tests for the $:/core-server filesystem utilities: generateTiddlerFilepath,
generateTiddlerFileInfo and generateTiddlerExtension. These are node-only
(module-type utils-node), so they run under `npm test` and are absent in the
browser: reproduce them from the CLI, not the F12 console.
Reproduce a case by hand. Save as probe.js in the repo root, run `node probe.js`,
and swap in the input and expected value from any spec below:
var path = require("path");
var $tw = require("./boot/boot.js").TiddlyWiki();
$tw.boot.argv = ["./editions/test"];
$tw.boot.boot(function() {
var dir = path.resolve("/tmp/tw5-probe");
var r = $tw.utils.generateTiddlerFilepath("a>b",{extension: ".tid", directory: dir, fileInfo: {}});
console.log(path.basename(r)); // expected: a_b.tid
process.exit(0);
});
Reproduce the real client/server save (the retain-original-tiddler-path case).
From the repo root run:
node ./tiddlywiki.js ./editions/tw5.com-server --listen
then in the browser edit a tiddler whose file lives in a subfolder, for example
examples/ButtonWidget/Popup.tid, save, and confirm on disk that the file stays in
examples/ButtonWidget/ rather than being flattened to the tiddlers root.
\*/
"use strict";
if($tw.node) {
var fs = require("fs");
var path = require("path");
describe("generateTiddlerFilepath", function() {
var directory = path.resolve("/tmp/tw5-test-filesystem");
// Characters stripped by the cross-platform-filename regex at
// core-server/filesystem.js:356. Each entry is [char, description].
// The forbidden set includes all characters disallowed by Windows
// (< > : " | ? *), backslash (directory separator on Windows),
// tilde (legacy 8.3 short-name marker), and caret (disallowed in
// some shell/FS contexts).
// Real saves use overwrite:false, which runs the uniquifier and probes the
// filesystem. Start every test from an empty directory so the computed
// filenames are deterministic. Only the dedicated overwrite test below
// passes overwrite:true.
beforeEach(function() {
fs.rmSync(directory,{recursive: true, force: true});
fs.mkdirSync(directory,{recursive: true});
});
afterAll(function() {
fs.rmSync(directory,{recursive: true, force: true});
});
// Characters illegal in a filename on at least one supported OS (Windows
// forbids < > : " / \ | ? *). generateTiddlerFilepath replaces each with
// "_" so a tiddler file committed on one OS still checks out on every
// other: an unsanitised "a>b.tid" would break `git checkout` on Windows.
// Only the filename is sanitised; the tiddler title keeps the character.
var forbiddenChars = [
["<","less-than"],
[">","greater-than"],
@@ -35,49 +72,291 @@ if($tw.node) {
["\\","backslash"]
];
// Use originalpath so we exercise the line-356 regex directly.
// The title-branch at line ~342 pre-strips "/" and "\" before the
// main regex runs, which would mask the backslash case.
function filepathFromOriginalpath(title,extension) {
// Title channel: a title is plain text, so its slashes and backslashes are
// not directory separators and every forbidden character is replaced.
function filepathFromTitle(title) {
return $tw.utils.generateTiddlerFilepath(title,{
extension: extension,
extension: ".tid",
directory: directory,
fileInfo: {
overwrite: true,
originalpath: title + extension
}
fileInfo: {}
});
}
// OTP channel: originalpath ($:/config/OriginalTiddlerPaths) is a real
// recorded relative location. Its separators are preserved; a forbidden
// character inside a segment is still replaced. A distinct title proves the
// filename derives from originalpath, not the title.
function filepathFromOriginalpath(originalpath) {
return $tw.utils.generateTiddlerFilepath("WrongIfUsed",{
extension: ".tid",
directory: directory,
fileInfo: {originalpath: originalpath}
});
}
// FileSystemPaths channel: a $:/config/FileSystemPaths filter builds the
// path from the title, so a backslash leaking in from the title must be
// sanitised. "[[...]]" echoes the literal.
function filepathFromPathFilter(title,filter) {
return $tw.utils.generateTiddlerFilepath(title,{
extension: ".tid",
directory: directory,
wiki: $tw.wiki,
pathFilters: [filter],
fileInfo: {}
});
}
forbiddenChars.forEach(function(entry) {
var char = entry[0], name = entry[1];
it("should replace " + name + " (" + char + ") with underscore", function() {
var result = filepathFromOriginalpath("a" + char + "b",".tid");
it("replaces " + name + " (" + char + ") in a title with underscore", function() {
var result = filepathFromTitle("a" + char + "b");
expect(path.dirname(result)).toBe(directory);
expect(path.basename(result)).toBe("a_b.tid");
});
});
it("should replace every forbidden char in a dense string", function() {
// Prefix with "x" so the sanitized result isn't all underscores,
// which would trigger the charcode-fallback branch at line ~371.
it("replaces every forbidden char in a dense title", function() {
// Prefix "x" so the result is not all underscores, which would trigger
// the charcode-fallback branch in generateTiddlerFilepath.
var title = "x" + forbiddenChars.map(function(e) { return e[0]; }).join("");
var expected = "x" + forbiddenChars.map(function() { return "_"; }).join("");
var result = filepathFromOriginalpath(title,".tid");
var result = filepathFromTitle(title);
expect(path.dirname(result)).toBe(directory);
expect(path.basename(result)).toBe(expected + ".tid");
});
it("should sanitize real-world 'Pragma: \\define' title without creating a subdirectory", function() {
// Issue for editions/tw5.com titles like "Pragma: \define".
// Before the fix, backslash survived sanitization and path.resolve
// treated it as a separator on Windows, producing a "Pragma_ "
// subdir containing "define.tid".
var result = filepathFromOriginalpath("Pragma: \\define",".tid");
it("does not overwrite a different tiddler that sanitises to the same filename", function() {
// "a>b" and "a/b" are distinct titles that both sanitise to "a_b.tid".
// With overwrite off (the real-save default) the second file gets a
// uniquifier ("_1") rather than clobbering the first.
fs.writeFileSync(path.resolve(directory,"a_b.tid"),"");
var result = filepathFromTitle("a/b");
expect(path.basename(result)).toBe("a_b_1.tid");
});
it("reuses the target filename when overwrite is set (the --save path)", function() {
// overwrite:true (used by the --save command) writes a specific file
// even if it already exists, so no uniquifier is added. This is the
// only test allowed to pass overwrite:true.
fs.writeFileSync(path.resolve(directory,"a_b.tid"),"");
var result = $tw.utils.generateTiddlerFilepath("a/b",{
extension: ".tid",
directory: directory,
fileInfo: {overwrite: true}
});
expect(path.basename(result)).toBe("a_b.tid");
});
it("sanitises a backslash from a FileSystemPaths filter without making a subdir (#9814)", function() {
// A title such as "Pragma: \define" reaching the filename via a
// FileSystemPaths filter must not act as a Windows path separator.
var result = filepathFromPathFilter("Pragma: \\define","[[Pragma: \\define]]");
expect(path.dirname(result)).toBe(directory);
expect(path.basename(result)).toBe("Pragma_ _define.tid");
});
it("keeps the subdirectory of a retained originalpath (native separators)", function() {
// Regression: on Windows path.relative yields backslash separators, so
// originalpath is e.g. "examples\ButtonWidget\Popup.tid". They must be
// preserved so the tiddler saves back to its folder rather than being
// flattened to "examples_ButtonWidget_Popup.tid" at the tiddlers root.
var original = ["examples","ButtonWidget","Popup"].join(path.sep) + ".tid";
var result = filepathFromOriginalpath(original);
expect(path.dirname(result)).toBe(path.resolve(directory,"examples","ButtonWidget"));
expect(path.basename(result)).toBe("Popup.tid");
});
it("keeps the subdirectory of a retained originalpath (forward slashes)", function() {
var result = filepathFromOriginalpath("examples/ButtonWidget/Popup.tid");
expect(path.dirname(result)).toBe(path.resolve(directory,"examples","ButtonWidget"));
expect(path.basename(result)).toBe("Popup.tid");
});
it("still sanitises a forbidden char inside an originalpath segment", function() {
// ">" is a legal filename char on Linux and macOS but forbidden on
// Windows. The sanitiser always targets the strictest OS, so a file
// committed on Linux still checks out on Windows: an unsanitised
// "a>b.tid" would break `git checkout` there and brick the clone.
// The ">" is replaced while the "sub/" directory is kept.
var result = filepathFromOriginalpath("sub/a>b.tid");
expect(path.dirname(result)).toBe(path.resolve(directory,"sub"));
expect(path.basename(result)).toBe("a_b.tid");
});
it("replaces trailing dots and spaces in a filename", function() {
// Windows silently strips a trailing dot or space from a filename, so
// they are replaced to keep the name stable across platforms, in a
// title and inside an originalpath segment alike.
expect(path.basename(filepathFromTitle("foo."))).toBe("foo_.tid");
expect(path.basename(filepathFromTitle("foo "))).toBe("foo_.tid");
expect(path.basename(filepathFromOriginalpath("sub/foo..tid"))).toBe("foo_.tid");
});
it("uses a tiddlywiki.files pinned path verbatim, skipping sanitisation", function() {
// A pinFilepath (tiddlywiki.files) path is author-controlled and
// trusted: separators are kept and the forbidden-char sanitiser is
// skipped, so a tilde (which it would otherwise replace) survives.
var result = $tw.utils.generateTiddlerFilepath("WrongIfUsed",{
extension: ".tid",
directory: directory,
fileInfo: {pinFilepath: true, originalpath: "pinned/a~b.tid"}
});
expect(path.dirname(result)).toBe(path.resolve(directory,"pinned"));
expect(path.basename(result)).toBe("a~b.tid");
});
it("falls back to originalpath when no FileSystemPaths filter matches", function() {
// A filter that selects nothing for this tiddler leaves the recorded
// originalpath in charge of the location.
var result = $tw.utils.generateTiddlerFilepath("WrongIfUsed",{
extension: ".tid",
directory: directory,
wiki: $tw.wiki,
pathFilters: ["[tag[__no_such_tag__]]"],
fileInfo: {originalpath: "examples/ButtonWidget/Popup.tid"}
});
expect(path.dirname(result)).toBe(path.resolve(directory,"examples","ButtonWidget"));
expect(path.basename(result)).toBe("Popup.tid");
});
it("replaces leading spaces in a title with underscores", function() {
expect(path.basename(filepathFromTitle(" foo"))).toBe("__foo.tid");
});
it("replaces leading dots so the file is not hidden on unix", function() {
expect(path.basename(filepathFromTitle(".hidden"))).toBe("_hidden.tid");
expect(path.basename(filepathFromTitle("..twodots"))).toBe("__twodots.tid");
});
it("replaces trailing dots or spaces in the extension", function() {
var result = $tw.utils.generateTiddlerFilepath("foo",{
extension: ".tid.",
directory: directory,
fileInfo: {}
});
expect(path.basename(result)).toBe("foo.tid_");
});
it("truncates an over-long extension to 32 characters", function() {
var longExt = "." + new Array(50).join("x");
var result = $tw.utils.generateTiddlerFilepath("foo",{
extension: longExt,
directory: directory,
fileInfo: {}
});
expect(path.basename(result)).toBe("foo" + longExt.substr(0,32));
});
it("does not double the extension when the title already ends in it", function() {
// "notes.tid" with extension ".tid" must yield "notes.tid", not
// "notes.tid.tid".
expect(path.basename(filepathFromTitle("notes.tid"))).toBe("notes.tid");
});
it("keeps its own filename when an existing tiddler is re-saved", function() {
// The tiddler already owns "a_b.tid" (fileInfo.filepath). Re-saving
// reuses it instead of appending a "_1" uniquifier, which would
// otherwise orphan a copy on every save.
var owned = path.resolve(directory,"a_b.tid");
fs.writeFileSync(owned,"");
var result = $tw.utils.generateTiddlerFilepath("a/b",{
extension: ".tid",
directory: directory,
fileInfo: {filepath: owned}
});
expect(path.basename(result)).toBe("a_b.tid");
});
it("URI-encodes the filename after a write error", function() {
// fileInfo.writeError forces the encoded fallback so a retry can write
// a name the filesystem will accept.
var result = $tw.utils.generateTiddlerFilepath("plain",{
extension: ".tid",
directory: directory,
fileInfo: {writeError: true}
});
expect(path.dirname(result)).toBe(directory);
expect(path.basename(result)).not.toBe("plain.tid");
expect(path.basename(result)).toContain("plain.tid");
});
it("folds a path that escapes all allowed roots back into the directory", function() {
// An originalpath resolving outside directory / wikiTiddlersPath is
// encoded into a single filename inside directory rather than escaping.
var result = $tw.utils.generateTiddlerFilepath("plain",{
extension: ".tid",
directory: directory,
fileInfo: {originalpath: "../../../outside/evil.tid"}
});
expect(path.dirname(result)).toBe(directory);
});
});
describe("generateTiddlerFileInfo", function() {
var directory = path.resolve("/tmp/tw5-test-filesystem");
function fileInfoFor(fields,existing) {
return $tw.utils.generateTiddlerFileInfo(new $tw.Tiddler(fields),{
directory: directory,
wiki: $tw.wiki,
fileInfo: existing || {}
});
}
it("saves a wikitext tiddler as a single .tid file", function() {
var fi = fileInfoFor({title: "Foo", text: "body", type: "text/vnd.tiddlywiki"});
expect(fi.type).toBe("application/x-tiddler");
expect(fi.hasMetaFile).toBe(false);
});
it("saves a non-wikitext tiddler as a body file plus a .meta file", function() {
var fi = fileInfoFor({title: "Foo", text: "body", type: "text/plain"});
expect(fi.type).toBe("text/plain");
expect(fi.hasMetaFile).toBe(true);
});
it("saves a tiddler with an unsafe field value as JSON", function() {
// A field value with leading whitespace cannot round-trip through a
// .tid header, so the whole tiddler is written as JSON.
var fi = fileInfoFor({title: "Foo", text: "body", type: "text/vnd.tiddlywiki", custom: " leading"});
expect(fi.type).toBe("application/json");
expect(fi.hasMetaFile).toBe(false);
});
it("keeps a tiddler with a _canonical_uri as a .tid file", function() {
var fi = fileInfoFor({title: "Img", type: "image/png", _canonical_uri: "images/x.png"});
expect(fi.type).toBe("application/x-tiddler");
});
it("propagates isEditableFile and originalpath from the existing fileInfo", function() {
var fi = fileInfoFor({title: "Foo", text: "b", type: "text/vnd.tiddlywiki"},{isEditableFile: true, originalpath: "sub/Foo.tid"});
expect(fi.isEditableFile).toBe(true);
expect(fi.originalpath).toBe("sub/Foo.tid");
});
});
describe("generateTiddlerExtension", function() {
it("returns the extension from the first matching extFilter", function() {
var ext = $tw.utils.generateTiddlerExtension("Foo",{
extFilters: ["[[Foo]then[.md]]"],
wiki: $tw.wiki
});
expect(ext).toBe(".md");
});
it("returns undefined when no extFilter matches", function() {
var ext = $tw.utils.generateTiddlerExtension("Foo",{
extFilters: ["[tag[__no_such_tag__]then[.md]]"],
wiki: $tw.wiki
});
expect(ext).toBeUndefined();
});
});
}
@@ -173,5 +173,20 @@ describe("json filter tests", function() {
expect(wiki.filterTiddlers("[{First}format:json[ ]]")).toEqual(["{\n \"a\": \"one\",\n \"b\": \"\",\n \"c\": 1.618,\n \"d\": {\n \"e\": \"four\",\n \"f\": [\n \"five\",\n \"six\",\n true,\n false,\n null\n ]\n }\n}"]);
});
it("should support the makepatches operator with json output", function() {
expect(wiki.filterTiddlers("[[The quick brown fox]makepatches::json[The fast brown fox]]")).toEqual([
'[{"type":"equal","text":"The "},{"type":"delete","text":"quick"},{"type":"insert","text":"fast"},{"type":"equal","text":" brown fox"}]'
]);
expect(wiki.filterTiddlers("[[The quick brown fox]makepatches:words:json[The fast brown fox]]")).toEqual([
'[{"type":"equal","text":"The "},{"type":"delete","text":"quick "},{"type":"insert","text":"fast "},{"type":"equal","text":"brown fox"}]'
]);
// Safely ignores missing/invalid second suffix and returns a standard patch string instead of JSON
expect(wiki.filterTiddlers("[[The quick brown fox]makepatches:words[The fast brown fox]]")[0]).not.toContain(
'"type":"equal"'
);
});
});
@@ -1,5 +1,5 @@
created: 20230304160331362
modified: 20230304160332927
modified: 20260724082228670
tags: [[makepatches Operator]] [[applypatches Operator]] [[Operator Examples]]
title: makepatches and applypatches Operator (Examples)
type: text/vnd.tiddlywiki
@@ -40,4 +40,12 @@ The `lines` mode doesn't work as well in this application:
It is better suited as a very fast algorithm to detect line-wise incremental changes to texts and store only the changes instead of multiple versions of the whole texts.
The `json` suffix outputs a structured JSON array representing the differences instead of a patch string. To use the JSON output with the default character mode, skip the first suffix with a double colon (`::`).
<<.operator-example 8 "[{Hamlet##Shakespeare-old}makepatches::json{Hamlet##Shakespeare-new}]">>
The `json` suffix can also be combined with the `words` or `lines` modes:
<<.operator-example 9 "[{Hamlet##Shakespeare-old}makepatches:words:json{Hamlet##Shakespeare-new}]">>
</div>
@@ -1,23 +1,28 @@
caption: makepatches
created: 20230304122354967
modified: 20230304122400128
op-purpose: returns a set of patches that transform the input to a given string
modified: 20260724081502905
op-input: a [[selection of titles|Title Selection]]
op-output: a set of patch instructions per input title to be used by the [[applypatches Operator]] to transform the input title(s) into the string <<.place S>>, or a structured JSON array representing the differences if the `json` suffix is used
op-parameter: a string of characters
op-parameter-name: S
op-output: a set of patch instructions per input title to be used by the [[applypatches Operator]] to transform the input title(s) into the string <<.place S>>
op-suffix: `lines` to operate in line mode, `words` to operate in word mode. If omitted (default), the algorithm operates in character mode. See notes below.
op-suffix-name: T
op-purpose: returns a set of patches that transform the input to a given string
op-suffix: optional suffixes specifying the diff mode and output format
op-suffix-name: T:F
tags: [[Filter Operators]] [[String Operators]]
title: makepatches Operator
type: text/vnd.tiddlywiki
<<.from-version "5.2.6">>
The <<.op makepatches>> operator uses up to two suffixes:
* <<.place T>> (diff mode): `lines` to operate in line mode, `words` to operate in word mode. If omitted (default), the algorithm operates in character mode.
* <<.place F>> (output format): <<.from-version "5.5.0">> `json` to output a structured JSON array of differences instead of a standard patch string. To use the JSON output with the default character mode, skip the first suffix with a double colon (e.g., `makepatches::json`).
The difference algorithm operates in character mode by default. This produces the most detailed diff possible. In `words` mode, each word in the input text is transformed into a meta-character, upon which the algorithm then operates. In the default character mode, the filter would find two patches between "ActionWidget" and "Action-Widgets" (the hyphen and the plural s), while in `words` mode, the whole word is found to be changed. In `lines` mode, the meta-character is formed from the whole line, delimited by newline characters, and is found to be changed independent of the number of changes within the line.
The different modes influence the result when the patches are applied to texts other than the original, as well as the runtime.
<<.tip "The calculation in `words` mode is roughly 10 times faster than the default character mode, while `lines` mode can be more than 100 times faster than the default.">>
<<.operator-examples "makepatches and applypatches">>
<<.operator-examples "makepatches and applypatches">>
@@ -0,0 +1,12 @@
change-category: hackability
change-type: bugfix
created: 20260711185128000
description: $tw.utils.pulseElement() works again and the vanilla theme defines the tc-pulse class
github-contributors: pmario
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9741
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9741
type: text/vnd.tiddlywiki
* `$tw.utils.pulseElement()` listens for the standard `animationend` event instead of the removed `$tw.browser.animationEnd`, so the pulse animation runs and cleans up its CSS class again, and the vanilla theme defines the missing `tc-pulse` class and animation (fixes [[Issue #6835|https://github.com/TiddlyWiki/TiddlyWiki5/issues/6835]])
@@ -1,13 +1,14 @@
change-category: nodejs
change-type: bugfix
created: 20260711175727000
description: Tiddler titles containing a backslash no longer break generated file paths
description: Keep retained subdirectory tiddlers and sanitise file names per path segment
github-contributors: pmario
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9815
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9815 https://github.com/TiddlyWiki/TiddlyWiki5/pull/9944
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9815
type: text/vnd.tiddlywiki
* Titles containing a backslash, such as `pragma: \define`, get the backslash replaced like other forbidden filename characters instead of it acting as a Windows path separator (fixes [[Issue #9814|https://github.com/TiddlyWiki/TiddlyWiki5/issues/9814]])
* Tiddler files declared in `tiddlywiki.files` are pinned to their original location and skip the `$:/config/FileSystemPaths` filters and filename sanitising
* A tiddler saved with `retain-original-tiddler-path` keeps its recorded subdirectory instead of being flattened to the tiddlers root on Windows
* File names are sanitised per path segment, so a forbidden character or reserved device name is replaced at every level (for example `sub/CON`), and a title containing a backslash such as `pragma: \define` no longer acts as a Windows path separator (fixes [[Issue #9814|https://github.com/TiddlyWiki/TiddlyWiki5/issues/9814]])
* Tiddler files declared in `tiddlywiki.files` are pinned to their original location and skip the `$:/config/FileSystemPaths` filters and forbidden-character sanitising
@@ -0,0 +1,12 @@
change-category: filters
change-type: enhancement
created: 20260724080519370
description: The makepatches operator can now output structured JSON
github-contributors: DesignThinkerer
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9940
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9940
type: text/vnd.tiddlywiki
* The [[makepatches|makepatches Operator]] operator now supports a `:json` suffix (e.g., `makepatches::json`) that outputs a structured JSON array of the differences, enabling easier and robust parsing with WikiText
@@ -0,0 +1,13 @@
change-category: filters
change-type: enhancement
created: 20260726124618748
description: Optimizes listops filter operators
github-contributors: saqimtiaz
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/issues/9942
modified: 20260726125232992
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9942
type: text/vnd.tiddlywiki
* The listops filter operators have been rewritten using more modern JavaScript (ES2017). The `unique` and `remove` operators have improved performance in certain circumstances.
@@ -0,0 +1,13 @@
change-category: filters
change-type: enhancement
created: 20260726124618748
description: Optimizes filterrun prefixes and select filters using modern JavaScript.
github-contributors: saqimtiaz
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/issues/9943
modified: 20260726130207664
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9943
type: text/vnd.tiddlywiki
* The `intersection` and `sort` filterrun prefixes, and the `prefix`, `sortsub`, `subfilter` and `suffix` operators have been optimized using more modern JavaScript (ES2017).
@@ -0,0 +1,26 @@
change-category: widget
change-type: bugfix
created: 20260727171657000
description: The button widget applies selectedClass when the state is addressed with setTitle
github-contributors: pmario
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9951
release: 5.5.0
tags: $:/tags/ChangeNote
title: $:/changenotes/5.5.0/#9951
type: text/vnd.tiddlywiki
* Buttons sharing a state tiddler through `setTitle` highlight the selected one. Only the button whose `setTo` matches the state gets the `selectedClass`
* The highlight follows the state as it changes
* `default` picks the highlighted button while the state tiddler is still missing
* The state is the tiddler text. Use `setField` or `setIndex` to address a field or an index instead
```
<div class="tc-tab-buttons">
<$button setTitle="$:/state/tab" setTo="Alpha" default="Alpha" selectedClass="tc-tab-selected">Alpha</$button>
<$button setTitle="$:/state/tab" setTo="Beta" default="Alpha" selectedClass="tc-tab-selected">Beta</$button>
</div>
```
The ButtonWidget documentation gained two missing rules. `setField` takes preference over `setIndex`. `selectedClass` and `default` apply to `setTitle`, not only to `set`.
Fixed issues: [[#9949|https://github.com/TiddlyWiki/TiddlyWiki5/issues/9949]] and [[#9950|https://github.com/TiddlyWiki/TiddlyWiki5/issues/9950]]
@@ -0,0 +1,10 @@
title: $:/changenotes/5.4.1/#9958
description: Fix typo in CSS custom properties
tags: $:/tags/ChangeNote
release: 5.5.0
change-type: bugfix
change-category: usability
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9958
github-contributors: BurningTreeC
Fixes a typo in the CSS custom properties
@@ -5,25 +5,27 @@ created: 20131129101027725
delivery: App
description: iPad/iPhone app for working with TiddlyWiki
method: save
modified: 20201007205336209
modified: 20260421210013537
tags: Saving iOS [[Standalone App]]
title: Saving on iPad/iPhone
type: text/vnd.tiddlywiki
The iPad/iPhone app ''Quine 2'' makes it possible to view, edit and then save changes to TiddlyWiki5 on iOS. [[Download it here|https://apps.apple.com/us/app/quine-2/id1450128957]].
The iPad/iPhone app ''Quine'' makes it possible to view, edit and then save changes to TiddlyWiki5 on iOS.
Currently in its third major version, rewritten for iOS and iPadOS 26.0.
[[Download it here|https://apps.apple.com/us/app/TiddlyWiki51664603]].
Instructions for use:
# Open Quine 2
# Tap the + toolbar button to create and open a new TiddlyWiki
# Open Quine
# From the file list tap the + toolbar button, or the "New Wiki" button, to create and open a new TiddlyWiki
# From the file list tap an existing TiddlyWiki file to open it
# Edit the TiddlyWiki as normal, and save as normal using either Autosave or the TiddlyWiki save button <<.icon $:/core/images/save-button-dynamic>>
# Tap the left hand "Documents" toolbar button to close an open TiddlyWiki
# Tap the left hand "<" toolbar button to close an open TiddlyWiki, returning to the file list
*Quine 2 works natively in iOS with the local file system and the iCloud file system
*Quine 2 also allows you to open, edit and save TiddlyWiki files stored with cloud file providers
*Quine 2 allows you to follow embedded WikiText links and canonical links to external files for cloud-like file providers which support "folder level sharing".
**This includes the apps "Secure Shellfish" and "Working Copy". Most providers, though, do not allow apps like Quine 2 to access linked files this way.
** If you wish to enable such links for "well behaved" file providers, toggle "on" the "Enable folder selection for out-of-sandbox links" setting in iOS Settings for Quine 2
*Quine works natively in iOS with the local file system and the iCloud file system
*Quine also allows you to open, edit and save TiddlyWiki files stored with other cloud file providers
*Quine allows you to follow embedded WikiText links and canonical links to external files on cloud-like file providers which support "folder level sharing".
//Note that Quine is published independently of TiddlyWiki//
@@ -30,12 +30,12 @@ The content of the `<$button>` widget is displayed within the button.
|param |The optional parameter to the message |
|set |A TextReference to which a new value will be assigned |
|setTitle |A title to which a new value will be assigned, ''without'' TextReference. Gets preferred over <<.attr set>> |
|setField |A ''field name'' to which the new value will be assigned, if the attribute <<.attr setTitle>> is present. Defaults to the ''text'' field |
|setIndex |An ''index'' to which the new value will be assigned, if the attribute <<.attr setTitle>> is present |
|setField |A ''field name'' to which the new value will be assigned, if the attribute <<.attr setTitle>> is present. Defaults to the ''text'' field. Takes preference over <<.attr setIndex>> |
|setIndex |An ''index'' to which the new value will be assigned, if the attribute <<.attr setTitle>> is present. Ignored if <<.attr setField>> is also present |
|setTo |The new value to assign to the TextReference identified in the `set` attribute or the text field / the field specified through <<.attr setField>> / the index specified through <<.attr setIndex>> of the title given through <<.attr setTitle>> |
|selectedClass |An optional additional CSS class to be assigned if the popup is triggered or the tiddler specified in <<.attr set>> already has the value specified in <<.attr setTo>> |
|selectedClass |An optional additional CSS class to be assigned if the popup is triggered or the state identified by <<.attr set>> or <<.attr setTitle>> already has the value specified in <<.attr setTo>> |
|selectedAria |<<.from-version "5.4.0">> An ARIA attribute to be set to `true` or `false` when <<.attr selectedClass>> is defined. Allowed values are `aria-checked` (default), `aria-selected` and `aria-pressed` |
|default |Default value if <<.attr set>> tiddler is missing for testing against <<.attr setTo>> to determine <<.attr selectedClass>> |
|default |Default value if the state identified by <<.attr set>> or <<.attr setTitle>> is missing for testing against <<.attr setTo>> to determine <<.attr selectedClass>> |
|popup |Title of a state tiddler for a popup that is toggled when the button is clicked. See PopupMechanism for details |
|popupTitle |Title of a state tiddler for a popup that is toggled when the button is clicked. In difference to the <<.attr popup>> attribute, ''no'' TextReference is used. See PopupMechanism for details |
|popupAbsCoords |<<.from-version "5.2.4">> If set to ''yes'' writes absolute coordinates to the tiddler referenced by the <<.attr popup>>. If set to ''no'' (the default) uses relative coordinates. See [[Coordinate Systems]] for details |
+4 -2
View File
@@ -6,7 +6,7 @@
"packages": {
"": {
"name": "tiddlywiki",
"version": "5.4.1",
"version": "5.5.0-prerelease",
"license": "BSD",
"bin": {
"tiddlywiki": "tiddlywiki.js"
@@ -298,7 +298,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
+11
View File
@@ -1242,6 +1242,17 @@ button.tc-btn-invisible.tc-remove-tag-button {
100% {background-position: 250% 0, 250% 0}
}
@keyframes pulse {
0% { opacity: .3; }
100% { opacity: 1; }
}
.tc-pulse {
background: lightgreen;
animation: pulse 0.8s ease-out;
z-index: 99999;
}
.tc-titlebar h2 {
font-size: 1em;
display: inline;