diff --git a/core-server/filesystem.js b/core-server/filesystem.js index 165291444..3d546cee5 100644 --- a/core-server/filesystem.js +++ b/core-server/filesystem.js @@ -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 diff --git a/core/modules/filterrunprefixes/intersection.js b/core/modules/filterrunprefixes/intersection.js index ce9ea4546..02329d7b9 100644 --- a/core/modules/filterrunprefixes/intersection.js +++ b/core/modules/filterrunprefixes/intersection.js @@ -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); } }); } }; -}; +}; \ No newline at end of file diff --git a/core/modules/filterrunprefixes/sort.js b/core/modules/filterrunprefixes/sort.js index 41d017832..1a8a64785 100644 --- a/core/modules/filterrunprefixes/sort.js +++ b/core/modules/filterrunprefixes/sort.js @@ -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 compareFn(sortKeys[a],sortKeys[b])); + indexes.forEach((index) => { results.push(inputTitles[index]); }); } diff --git a/core/modules/filters.js b/core/modules/filters.js index 89b281c5a..4ae1e40eb 100644 --- a/core/modules/filters.js +++ b/core/modules/filters.js @@ -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 = ""; diff --git a/core/modules/filters/listops.js b/core/modules/filters/listops.js index ccd81b9fe..3a0d21eb1 100644 --- a/core/modules/filters/listops.js +++ b/core/modules/filters/listops.js @@ -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); +}; \ No newline at end of file diff --git a/core/modules/filters/prefix.js b/core/modules/filters/prefix.js index 9b53cd78e..803726a6d 100644 --- a/core/modules/filters/prefix.js +++ b/core/modules/filters/prefix.js @@ -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; -}; +}; \ No newline at end of file diff --git a/core/modules/filters/sortsub.js b/core/modules/filters/sortsub.js index 291829992..4e88c11f3 100644 --- a/core/modules/filters/sortsub.js +++ b/core/modules/filters/sortsub.js @@ -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 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]); }); diff --git a/core/modules/filters/strings.js b/core/modules/filters/strings.js index 854c43b8c..6a9371596 100644 --- a/core/modules/filters/strings.js +++ b/core/modules/filters/strings.js @@ -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("")]; -}; +}; \ No newline at end of file diff --git a/core/modules/filters/subfilter.js b/core/modules/filters/subfilter.js index 2c1a631ff..6d58bd4b6 100644 --- a/core/modules/filters/subfilter.js +++ b/core/modules/filters/subfilter.js @@ -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); } }); diff --git a/core/modules/filters/suffix.js b/core/modules/filters/suffix.js index 74e7ecd14..31e505d1b 100644 --- a/core/modules/filters/suffix.js +++ b/core/modules/filters/suffix.js @@ -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; }; diff --git a/core/modules/filters/x-listops.js b/core/modules/filters/x-listops.js index f438aa005..97eb83004 100644 --- a/core/modules/filters/x-listops.js +++ b/core/modules/filters/x-listops.js @@ -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); -}; +}; \ No newline at end of file diff --git a/core/modules/utils/dom/dom.js b/core/modules/utils/dom/dom.js index 755978f33..9e2ce8627 100644 --- a/core/modules/utils/dom/dom.js +++ b/core/modules/utils/dom/dom.js @@ -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"); }; /* diff --git a/core/modules/widgets/button.js b/core/modules/widgets/button.js index 4a2ba0083..ad3631902 100644 --- a/core/modules/widgets/button.js +++ b/core/modules/widgets/button.js @@ -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 { diff --git a/core/stylesheets/custom-properties.tid b/core/stylesheets/custom-properties.tid index 7eba93fd1..cf2cf217b 100644 --- a/core/stylesheets/custom-properties.tid +++ b/core/stylesheets/custom-properties.tid @@ -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]] }}}; -} \ No newline at end of file +} diff --git a/editions/test/tiddlers/tests/data/button-selection/DefaultSelection.tid b/editions/test/tiddlers/tests/data/button-selection/DefaultSelection.tid new file mode 100644 index 000000000..ce9caed02 --- /dev/null +++ b/editions/test/tiddlers/tests/data/button-selection/DefaultSelection.tid @@ -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 setTitle="$:/state/demo" setTo="Beta" default="Beta" selectedClass="sel">Beta ++ +title: ExpectedResult + +

\ No newline at end of file diff --git a/editions/test/tiddlers/tests/data/button-selection/MissingStateTiddler.tid b/editions/test/tiddlers/tests/data/button-selection/MissingStateTiddler.tid new file mode 100644 index 000000000..49215bad7 --- /dev/null +++ b/editions/test/tiddlers/tests/data/button-selection/MissingStateTiddler.tid @@ -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 ++ +title: ExpectedResult + +

\ No newline at end of file diff --git a/editions/test/tiddlers/tests/data/button-selection/SetFieldSelection.tid b/editions/test/tiddlers/tests/data/button-selection/SetFieldSelection.tid new file mode 100644 index 000000000..7478815d2 --- /dev/null +++ b/editions/test/tiddlers/tests/data/button-selection/SetFieldSelection.tid @@ -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 setTitle="$:/state/demo" setField="selection" setTo="Beta" selectedClass="sel">Beta ++ +title: ExpectedResult + +

\ No newline at end of file diff --git a/editions/test/tiddlers/tests/data/button-selection/SetIndexSelection.tid b/editions/test/tiddlers/tests/data/button-selection/SetIndexSelection.tid new file mode 100644 index 000000000..cffc9ef46 --- /dev/null +++ b/editions/test/tiddlers/tests/data/button-selection/SetIndexSelection.tid @@ -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 setTitle="$:/state/demo" setIndex="selection" setTo="Beta" selectedClass="sel">Beta ++ +title: ExpectedResult + +

\ No newline at end of file diff --git a/editions/test/tiddlers/tests/data/button-selection/SetSelection.tid b/editions/test/tiddlers/tests/data/button-selection/SetSelection.tid new file mode 100644 index 000000000..997d1adc1 --- /dev/null +++ b/editions/test/tiddlers/tests/data/button-selection/SetSelection.tid @@ -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 set="$:/state/demo" setTo="Beta" selectedClass="sel">Beta ++ +title: ExpectedResult + +

\ No newline at end of file diff --git a/editions/test/tiddlers/tests/data/button-selection/SetTitleRefresh.tid b/editions/test/tiddlers/tests/data/button-selection/SetTitleRefresh.tid new file mode 100644 index 000000000..68d7b77e5 --- /dev/null +++ b/editions/test/tiddlers/tests/data/button-selection/SetTitleRefresh.tid @@ -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 setTitle="$:/state/demo" setTo="Beta" selectedClass="sel">Beta ++ +title: Actions + +<$action-setfield $tiddler="$:/state/demo" text="Beta"/> ++ +title: ExpectedResult + +

\ No newline at end of file diff --git a/editions/test/tiddlers/tests/data/button-selection/SetTitleSelection.tid b/editions/test/tiddlers/tests/data/button-selection/SetTitleSelection.tid new file mode 100644 index 000000000..5cd0c58fd --- /dev/null +++ b/editions/test/tiddlers/tests/data/button-selection/SetTitleSelection.tid @@ -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 setTitle="$:/state/demo" setTo="Beta" selectedClass="sel">Beta ++ +title: ExpectedResult + +

\ No newline at end of file diff --git a/editions/test/tiddlers/tests/data/filters/DiffMergePatch4.tid b/editions/test/tiddlers/tests/data/filters/DiffMergePatch4.tid new file mode 100644 index 000000000..6c56a379f --- /dev/null +++ b/editions/test/tiddlers/tests/data/filters/DiffMergePatch4.tid @@ -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={{{ [makepatches::json] }}}/> + ++ +title: ExpectedResult + +[{"type":"equal","text":"The "},{"type":"delete","text":"quick"},{"type":"insert","text":"fast"},{"type":"equal","text":" brown fox"}] \ No newline at end of file diff --git a/editions/test/tiddlers/tests/data/filters/listops.tid b/editions/test/tiddlers/tests/data/filters/listops.tid new file mode 100644 index 000000000..170eaad0e --- /dev/null +++ b/editions/test/tiddlers/tests/data/filters/listops.tid @@ -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 + +

(EABCD)

\ No newline at end of file diff --git a/editions/test/tiddlers/tests/test-filesystem-adversarial.js b/editions/test/tiddlers/tests/test-filesystem-adversarial.js new file mode 100644 index 000000000..ab96e3a31 --- /dev/null +++ b/editions/test/tiddlers/tests/test-filesystem-adversarial.js @@ -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("ac")); // 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", + "ac: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); + }); + + }); + +} diff --git a/editions/test/tiddlers/tests/test-filesystem.js b/editions/test/tiddlers/tests/test-filesystem.js index 23f6f0a7e..8df4fb99d 100644 --- a/editions/test/tiddlers/tests/test-filesystem.js +++ b/editions/test/tiddlers/tests/test-filesystem.js @@ -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(); + }); + }); } diff --git a/editions/test/tiddlers/tests/test-json-filters.js b/editions/test/tiddlers/tests/test-json-filters.js index 1232e5cf8..c68608948 100644 --- a/editions/test/tiddlers/tests/test-json-filters.js +++ b/editions/test/tiddlers/tests/test-json-filters.js @@ -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"' + ); + }); + }); diff --git a/editions/tw5.com/tiddlers/filters/examples/makepatches and applypatches Operator (Examples).tid b/editions/tw5.com/tiddlers/filters/examples/makepatches and applypatches Operator (Examples).tid index a9dd38d6e..f1ae2b861 100644 --- a/editions/tw5.com/tiddlers/filters/examples/makepatches and applypatches Operator (Examples).tid +++ b/editions/tw5.com/tiddlers/filters/examples/makepatches and applypatches Operator (Examples).tid @@ -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}]">> + \ No newline at end of file diff --git a/editions/tw5.com/tiddlers/filters/makepatches Operator.tid b/editions/tw5.com/tiddlers/filters/makepatches Operator.tid index c59284e22..37a223d76 100644 --- a/editions/tw5.com/tiddlers/filters/makepatches Operator.tid +++ b/editions/tw5.com/tiddlers/filters/makepatches Operator.tid @@ -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">> \ No newline at end of file diff --git a/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9741-restore-pulseelement.tid b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9741-restore-pulseelement.tid new file mode 100644 index 000000000..ff968ab54 --- /dev/null +++ b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9741-restore-pulseelement.tid @@ -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]]) diff --git a/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9815-filesystempaths-backslash.tid b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9815-filesystempaths-backslash.tid index d7255e3a9..894806ce7 100644 --- a/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9815-filesystempaths-backslash.tid +++ b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9815-filesystempaths-backslash.tid @@ -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 diff --git a/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9940-makepatches-json-output.tid b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9940-makepatches-json-output.tid new file mode 100644 index 000000000..3d9fb165f --- /dev/null +++ b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9940-makepatches-json-output.tid @@ -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 \ No newline at end of file diff --git a/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9942-optimize-listops.tid b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9942-optimize-listops.tid new file mode 100644 index 000000000..660322717 --- /dev/null +++ b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9942-optimize-listops.tid @@ -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. \ No newline at end of file diff --git a/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9943.tid b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9943.tid new file mode 100644 index 000000000..fb8a4c172 --- /dev/null +++ b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9943.tid @@ -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). \ No newline at end of file diff --git a/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9951-button-selectedclass-settitle.tid b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9951-button-selectedclass-settitle.tid new file mode 100644 index 000000000..713f36936 --- /dev/null +++ b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9951-button-selectedclass-settitle.tid @@ -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 + +``` +
+<$button setTitle="$:/state/tab" setTo="Alpha" default="Alpha" selectedClass="tc-tab-selected">Alpha +<$button setTitle="$:/state/tab" setTo="Beta" default="Alpha" selectedClass="tc-tab-selected">Beta +
+``` + +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]] diff --git a/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9958.tid b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9958.tid new file mode 100644 index 000000000..114e77204 --- /dev/null +++ b/editions/tw5.com/tiddlers/releasenotes/5.5.0/#9958.tid @@ -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 diff --git a/editions/tw5.com/tiddlers/saving/Saving on iPad_iPhone.tid b/editions/tw5.com/tiddlers/saving/Saving on iPad_iPhone.tid index db31957b7..2029a6c6a 100644 --- a/editions/tw5.com/tiddlers/saving/Saving on iPad_iPhone.tid +++ b/editions/tw5.com/tiddlers/saving/Saving on iPad_iPhone.tid @@ -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// diff --git a/editions/tw5.com/tiddlers/widgets/ButtonWidget.tid b/editions/tw5.com/tiddlers/widgets/ButtonWidget.tid index 46d3a3196..bf1b68800 100644 --- a/editions/tw5.com/tiddlers/widgets/ButtonWidget.tid +++ b/editions/tw5.com/tiddlers/widgets/ButtonWidget.tid @@ -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 | diff --git a/package-lock.json b/package-lock.json index f61a1d4b1..70b62e841 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/themes/tiddlywiki/vanilla/base.tid b/themes/tiddlywiki/vanilla/base.tid index 4c760d6f9..45ae5edb7 100644 --- a/themes/tiddlywiki/vanilla/base.tid +++ b/themes/tiddlywiki/vanilla/base.tid @@ -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;