mirror of
https://github.com/Jermolene/TiddlyWiki5
synced 2026-06-18 03:08:52 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31f242a42b | |||
| 1d8131704c | |||
| a1a191b504 | |||
| 34d013ca3d | |||
| 1e06098d20 | |||
| b378f3f462 | |||
| b20b578183 | |||
| 4b046884b1 | |||
| f272f718fa |
+65
-60
@@ -14,6 +14,8 @@ var _boot = (function($tw) {
|
||||
|
||||
"use strict";
|
||||
|
||||
if(typeof performance !== "undefined") { performance.mark("tw-boot-start"); }
|
||||
|
||||
// Include bootprefix if we're not given module data
|
||||
if(!$tw) {
|
||||
$tw = require("./bootprefix.js").bootprefix();
|
||||
@@ -1158,6 +1160,30 @@ $tw.Wiki = function(options) {
|
||||
pluginTiddlers = [], // Array of tiddlers containing registered plugins, ordered by priority
|
||||
pluginInfo = Object.create(null), // Hashmap of parsed plugin content
|
||||
shadowTiddlers = Object.create(null), // Hashmap by title of {source:, tiddler:}
|
||||
systemTiddlerTitles = null, // Array of system tiddler titles (starting with "$:/")
|
||||
nonSystemTiddlerTitles = null, // Array of non-system tiddler titles
|
||||
partitionTiddlerTitles = function() {
|
||||
if(systemTiddlerTitles === null) {
|
||||
systemTiddlerTitles = [];
|
||||
nonSystemTiddlerTitles = [];
|
||||
var titles = getTiddlerTitles();
|
||||
for(var i = 0, length = titles.length; i < length; i++) {
|
||||
if(titles[i].indexOf("$:/") === 0) {
|
||||
systemTiddlerTitles.push(titles[i]);
|
||||
} else {
|
||||
nonSystemTiddlerTitles.push(titles[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
getSystemTiddlerTitles = function() {
|
||||
partitionTiddlerTitles();
|
||||
return systemTiddlerTitles;
|
||||
},
|
||||
getNonSystemTiddlerTitles = function() {
|
||||
partitionTiddlerTitles();
|
||||
return nonSystemTiddlerTitles;
|
||||
},
|
||||
shadowTiddlerTitles = null,
|
||||
getShadowTiddlerTitles = function() {
|
||||
if(!shadowTiddlerTitles) {
|
||||
@@ -1206,6 +1232,14 @@ $tw.Wiki = function(options) {
|
||||
tiddlers[title] = tiddler;
|
||||
// Check we've got the title
|
||||
tiddlerTitles = $tw.utils.insertSortedArray(tiddlerTitles || [],title);
|
||||
// Maintain system/non-system partitions
|
||||
if(systemTiddlerTitles !== null) {
|
||||
if(title.indexOf("$:/") === 0) {
|
||||
$tw.utils.insertSortedArray(systemTiddlerTitles,title);
|
||||
} else {
|
||||
$tw.utils.insertSortedArray(nonSystemTiddlerTitles,title);
|
||||
}
|
||||
}
|
||||
// Record the new tiddler state
|
||||
updateDescriptor["new"] = {
|
||||
tiddler: tiddler,
|
||||
@@ -1246,6 +1280,14 @@ $tw.Wiki = function(options) {
|
||||
tiddlerTitles.splice(index,1);
|
||||
}
|
||||
}
|
||||
// Delete from system/non-system partitions
|
||||
if(systemTiddlerTitles !== null) {
|
||||
var partitionArray = title.indexOf("$:/") === 0 ? systemTiddlerTitles : nonSystemTiddlerTitles;
|
||||
var partitionIndex = partitionArray.indexOf(title);
|
||||
if(partitionIndex !== -1) {
|
||||
partitionArray.splice(partitionIndex,1);
|
||||
}
|
||||
}
|
||||
// Record the new tiddler state
|
||||
updateDescriptor["new"] = {
|
||||
tiddler: this.getTiddler(title),
|
||||
@@ -1284,6 +1326,16 @@ $tw.Wiki = function(options) {
|
||||
return getTiddlerTitles().slice(0);
|
||||
};
|
||||
|
||||
// Get an array of all system tiddler titles (returns cached array; do not mutate)
|
||||
this.allSystemTitles = function() {
|
||||
return getSystemTiddlerTitles();
|
||||
};
|
||||
|
||||
// Get an array of all non-system tiddler titles (returns cached array; do not mutate)
|
||||
this.allNonSystemTitles = function() {
|
||||
return getNonSystemTiddlerTitles();
|
||||
};
|
||||
|
||||
// Iterate through all tiddler titles
|
||||
this.each = function(callback) {
|
||||
var titles = getTiddlerTitles(),
|
||||
@@ -1539,8 +1591,8 @@ Register all the module tiddlers that have a module type
|
||||
$tw.Wiki.prototype.defineShadowModules = function() {
|
||||
var self = this;
|
||||
this.eachShadow(function(tiddler,title) {
|
||||
// Don't define the module if it is overidden by an ordinary tiddler, or has already been defined
|
||||
if(!self.tiddlerExists(title) && tiddler.hasField("module-type") && !$tw.utils.hop($tw.modules.titles,title)) {
|
||||
// Don't define the module if it is overidden by an ordinary tiddler
|
||||
if(!self.tiddlerExists(title) && tiddler.hasField("module-type")) {
|
||||
if(tiddler.hasField("draft.of")) {
|
||||
// Report a fundamental problem
|
||||
console.warn(`TiddlyWiki: Plugins should not contain tiddlers with a 'draft.of' field: ${tiddler.fields.title}`);
|
||||
@@ -1971,7 +2023,7 @@ $tw.loadTiddlersFromSpecification = function(filepath,excludeRegExp) {
|
||||
});
|
||||
|
||||
// Helper to process a file
|
||||
var processFile = function(filename,isTiddlerFile,fields,isEditableFile,rootPath,dynamicStoreId) {
|
||||
var processFile = function(filename,isTiddlerFile,fields,isEditableFile,rootPath) {
|
||||
var extInfo = $tw.config.fileExtensionInfo[path.extname(filename)],
|
||||
type = (extInfo || {}).type || fields.type || "text/plain",
|
||||
typeInfo = $tw.config.contentTypeInfo[type] || {},
|
||||
@@ -2046,9 +2098,9 @@ $tw.loadTiddlersFromSpecification = function(filepath,excludeRegExp) {
|
||||
});
|
||||
});
|
||||
if(isEditableFile) {
|
||||
tiddlers.push({filepath: pathname, hasMetaFile: !!metadata && !isTiddlerFile, isEditableFile: true, dynamicStoreId: dynamicStoreId, tiddlers: fileTiddlers});
|
||||
tiddlers.push({filepath: pathname, hasMetaFile: !!metadata && !isTiddlerFile, isEditableFile: true, tiddlers: fileTiddlers});
|
||||
} else {
|
||||
tiddlers.push({dynamicStoreId: dynamicStoreId, tiddlers: fileTiddlers});
|
||||
tiddlers.push({tiddlers: fileTiddlers});
|
||||
}
|
||||
};
|
||||
// Helper to recursively search subdirectories
|
||||
@@ -2089,31 +2141,6 @@ $tw.loadTiddlersFromSpecification = function(filepath,excludeRegExp) {
|
||||
// Process directory specifier
|
||||
var dirPath = path.resolve(filepath,dirSpec.path);
|
||||
if(fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
|
||||
// Register a dynamic store if requested
|
||||
var dynamicStoreId = null;
|
||||
if(dirSpec.dynamicStore && $tw.boot.dynamicStores) {
|
||||
dynamicStoreId = dirPath;
|
||||
var existing = null;
|
||||
for(var ds=0; ds<$tw.boot.dynamicStores.length; ds++) {
|
||||
if($tw.boot.dynamicStores[ds].id === dynamicStoreId) {
|
||||
existing = $tw.boot.dynamicStores[ds];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!existing) {
|
||||
$tw.boot.dynamicStores.push({
|
||||
id: dynamicStoreId,
|
||||
directory: dirPath,
|
||||
saveFilter: dirSpec.dynamicStore.saveFilter || "",
|
||||
watch: dirSpec.dynamicStore.watch !== false,
|
||||
debounce: dirSpec.dynamicStore.debounce || 400,
|
||||
filesRegExp: dirSpec.filesRegExp || "^.*$",
|
||||
searchSubdirectories: !!dirSpec.searchSubdirectories,
|
||||
isTiddlerFile: !!dirSpec.isTiddlerFile,
|
||||
fields: dirSpec.fields || {}
|
||||
});
|
||||
}
|
||||
}
|
||||
var files = getAllFiles(dirPath, dirSpec.searchSubdirectories),
|
||||
fileRegExp = new RegExp(dirSpec.filesRegExp || "^.*$"),
|
||||
metaRegExp = /^.*\.meta$/;
|
||||
@@ -2122,7 +2149,7 @@ $tw.loadTiddlersFromSpecification = function(filepath,excludeRegExp) {
|
||||
filename = path.basename(thisPath);
|
||||
if(filename !== "tiddlywiki.files" && !metaRegExp.test(filename) && fileRegExp.test(filename)) {
|
||||
dirSpec.fields = dirSpec.fields || {};
|
||||
processFile(thisPath,dirSpec.isTiddlerFile,dirSpec.fields,dirSpec.isEditableFile || !!dirSpec.dynamicStore,dirSpec.path,dynamicStoreId);
|
||||
processFile(thisPath,dirSpec.isTiddlerFile,dirSpec.fields,dirSpec.isEditableFile,dirSpec.path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -2309,24 +2336,6 @@ $tw.loadWikiTiddlers = function(wikiPath,options) {
|
||||
$tw.loadPlugins(wikiInfo.plugins,$tw.config.pluginsPath,$tw.config.pluginsEnvVar);
|
||||
$tw.loadPlugins(wikiInfo.themes,$tw.config.themesPath,$tw.config.themesEnvVar);
|
||||
$tw.loadPlugins(wikiInfo.languages,$tw.config.languagesPath,$tw.config.languagesEnvVar);
|
||||
// Register plugin-provided tiddlerdeserializer and tiddlerserializer modules now,
|
||||
// so they are available when the wiki tiddler files are read from disk below.
|
||||
// We also apply the supporting `utils`, `tiddlerfield`, and `tiddlermethod`
|
||||
// modules so deserializers can call into them (e.g. core's text/html
|
||||
// deserializer needs $tw.utils.extractEncryptedStoreArea).
|
||||
// (All of these steps run again later in execStartup; they are idempotent.)
|
||||
$tw.wiki.readPluginInfo();
|
||||
$tw.wiki.registerPluginTiddlers("plugin");
|
||||
$tw.wiki.unpackPluginTiddlers();
|
||||
$tw.wiki.defineShadowModules();
|
||||
$tw.modules.applyMethods("utils",$tw.utils);
|
||||
if($tw.node) {
|
||||
$tw.modules.applyMethods("utils-node",$tw.utils);
|
||||
}
|
||||
$tw.Tiddler.fieldModules = $tw.modules.getModulesByTypeAsHashmap("tiddlerfield");
|
||||
$tw.modules.applyMethods("tiddlermethod",$tw.Tiddler.prototype);
|
||||
$tw.modules.applyMethods("tiddlerdeserializer",$tw.Wiki.tiddlerDeserializerModules);
|
||||
$tw.modules.applyMethods("tiddlerserializer",$tw.Wiki.tiddlerSerializerModules);
|
||||
// Load the wiki files, registering them as writable
|
||||
var resolvedWikiPath = path.resolve(wikiPath,$tw.config.wikiTiddlersSubDir);
|
||||
$tw.utils.each($tw.loadTiddlersFromPath(resolvedWikiPath),function(tiddlerFile) {
|
||||
@@ -2336,8 +2345,7 @@ $tw.loadWikiTiddlers = function(wikiPath,options) {
|
||||
filepath: tiddlerFile.filepath,
|
||||
type: tiddlerFile.type,
|
||||
hasMetaFile: tiddlerFile.hasMetaFile,
|
||||
isEditableFile: config["retain-original-tiddler-path"] || tiddlerFile.isEditableFile || tiddlerFile.filepath.indexOf($tw.boot.wikiTiddlersPath) !== 0,
|
||||
dynamicStoreId: tiddlerFile.dynamicStoreId || null
|
||||
isEditableFile: config["retain-original-tiddler-path"] || tiddlerFile.isEditableFile || tiddlerFile.filepath.indexOf($tw.boot.wikiTiddlersPath) !== 0
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -2349,10 +2357,7 @@ $tw.loadWikiTiddlers = function(wikiPath,options) {
|
||||
for(var title in $tw.boot.files) {
|
||||
fileInfo = $tw.boot.files[title];
|
||||
if(fileInfo.isEditableFile) {
|
||||
// For tiddlers loaded from a dynamic store, compute originalpath relative to the store's directory
|
||||
// so that save-time path resolution against that directory yields the correct location.
|
||||
var basePath = fileInfo.dynamicStoreId || $tw.boot.wikiTiddlersPath;
|
||||
relativePath = path.relative(basePath,fileInfo.filepath);
|
||||
relativePath = path.relative($tw.boot.wikiTiddlersPath,fileInfo.filepath);
|
||||
fileInfo.originalpath = relativePath;
|
||||
output[title] =
|
||||
path.sep === "/" ?
|
||||
@@ -2478,8 +2483,6 @@ $tw.boot.initStartup = function(options) {
|
||||
if(!$tw.boot.tasks.readBrowserTiddlers) {
|
||||
// For writable tiddler files, a hashmap of title to {filepath:,type:,hasMetaFile:}
|
||||
$tw.boot.files = Object.create(null);
|
||||
// Array of {id, directory, saveFilter, watch, debounce} registered via tiddlywiki.files dynamicStore directives
|
||||
$tw.boot.dynamicStores = [];
|
||||
// System paths and filenames
|
||||
$tw.boot.bootPath = options.bootPath || path.dirname(module.filename);
|
||||
$tw.boot.corePath = path.resolve($tw.boot.bootPath,"../core");
|
||||
@@ -2569,9 +2572,6 @@ $tw.boot.initStartup = function(options) {
|
||||
// Install the tiddler deserializer modules
|
||||
$tw.Wiki.tiddlerDeserializerModules = Object.create(null);
|
||||
$tw.modules.applyMethods("tiddlerdeserializer",$tw.Wiki.tiddlerDeserializerModules);
|
||||
// Install the tiddler serializer modules
|
||||
$tw.Wiki.tiddlerSerializerModules = Object.create(null);
|
||||
$tw.modules.applyMethods("tiddlerserializer",$tw.Wiki.tiddlerSerializerModules);
|
||||
// Call unload handlers in the browser
|
||||
if($tw.browser) {
|
||||
window.onbeforeunload = function(event) {
|
||||
@@ -2591,7 +2591,9 @@ $tw.boot.loadStartup = function(options){
|
||||
|
||||
// Load tiddlers
|
||||
if($tw.boot.tasks.readBrowserTiddlers) {
|
||||
if(typeof performance !== "undefined") { performance.mark("tw-boot-store-read-start"); }
|
||||
$tw.loadTiddlersBrowser();
|
||||
if(typeof performance !== "undefined") { performance.mark("tw-boot-store-read-end"); }
|
||||
} else {
|
||||
$tw.loadTiddlersNode();
|
||||
}
|
||||
@@ -2603,6 +2605,7 @@ $tw.boot.loadStartup = function(options){
|
||||
$tw.hooks.invokeHook("th-boot-tiddlers-loaded");
|
||||
};
|
||||
$tw.boot.execStartup = function(options){
|
||||
if(typeof performance !== "undefined") { performance.mark("tw-boot-exec-start"); }
|
||||
// Unpack plugin tiddlers
|
||||
$tw.wiki.readPluginInfo();
|
||||
$tw.wiki.registerPluginTiddlers("plugin",$tw.safeMode ? ["$:/core"] : undefined);
|
||||
@@ -2630,6 +2633,7 @@ $tw.boot.execStartup = function(options){
|
||||
$tw.boot.executedStartupModules = Object.create(null);
|
||||
$tw.boot.disabledStartupModules = $tw.boot.disabledStartupModules || [];
|
||||
// Repeatedly execute the next eligible task
|
||||
if(typeof performance !== "undefined") { performance.mark("tw-boot-startup-modules-start"); }
|
||||
$tw.boot.executeNextStartupTask(options.callback);
|
||||
};
|
||||
/*
|
||||
@@ -2697,6 +2701,7 @@ $tw.boot.executeNextStartupTask = function(callback) {
|
||||
}
|
||||
taskIndex++;
|
||||
}
|
||||
if(typeof performance !== "undefined") { performance.mark("tw-boot-complete"); }
|
||||
if(typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
|
||||
+4
-1
@@ -14,6 +14,8 @@ See Boot.js for further details of the boot process.
|
||||
|
||||
/* eslint-disable @stylistic/indent */
|
||||
|
||||
if(typeof performance !== "undefined") { performance.mark("tw-bootprefix-start"); }
|
||||
|
||||
var _bootprefix = (function($tw) {
|
||||
|
||||
"use strict";
|
||||
@@ -23,7 +25,7 @@ $tw.boot = $tw.boot || Object.create(null);
|
||||
|
||||
// Config
|
||||
$tw.config = $tw.config || Object.create(null);
|
||||
$tw.config.maxEditFileSize = 100 * 1024 * 1024; // 100MB
|
||||
$tw.config.maxEditFileSize = 200 * 1024 * 1024; // 200MB
|
||||
|
||||
// Detect platforms
|
||||
if(!("browser" in $tw)) {
|
||||
@@ -121,6 +123,7 @@ return $tw;
|
||||
if(typeof(exports) === "undefined") {
|
||||
// Set up $tw global for the browser
|
||||
window.$tw = _bootprefix(window.$tw);
|
||||
if(typeof performance !== "undefined") { performance.mark("tw-bootprefix-end"); }
|
||||
} else {
|
||||
// Export functionality as a module
|
||||
exports.bootprefix = _bootprefix;
|
||||
|
||||
@@ -238,10 +238,6 @@ exports.generateTiddlerFileInfo = function(tiddler,options) {
|
||||
// Save as a .tid file
|
||||
fileInfo.type = "application/x-tiddler";
|
||||
fileInfo.hasMetaFile = false;
|
||||
} else if($tw.Wiki.tiddlerSerializerModules && $tw.Wiki.tiddlerSerializerModules[tiddlerType]) {
|
||||
// A serializer is registered for this content type - save as a single self-contained file
|
||||
fileInfo.type = tiddlerType;
|
||||
fileInfo.hasMetaFile = false;
|
||||
} else {
|
||||
// Save as a text/binary file and a .meta file
|
||||
fileInfo.type = tiddlerType;
|
||||
@@ -420,16 +416,7 @@ Save a tiddler to a file described by the fileInfo:
|
||||
*/
|
||||
exports.saveTiddlerToFile = function(tiddler,fileInfo,callback) {
|
||||
$tw.utils.createDirectory(path.dirname(fileInfo.filepath));
|
||||
var serializer = $tw.Wiki.tiddlerSerializerModules && $tw.Wiki.tiddlerSerializerModules[fileInfo.type];
|
||||
if(serializer && !fileInfo.hasMetaFile && fileInfo.type !== "application/x-tiddler" && fileInfo.type !== "application/json") {
|
||||
var typeInfo = $tw.config.contentTypeInfo[fileInfo.type] || {encoding: "utf8"};
|
||||
fs.writeFile(fileInfo.filepath,serializer(tiddler),typeInfo.encoding,function(err) {
|
||||
if(err) {
|
||||
return callback(err);
|
||||
}
|
||||
return callback(null,fileInfo);
|
||||
});
|
||||
} else if(fileInfo.hasMetaFile) {
|
||||
if(fileInfo.hasMetaFile) {
|
||||
// Save the tiddler as a separate body and meta file
|
||||
var typeInfo = $tw.config.contentTypeInfo[tiddler.fields.type || "text/plain"] || {encoding: "utf8"};
|
||||
fs.writeFile(fileInfo.filepath,tiddler.fields.text || "",typeInfo.encoding,function(err) {
|
||||
@@ -471,11 +458,7 @@ Save a tiddler to a file described by the fileInfo:
|
||||
*/
|
||||
exports.saveTiddlerToFileSync = function(tiddler,fileInfo) {
|
||||
$tw.utils.createDirectory(path.dirname(fileInfo.filepath));
|
||||
var serializer = $tw.Wiki.tiddlerSerializerModules && $tw.Wiki.tiddlerSerializerModules[fileInfo.type];
|
||||
if(serializer && !fileInfo.hasMetaFile && fileInfo.type !== "application/x-tiddler" && fileInfo.type !== "application/json") {
|
||||
var typeInfo = $tw.config.contentTypeInfo[fileInfo.type] || {encoding: "utf8"};
|
||||
fs.writeFileSync(fileInfo.filepath,serializer(tiddler),typeInfo.encoding);
|
||||
} else if(fileInfo.hasMetaFile) {
|
||||
if(fileInfo.hasMetaFile) {
|
||||
// Save the tiddler as a separate body and meta file
|
||||
var typeInfo = $tw.config.contentTypeInfo[tiddler.fields.type || "text/plain"] || {encoding: "utf8"};
|
||||
fs.writeFileSync(fileInfo.filepath,tiddler.fields.text || "",typeInfo.encoding);
|
||||
|
||||
+34
-17
@@ -261,6 +261,7 @@ exports.compileFilter = function(filterString) {
|
||||
var filterOperators = this.getFilterOperators();
|
||||
// Assemble array of functions, one for each operation
|
||||
var operationFunctions = [];
|
||||
var operationSubFunctions = []; // Unwrapped sub-functions for fast path
|
||||
// Step through the operations
|
||||
var self = this;
|
||||
$tw.utils.each(filterParseTree,function(operation) {
|
||||
@@ -289,20 +290,24 @@ exports.compileFilter = function(filterString) {
|
||||
operand.value = self.getTextReference(operand.text,"",currTiddlerTitle);
|
||||
operand.multiValue = [operand.value];
|
||||
} else if(operand.variable) {
|
||||
var varTree = $tw.utils.parseFilterVariable(operand.text);
|
||||
operand.value = widgetClass.evaluateVariable(widget,varTree.name,{params: varTree.params, source: source})[0] || "";
|
||||
if(!operand._varTree) {
|
||||
operand._varTree = $tw.utils.parseFilterVariable(operand.text);
|
||||
}
|
||||
operand.value = widgetClass.evaluateVariable(widget,operand._varTree.name,{params: operand._varTree.params, source: source})[0] || "";
|
||||
operand.multiValue = [operand.value];
|
||||
} else if(operand.multiValuedVariable) {
|
||||
var varTree = $tw.utils.parseFilterVariable(operand.text);
|
||||
var resultList = widgetClass.evaluateVariable(widget,varTree.name,{params: varTree.params, source: source});
|
||||
if(!operand._varTree) {
|
||||
operand._varTree = $tw.utils.parseFilterVariable(operand.text);
|
||||
}
|
||||
var resultList = widgetClass.evaluateVariable(widget,operand._varTree.name,{params: operand._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 = widgetClass.evaluateVariable(widget,operand._varTree.name,{params: operand._varTree.params, source: source}) || [];
|
||||
operand.value = operand.multiValue[0] || "";
|
||||
} else {
|
||||
operand.value = "";
|
||||
operand.multiValue = [];
|
||||
}
|
||||
operand.isMultiValueOperand = true;
|
||||
operand.isMultiValueOperand = true;
|
||||
} else {
|
||||
operand.value = operand.text;
|
||||
operand.multiValue = [operand.value];
|
||||
@@ -343,6 +348,7 @@ exports.compileFilter = function(filterString) {
|
||||
return resultArray;
|
||||
}
|
||||
};
|
||||
operationSubFunctions.push(operationSubFunction);
|
||||
var filterRunPrefixes = self.getFilterRunPrefixes();
|
||||
// Wrap the operator functions in a wrapper function that depends on the prefix
|
||||
operationFunctions.push((function() {
|
||||
@@ -372,6 +378,10 @@ exports.compileFilter = function(filterString) {
|
||||
}
|
||||
})());
|
||||
});
|
||||
// Detect single "or" run for fast path (bypass LinkedList)
|
||||
var isSingleOrRun = filterParseTree.length === 1 &&
|
||||
(!filterParseTree[0].prefix || filterParseTree[0].prefix === "");
|
||||
var singleOrSubFunction = isSingleOrRun ? operationSubFunctions[0] : null;
|
||||
// Return a function that applies the operations to a source iterator of tiddler titles
|
||||
var fnMeasured = $tw.perf.measure("filter: " + filterString,function filterFunction(source,widget) {
|
||||
if(!source) {
|
||||
@@ -382,23 +392,30 @@ exports.compileFilter = function(filterString) {
|
||||
if(!widget) {
|
||||
widget = $tw.rootWidget;
|
||||
}
|
||||
var results = new $tw.utils.LinkedList();
|
||||
self.filterRecursionCount = (self.filterRecursionCount || 0) + 1;
|
||||
var resultArray;
|
||||
if(self.filterRecursionCount < MAX_FILTER_DEPTH) {
|
||||
$tw.utils.each(operationFunctions,function(operationFunction) {
|
||||
var operationResult = operationFunction(results,source,widget);
|
||||
if(operationResult) {
|
||||
if(operationResult.variables) {
|
||||
// If the filter run prefix has returned variables, create a new fake widget with those variables
|
||||
widget = widget.makeFakeWidgetWithVariables(operationResult.variables);
|
||||
if(singleOrSubFunction) {
|
||||
// Fast path: single "or" run, return array directly without LinkedList
|
||||
resultArray = singleOrSubFunction(source,widget);
|
||||
} else {
|
||||
var results = new $tw.utils.LinkedList();
|
||||
$tw.utils.each(operationFunctions,function(operationFunction) {
|
||||
var operationResult = operationFunction(results,source,widget);
|
||||
if(operationResult) {
|
||||
if(operationResult.variables) {
|
||||
// If the filter run prefix has returned variables, create a new fake widget with those variables
|
||||
widget = widget.makeFakeWidgetWithVariables(operationResult.variables);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
resultArray = results.toArray();
|
||||
}
|
||||
} else {
|
||||
results.push("/**-- Excessive filter recursion --**/");
|
||||
resultArray = ["/**-- Excessive filter recursion --**/"];
|
||||
}
|
||||
self.filterRecursionCount = self.filterRecursionCount - 1;
|
||||
return results.toArray();
|
||||
return resultArray;
|
||||
});
|
||||
if(this.filterCacheCount >= 2000) {
|
||||
// To prevent memory leak, we maintain an upper limit for cache size.
|
||||
|
||||
@@ -13,6 +13,18 @@ Filter function for [is[shadow]]
|
||||
Export our filter function
|
||||
*/
|
||||
exports.shadow = function(source,prefix,options) {
|
||||
// Fast path: when source is wiki.each (all real tiddlers), use shadow title list
|
||||
if(source === options.wiki.each && prefix !== "!") {
|
||||
// Return real tiddlers that are also shadow tiddlers (overridden shadows)
|
||||
var results = [],
|
||||
shadowTitles = options.wiki.allShadowTitles();
|
||||
for(var i = 0, len = shadowTitles.length; i < len; i++) {
|
||||
if(options.wiki.tiddlerExists(shadowTitles[i])) {
|
||||
results.push(shadowTitles[i]);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
var results = [];
|
||||
if(prefix === "!") {
|
||||
source(function(tiddler,title) {
|
||||
|
||||
@@ -13,6 +13,14 @@ Filter function for [is[system]]
|
||||
Export our filter function
|
||||
*/
|
||||
exports.system = function(source,prefix,options) {
|
||||
// Fast path: when iterating all tiddlers, use pre-partitioned arrays
|
||||
if(source === options.wiki.each) {
|
||||
if(prefix === "!") {
|
||||
return options.wiki.allNonSystemTitles();
|
||||
} else {
|
||||
return options.wiki.allSystemTitles();
|
||||
}
|
||||
}
|
||||
var results = [];
|
||||
if(prefix === "!") {
|
||||
source(function(tiddler,title) {
|
||||
|
||||
@@ -13,6 +13,13 @@ Filter function for [is[tiddler]]
|
||||
Export our filter function
|
||||
*/
|
||||
exports.tiddler = function(source,prefix,options) {
|
||||
// Fast path: wiki.each only iterates real tiddlers, all of which exist
|
||||
if(source === options.wiki.each) {
|
||||
if(prefix === "!") {
|
||||
return []; // No real tiddler fails tiddlerExists
|
||||
}
|
||||
return source; // Return iterator directly; all real tiddlers pass
|
||||
}
|
||||
var results = [];
|
||||
if(prefix === "!") {
|
||||
source(function(tiddler,title) {
|
||||
|
||||
@@ -13,6 +13,21 @@ Filter operator returning all the tags of the selected tiddlers
|
||||
Export our filter function
|
||||
*/
|
||||
exports.tags = function(source,operator,options) {
|
||||
// Fast path: cache result when iterating all tiddlers
|
||||
if(source === options.wiki.each) {
|
||||
return options.wiki.getGlobalCache("filter-tags-all-tiddlers",function() {
|
||||
var tags = {};
|
||||
source(function(tiddler,title) {
|
||||
var t, length;
|
||||
if(tiddler && tiddler.fields.tags) {
|
||||
for(t=0, length=tiddler.fields.tags.length; t<length; t++) {
|
||||
tags[tiddler.fields.tags[t]] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
return Object.keys(tags);
|
||||
});
|
||||
}
|
||||
var tags = {};
|
||||
source(function(tiddler,title) {
|
||||
var t, length;
|
||||
|
||||
@@ -67,7 +67,57 @@ TagSubIndexer.prototype.rebuild = function() {
|
||||
};
|
||||
|
||||
TagSubIndexer.prototype.update = function(updateDescriptor) {
|
||||
this.index = null;
|
||||
// If the index hasn't been built yet, no update needed
|
||||
if(this.index === null) {
|
||||
return;
|
||||
}
|
||||
// Determine whether the old/new tiddler is visible to this iterator
|
||||
var oldVisible = this._isVisible(updateDescriptor.old),
|
||||
newVisible = this._isVisible(updateDescriptor["new"]),
|
||||
self = this;
|
||||
// Remove old tags from index
|
||||
if(oldVisible && updateDescriptor.old.tiddler) {
|
||||
var oldTitle = updateDescriptor.old.tiddler.fields.title,
|
||||
oldTags = updateDescriptor.old.tiddler.fields.tags || [];
|
||||
$tw.utils.each(oldTags,function(tag) {
|
||||
if(self.index[tag]) {
|
||||
var idx = self.index[tag].titles.indexOf(oldTitle);
|
||||
if(idx !== -1) {
|
||||
self.index[tag].titles.splice(idx,1);
|
||||
if(self.index[tag].titles.length === 0) {
|
||||
delete self.index[tag];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Add new tags to index
|
||||
if(newVisible && updateDescriptor["new"].tiddler) {
|
||||
var newTitle = updateDescriptor["new"].tiddler.fields.title,
|
||||
newTags = updateDescriptor["new"].tiddler.fields.tags || [];
|
||||
$tw.utils.each(newTags,function(tag) {
|
||||
if(!self.index[tag]) {
|
||||
self.index[tag] = {isSorted: false, titles: [newTitle]};
|
||||
} else if(self.index[tag].titles.indexOf(newTitle) === -1) {
|
||||
self.index[tag].titles.push(newTitle);
|
||||
self.index[tag].isSorted = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Determine whether a tiddler described by a descriptor is visible to this sub-indexer's iterator
|
||||
*/
|
||||
TagSubIndexer.prototype._isVisible = function(descriptor) {
|
||||
if(this.iteratorMethod === "each") {
|
||||
return descriptor.exists;
|
||||
} else if(this.iteratorMethod === "eachShadow") {
|
||||
return descriptor.shadow;
|
||||
} else {
|
||||
// eachTiddlerPlusShadows and eachShadowPlusTiddlers both visit all tiddlers and shadows
|
||||
return descriptor.exists || descriptor.shadow;
|
||||
}
|
||||
};
|
||||
|
||||
TagSubIndexer.prototype.lookup = function(tag) {
|
||||
|
||||
@@ -34,7 +34,6 @@ exports.startup = function() {
|
||||
$tw.modules.applyMethods("wikimethod",$tw.Wiki.prototype);
|
||||
$tw.wiki.addIndexersToWiki();
|
||||
$tw.modules.applyMethods("tiddlerdeserializer",$tw.Wiki.tiddlerDeserializerModules);
|
||||
$tw.modules.applyMethods("tiddlerserializer",$tw.Wiki.tiddlerSerializerModules);
|
||||
$tw.macros = $tw.modules.getModulesByTypeAsHashmap("macro");
|
||||
$tw.wiki.initParsers();
|
||||
// --------------------------
|
||||
|
||||
@@ -59,14 +59,48 @@ LinkedList.prototype.push = function(/* values */) {
|
||||
LinkedList.prototype.pushTop = function(value) {
|
||||
var t;
|
||||
if($tw.utils.isArray(value)) {
|
||||
for(t=0; t<value.length; t++) {
|
||||
_assertString(value[t]);
|
||||
}
|
||||
for(t=0; t<value.length; t++) {
|
||||
_removeOne(this,value[t]);
|
||||
}
|
||||
for(t=0; t<value.length; t++) {
|
||||
_linkToEnd(this,value[t]);
|
||||
if(this.length === 0) {
|
||||
// Fast path for empty list: skip removal pass
|
||||
for(t = 0; t < value.length; t++) {
|
||||
_assertString(value[t]);
|
||||
}
|
||||
var prev = null,
|
||||
useInline = true;
|
||||
for(t = 0; t < value.length; t++) {
|
||||
if(useInline) {
|
||||
var v = value[t];
|
||||
var old = this.next.get(v);
|
||||
if(old !== undefined) {
|
||||
// Duplicate found: switch to _linkToEnd for this and all remaining elements
|
||||
useInline = false;
|
||||
_linkToEnd(this,v);
|
||||
} else {
|
||||
// Inline the common case of _linkToEnd for new unique values
|
||||
this.next.set(v,null);
|
||||
this.prev.set(v,prev);
|
||||
if(prev !== null) {
|
||||
this.next.set(prev,v);
|
||||
} else {
|
||||
this.next.set(null,v);
|
||||
}
|
||||
this.prev.set(null,v);
|
||||
this.length++;
|
||||
prev = v;
|
||||
}
|
||||
} else {
|
||||
_linkToEnd(this,value[t]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for(t=0; t<value.length; t++) {
|
||||
_assertString(value[t]);
|
||||
}
|
||||
for(t=0; t<value.length; t++) {
|
||||
_removeOne(this,value[t]);
|
||||
}
|
||||
for(t=0; t<value.length; t++) {
|
||||
_linkToEnd(this,value[t]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_assertString(value);
|
||||
|
||||
+33
-6
@@ -1400,15 +1400,15 @@ exports.search = function(text,options) {
|
||||
fields.push("text");
|
||||
}
|
||||
// Function to check a given tiddler for the search term
|
||||
var searchTiddler = function(title) {
|
||||
var searchTiddler = function(tiddler,title) {
|
||||
if(!searchTermsRegExps) {
|
||||
return true;
|
||||
}
|
||||
var notYetFound = searchTermsRegExps.slice();
|
||||
|
||||
var tiddler = self.getTiddler(title);
|
||||
if(!tiddler) {
|
||||
tiddler = new $tw.Tiddler({title: title, text: "", type: "text/vnd.tiddlywiki"});
|
||||
tiddler = self.getTiddler(title);
|
||||
if(!tiddler) {
|
||||
tiddler = new $tw.Tiddler({title: title, text: "", type: "text/vnd.tiddlywiki"});
|
||||
}
|
||||
}
|
||||
var contentTypeInfo = $tw.config.contentTypeInfo[tiddler.fields.type] || $tw.config.contentTypeInfo["text/vnd.tiddlywiki"],
|
||||
searchFields;
|
||||
@@ -1424,6 +1424,33 @@ exports.search = function(text,options) {
|
||||
} else {
|
||||
searchFields = fields;
|
||||
}
|
||||
// Fast path for single search term (avoids array slice/splice per tiddler)
|
||||
if(searchTermsRegExps.length === 1) {
|
||||
var singleRegExp = searchTermsRegExps[0];
|
||||
for(var fieldIndex=0; fieldIndex<searchFields.length; fieldIndex++) {
|
||||
var fieldName = searchFields[fieldIndex];
|
||||
if(fieldName === "text" && contentTypeInfo.encoding !== "utf8") {
|
||||
continue;
|
||||
}
|
||||
var str = tiddler.fields[fieldName];
|
||||
if(str) {
|
||||
if($tw.utils.isArray(str)) {
|
||||
for(var s=0; s<str.length; s++) {
|
||||
if(singleRegExp.test(str[s])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
str = tiddler.getFieldString(fieldName);
|
||||
if(singleRegExp.test(str)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
var notYetFound = searchTermsRegExps.slice();
|
||||
for(var fieldIndex=0; notYetFound.length>0 && fieldIndex<searchFields.length; fieldIndex++) {
|
||||
// Don't search the text field if the content type is binary
|
||||
var fieldName = searchFields[fieldIndex];
|
||||
@@ -1463,7 +1490,7 @@ exports.search = function(text,options) {
|
||||
var results = [],
|
||||
source = options.source || this.each;
|
||||
source(function(tiddler,title) {
|
||||
if(searchTiddler(title) !== invert) {
|
||||
if(searchTiddler(tiddler,title) !== invert) {
|
||||
results.push(title);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"description": "Performance measurement edition",
|
||||
"plugins": [
|
||||
"tiddlywiki/performance"
|
||||
],
|
||||
"themes": [
|
||||
"tiddlywiki/vanilla",
|
||||
"tiddlywiki/snowwhite"
|
||||
]
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"tiddlywiki/jszip",
|
||||
"tiddlywiki/confetti",
|
||||
"tiddlywiki/tour",
|
||||
"tiddlywiki/performance",
|
||||
"tiddlywiki/dom-to-image"
|
||||
],
|
||||
"themes": [
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
modified: 20260413092032887
|
||||
title: TabFour
|
||||
|
||||
Text tab 4
|
||||
@@ -1,5 +0,0 @@
|
||||
caption: t 1
|
||||
modified: 20260413092032887
|
||||
title: TabOne
|
||||
|
||||
Text tab 1
|
||||
@@ -1,6 +0,0 @@
|
||||
caption: t 3
|
||||
description: desc
|
||||
modified: 20260413092032887
|
||||
title: TabThree
|
||||
|
||||
Text tab 3
|
||||
@@ -1,5 +0,0 @@
|
||||
caption: t 2
|
||||
modified: 20260413092032887
|
||||
title: TabTwo
|
||||
|
||||
Text tab 2
|
||||
@@ -1,7 +0,0 @@
|
||||
code-body: yes
|
||||
modified: 20260413092032887
|
||||
title: body-template
|
||||
|
||||
!! <<currentTab>>
|
||||
|
||||
<$transclude tiddler=<<currentTab>> mode="block"/>
|
||||
@@ -1,5 +0,0 @@
|
||||
code-body: yes
|
||||
modified: 20260413092032887
|
||||
title: button-template
|
||||
|
||||
<$transclude tiddler=<<currentTab>> field="description"><$transclude tiddler=<<currentTab>> field="caption"><$macrocall $name="currentTab" $type="text/plain" $output="text/plain"/></$transclude></$transclude>
|
||||
@@ -1,72 +0,0 @@
|
||||
code-body: yes
|
||||
modified: 20260413092032887
|
||||
title: tabs-macro-definition
|
||||
|
||||
\define tabs-button()
|
||||
\whitespace trim
|
||||
<$button
|
||||
set=<<tabsState>>
|
||||
setTo=<<currentTab>>
|
||||
default=<<__default__>>
|
||||
selectedClass="tc-tab-selected"
|
||||
selectedAria="aria-selected"
|
||||
tooltip={{!!tooltip}}
|
||||
role="tab"
|
||||
data-tab-title=<<currentTab>>
|
||||
>
|
||||
<$tiddler tiddler=<<save-currentTiddler>>>
|
||||
<$set name="tv-wikilinks" value="no">
|
||||
<$transclude tiddler=<<__buttonTemplate__>> mode="inline">
|
||||
<$transclude tiddler=<<currentTab>> field="caption">
|
||||
<$macrocall $name="currentTab" $type="text/plain" $output="text/plain"/>
|
||||
</$transclude>
|
||||
</$transclude>
|
||||
</$set>
|
||||
</$tiddler>
|
||||
<<__actions__>>
|
||||
</$button>
|
||||
\end
|
||||
|
||||
\define tabs-tab()
|
||||
\whitespace trim
|
||||
<$set name="save-currentTiddler" value=<<currentTiddler>>>
|
||||
<$tiddler tiddler=<<currentTab>>>
|
||||
<<tabs-button>>
|
||||
</$tiddler>
|
||||
</$set>
|
||||
\end
|
||||
|
||||
\define tabs-tab-list()
|
||||
\whitespace trim
|
||||
<$list filter=<<__tabsList__>> variable="currentTab" storyview="pop">
|
||||
<<tabs-tab>>
|
||||
</$list>
|
||||
\end
|
||||
|
||||
\define tabs-tab-body()
|
||||
\whitespace trim
|
||||
<$list filter=<<__tabsList__>> variable="currentTab">
|
||||
<$reveal type="match" state=<<tabsState>> text=<<currentTab>> default=<<__default__>> retain=<<__retain__>> tag="div">
|
||||
<$transclude tiddler=<<__template__>> mode="block">
|
||||
<$transclude tiddler=<<currentTab>> mode="block"/>
|
||||
</$transclude>
|
||||
</$reveal>
|
||||
</$list>
|
||||
\end
|
||||
|
||||
\define tabs(tabsList,default,state:"$:/state/tab",class,template,buttonTemplate,retain,actions,explicitState)
|
||||
\whitespace trim
|
||||
<$qualify title=<<__state__>> name="qualifiedState">
|
||||
<$let tabsState={{{ [<__explicitState__>minlength[1]] ~[<qualifiedState>] }}}>
|
||||
<div class={{{ [[tc-tab-set]addsuffix[ ]addsuffix<__class__>] }}} role="tablist">
|
||||
<div class={{{ [[tc-tab-buttons]addsuffix[ ]addsuffix<__class__>] }}}>
|
||||
<<tabs-tab-list>>
|
||||
</div>
|
||||
<div class={{{ [[tc-tab-divider]addsuffix[ ]addsuffix<__class__>] }}}/>
|
||||
<div class={{{ [[tc-tab-content]addsuffix[ ]addsuffix<__class__>] }}} role="tabpanel">
|
||||
<<tabs-tab-body>>
|
||||
</div>
|
||||
</div>
|
||||
</$let>
|
||||
</$qualify>
|
||||
\end
|
||||
@@ -1,5 +0,0 @@
|
||||
modified: 20260413092032887
|
||||
title: test-tabs-horizontal-all
|
||||
|
||||
\import [[tabs-macro-definition]]
|
||||
<<tabs "TabOne TabTwo TabThree TabFour" "TabTwo" "$:/state/test-tab-01" template:"body-template" buttonTemplate:"button-template" explicitState:"$:/state/explicit">>
|
||||
@@ -1,5 +0,0 @@
|
||||
modified: 20260413092032887
|
||||
title: test-tabs-horizontal
|
||||
|
||||
\import [[tabs-macro-definition]]
|
||||
<<tabs "TabOne TabTwo TabThree TabFour" "TabTwo" "$:/state/test-tab-01">>
|
||||
@@ -1,5 +0,0 @@
|
||||
modified: 20260413092032887
|
||||
title: test-tabs-vertical
|
||||
|
||||
\import [[tabs-macro-definition]]
|
||||
<<tabs "TabOne TabTwo TabThree TabFour" "TabTwo" "$:/state/test-tab-02" "tc-vertical">>
|
||||
@@ -1,179 +0,0 @@
|
||||
/*\
|
||||
title: test-filesystem-dynamic-store.js
|
||||
type: application/javascript
|
||||
tags: [[$:/tags/test-spec]]
|
||||
|
||||
Tests for the filesystem syncadaptor dynamic store feature: save routing
|
||||
driven by saveFilter, and chokidar-based watching of out-of-band edits.
|
||||
|
||||
\*/
|
||||
"use strict";
|
||||
|
||||
if($tw.node) {
|
||||
|
||||
var fs = require("fs"),
|
||||
path = require("path"),
|
||||
os = require("os");
|
||||
|
||||
// Load the filesystem adaptor source as if it were a TW module, so that
|
||||
// $tw is provided without having to include the plugin in the edition
|
||||
// (which would pull in the server-side syncer and keep the test runner alive).
|
||||
var adaptorPath = path.resolve($tw.boot.bootPath,"..","plugins","tiddlywiki","filesystem","filesystemadaptor.js"),
|
||||
adaptorTitle = "$:/plugins/tiddlywiki/filesystem/filesystemadaptor.js";
|
||||
if(!$tw.modules.titles[adaptorTitle]) {
|
||||
$tw.modules.titles[adaptorTitle] = {
|
||||
moduleType: "syncadaptor",
|
||||
definition: fs.readFileSync(adaptorPath,"utf8")
|
||||
};
|
||||
$tw.wiki.addTiddler({
|
||||
title: adaptorTitle,
|
||||
type: "application/javascript",
|
||||
"module-type": "syncadaptor",
|
||||
text: ""
|
||||
});
|
||||
}
|
||||
var FileSystemAdaptor = $tw.modules.execute(adaptorTitle).adaptorClass;
|
||||
|
||||
function makeTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(),prefix));
|
||||
}
|
||||
|
||||
function removeDirRecursive(dir) {
|
||||
if(fs.existsSync(dir)) {
|
||||
fs.rmSync(dir,{recursive: true, force: true});
|
||||
}
|
||||
}
|
||||
|
||||
describe("filesystem dynamic store", function() {
|
||||
|
||||
var tmpRoot, wikiTiddlers, storeDir, origDynamicStores, origFiles, originalBootPath;
|
||||
var adaptor, wiki;
|
||||
|
||||
beforeEach(function() {
|
||||
tmpRoot = makeTempDir("tw-dyn-");
|
||||
wikiTiddlers = path.join(tmpRoot,"tiddlers");
|
||||
storeDir = path.join(tmpRoot,"content");
|
||||
fs.mkdirSync(wikiTiddlers);
|
||||
fs.mkdirSync(storeDir);
|
||||
|
||||
origDynamicStores = $tw.boot.dynamicStores;
|
||||
origFiles = $tw.boot.files;
|
||||
originalBootPath = $tw.boot.wikiTiddlersPath;
|
||||
|
||||
$tw.boot.dynamicStores = [{
|
||||
id: storeDir,
|
||||
directory: storeDir,
|
||||
saveFilter: "[type[text/x-markdown]]",
|
||||
watch: true,
|
||||
debounce: 40,
|
||||
filesRegExp: ".*\\.tid$",
|
||||
searchSubdirectories: false,
|
||||
isTiddlerFile: true,
|
||||
fields: {}
|
||||
}];
|
||||
$tw.boot.files = Object.create(null);
|
||||
$tw.boot.wikiTiddlersPath = wikiTiddlers;
|
||||
|
||||
wiki = new $tw.Wiki();
|
||||
adaptor = new FileSystemAdaptor({wiki: wiki, boot: $tw.boot});
|
||||
});
|
||||
|
||||
afterEach(function(done) {
|
||||
adaptor.close().then(function() {
|
||||
$tw.boot.dynamicStores = origDynamicStores;
|
||||
$tw.boot.files = origFiles;
|
||||
$tw.boot.wikiTiddlersPath = originalBootPath;
|
||||
removeDirRecursive(tmpRoot);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("routes saves for matching tiddlers into the dynamic store directory", function(done) {
|
||||
wiki.addTiddler(new $tw.Tiddler({title: "note1", type: "text/x-markdown", text: "hello"}));
|
||||
var tiddler = wiki.getTiddler("note1");
|
||||
adaptor.getTiddlerFileInfo(tiddler,function(err,fileInfo) {
|
||||
expect(err).toBeFalsy();
|
||||
expect(fileInfo.filepath.indexOf(storeDir)).toBe(0);
|
||||
expect(fileInfo.dynamicStoreId).toBe(storeDir);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("routes saves for non-matching tiddlers into the default wiki tiddlers path", function(done) {
|
||||
wiki.addTiddler(new $tw.Tiddler({title: "note2", type: "text/vnd.tiddlywiki", text: "plain"}));
|
||||
var tiddler = wiki.getTiddler("note2");
|
||||
adaptor.getTiddlerFileInfo(tiddler,function(err,fileInfo) {
|
||||
expect(err).toBeFalsy();
|
||||
expect(fileInfo.filepath.indexOf(wikiTiddlers)).toBe(0);
|
||||
expect(fileInfo.dynamicStoreId).toBeFalsy();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps saving a tiddler into the store it originally came from", function(done) {
|
||||
// Simulate a tiddler that was loaded at boot from the dynamic store
|
||||
$tw.boot.files["frozen"] = {
|
||||
filepath: path.join(storeDir,"frozen.tid"),
|
||||
type: "application/x-tiddler",
|
||||
hasMetaFile: false,
|
||||
isEditableFile: true,
|
||||
dynamicStoreId: storeDir
|
||||
};
|
||||
// Its current type no longer matches the saveFilter — store id must still win
|
||||
wiki.addTiddler(new $tw.Tiddler({title: "frozen", type: "text/vnd.tiddlywiki", text: "still here"}));
|
||||
adaptor.getTiddlerFileInfo(wiki.getTiddler("frozen"),function(err,fileInfo) {
|
||||
expect(err).toBeFalsy();
|
||||
expect(fileInfo.filepath.indexOf(storeDir)).toBe(0);
|
||||
expect(fileInfo.dynamicStoreId).toBe(storeDir);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
// Note: the chokidar watcher's only job is to call processFileEvent in
|
||||
// response to fs events. We invoke processFileEvent directly here so the
|
||||
// tests don't depend on real fs notifications being delivered (some CI
|
||||
// sandboxes do not propagate inotify events to chokidar).
|
||||
|
||||
it("processes external additions, changes and deletions", function(done) {
|
||||
var store = $tw.boot.dynamicStores[0];
|
||||
var filepath = path.join(storeDir,"external.tid");
|
||||
fs.writeFileSync(filepath,"title: external\ntype: text/x-markdown\n\nInitial\n");
|
||||
adaptor.processFileEvent(store,filepath,"change");
|
||||
adaptor.getUpdatedTiddlers({},function(err,updates) {
|
||||
expect(err).toBeFalsy();
|
||||
expect(updates.modifications).toContain("external");
|
||||
adaptor.loadTiddler("external",function(err,fields) {
|
||||
expect(err).toBeFalsy();
|
||||
expect(fields).toBeTruthy();
|
||||
expect(fields.title).toBe("external");
|
||||
expect(fields.text).toContain("Initial");
|
||||
// Edit
|
||||
fs.writeFileSync(filepath,"title: external\ntype: text/x-markdown\n\nChanged\n");
|
||||
adaptor.processFileEvent(store,filepath,"change");
|
||||
adaptor.getUpdatedTiddlers({},function(err,updates) {
|
||||
expect(updates.modifications).toContain("external");
|
||||
// Delete
|
||||
fs.unlinkSync(filepath);
|
||||
adaptor.processFileEvent(store,filepath,"unlink");
|
||||
adaptor.getUpdatedTiddlers({},function(err,updates) {
|
||||
expect(updates.deletions).toContain("external");
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses echoes when the file on disk matches the current wiki tiddler", function(done) {
|
||||
var store = $tw.boot.dynamicStores[0];
|
||||
wiki.addTiddler(new $tw.Tiddler({title: "echo", type: "text/x-markdown", text: "same\n"}));
|
||||
var filepath = path.join(storeDir,"echo.tid");
|
||||
fs.writeFileSync(filepath,"title: echo\ntype: text/x-markdown\n\nsame\n");
|
||||
adaptor.processFileEvent(store,filepath,"change");
|
||||
adaptor.getUpdatedTiddlers({},function(err,updates) {
|
||||
expect(updates.modifications).not.toContain("echo");
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1062,7 +1062,7 @@ describe("Filter tests", function() {
|
||||
});
|
||||
|
||||
it("should handle the deserializers operator", function() {
|
||||
var expectedDeserializers = ["application/javascript","application/json","application/x-tiddler","application/x-tiddler-html-div","application/x-tiddlers","text/css","text/html","text/markdown","text/plain","text/x-markdown"];
|
||||
var expectedDeserializers = ["application/javascript","application/json","application/x-tiddler","application/x-tiddler-html-div","application/x-tiddlers","text/css","text/html","text/plain"];
|
||||
if($tw.browser) {
|
||||
expectedDeserializers.unshift("(DOM)");
|
||||
}
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
/*\
|
||||
title: test-markdown-frontmatter.js
|
||||
type: application/javascript
|
||||
tags: [[$:/tags/test-spec]]
|
||||
|
||||
Tests for the markdown plugin's YAML frontmatter parser, deserializer,
|
||||
and serializer.
|
||||
|
||||
\*/
|
||||
|
||||
/* eslint-env node, browser, jasmine */
|
||||
/* eslint no-mixed-spaces-and-tabs: ["error", "smart-tabs"]*/
|
||||
"use strict";
|
||||
|
||||
describe("markdown YAML frontmatter", function() {
|
||||
|
||||
var yaml = require("$:/plugins/tiddlywiki/markdown/yaml.js");
|
||||
var deserializer = require("$:/plugins/tiddlywiki/markdown/frontmatter-deserializer.js");
|
||||
var serializer = require("$:/plugins/tiddlywiki/markdown/frontmatter-serializer.js");
|
||||
|
||||
// --- YAML parser ---
|
||||
|
||||
describe("yaml.load scalars", function() {
|
||||
it("parses null forms", function() {
|
||||
expect(yaml.load("null")).toBe(null);
|
||||
expect(yaml.load("~")).toBe(null);
|
||||
expect(yaml.load("")).toBe(null);
|
||||
});
|
||||
it("parses booleans", function() {
|
||||
expect(yaml.load("true")).toBe(true);
|
||||
expect(yaml.load("True")).toBe(true);
|
||||
expect(yaml.load("false")).toBe(false);
|
||||
});
|
||||
it("parses numbers", function() {
|
||||
expect(yaml.load("42")).toBe(42);
|
||||
expect(yaml.load("-7")).toBe(-7);
|
||||
expect(yaml.load("3.14")).toBe(3.14);
|
||||
expect(yaml.load("1e10")).toBe(1e10);
|
||||
expect(yaml.load("0xFF")).toBe(255);
|
||||
expect(yaml.load("0o17")).toBe(15);
|
||||
});
|
||||
it("parses special floats", function() {
|
||||
expect(yaml.load(".inf")).toBe(Infinity);
|
||||
expect(yaml.load("-.inf")).toBe(-Infinity);
|
||||
});
|
||||
it("parses quoted strings", function() {
|
||||
expect(yaml.load('"hello world"')).toBe("hello world");
|
||||
expect(yaml.load("'hello world'")).toBe("hello world");
|
||||
expect(yaml.load('"line1\\nline2"')).toBe("line1\nline2");
|
||||
});
|
||||
it("parses plain strings", function() {
|
||||
expect(yaml.load("hello")).toBe("hello");
|
||||
});
|
||||
it("rejects non-strings", function() {
|
||||
expect(function() { yaml.load(123); }).toThrowError(yaml.YAMLException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("yaml.load flow collections", function() {
|
||||
it("parses flow sequences", function() {
|
||||
expect(yaml.load("[a, b, c]")).toEqual(["a","b","c"]);
|
||||
expect(yaml.load("[1, 2, 3]")).toEqual([1,2,3]);
|
||||
expect(yaml.load('[1, "two", true, null]')).toEqual([1,"two",true,null]);
|
||||
expect(yaml.load("[]")).toEqual([]);
|
||||
expect(yaml.load('["multi word", simple]')).toEqual(["multi word","simple"]);
|
||||
});
|
||||
it("parses flow mappings", function() {
|
||||
expect(yaml.load("{a: 1, b: 2}")).toEqual({a:1,b:2});
|
||||
expect(yaml.load("{}")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("yaml.load block collections", function() {
|
||||
it("parses simple block mappings", function() {
|
||||
expect(yaml.load("title: Hello\ntags: foo bar\nrating: 6")).toEqual({
|
||||
title: "Hello",
|
||||
tags: "foo bar",
|
||||
rating: 6
|
||||
});
|
||||
});
|
||||
it("parses block mapping with flow array value", function() {
|
||||
expect(yaml.load("title: Test\ntags: [concept, synthesis, multi word tag]")).toEqual({
|
||||
title: "Test",
|
||||
tags: ["concept","synthesis","multi word tag"]
|
||||
});
|
||||
});
|
||||
it("parses block mapping with quoted value", function() {
|
||||
expect(yaml.load('title: "A: Subtitle"')).toEqual({title: "A: Subtitle"});
|
||||
});
|
||||
it("parses block mapping with null value", function() {
|
||||
expect(yaml.load("title: Test\ndescription:")).toEqual({
|
||||
title: "Test",
|
||||
description: null
|
||||
});
|
||||
});
|
||||
it("parses block sequences", function() {
|
||||
expect(yaml.load("- alpha\n- beta\n- gamma")).toEqual(["alpha","beta","gamma"]);
|
||||
expect(yaml.load("- 1\n- two\n- true")).toEqual([1,"two",true]);
|
||||
});
|
||||
it("parses nested block mappings", function() {
|
||||
expect(yaml.load("outer:\n inner: value\n count: 3")).toEqual({
|
||||
outer: {inner: "value", count: 3}
|
||||
});
|
||||
});
|
||||
it("parses block mapping with block sequence value", function() {
|
||||
expect(yaml.load("title: Test\ntags:\n - concept\n - synthesis")).toEqual({
|
||||
title: "Test",
|
||||
tags: ["concept","synthesis"]
|
||||
});
|
||||
});
|
||||
it("ignores comments and blank lines", function() {
|
||||
expect(yaml.load("# comment\ntitle: Test\n# more\nrating: 5")).toEqual({
|
||||
title: "Test",
|
||||
rating: 5
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("yaml.dump", function() {
|
||||
it("dumps simple mappings", function() {
|
||||
expect(yaml.dump({title: "Hello", rating: 6}).trim()).toBe("title: Hello\nrating: 6");
|
||||
});
|
||||
it("dumps arrays", function() {
|
||||
expect(yaml.dump({tags: ["a","b"]}).trim()).toBe("tags:\n - a\n - b");
|
||||
});
|
||||
it("dumps null and booleans", function() {
|
||||
expect(yaml.dump({x: null}).trim()).toBe("x: null");
|
||||
expect(yaml.dump({x: true, y: false}).trim()).toBe("x: true\ny: false");
|
||||
});
|
||||
it("dumps empty containers", function() {
|
||||
expect(yaml.dump({}).trim()).toBe("{}");
|
||||
expect(yaml.dump({x: []}).trim()).toBe("x: []");
|
||||
});
|
||||
it("quotes string values that look like numbers", function() {
|
||||
expect(yaml.dump({rating: "9"}).trim()).toBe('rating: "9"');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Deserializer ---
|
||||
|
||||
describe("frontmatter deserializer", function() {
|
||||
var ds = deserializer["text/x-markdown"];
|
||||
|
||||
it("extracts simple frontmatter into fields", function() {
|
||||
var result = ds("---\ntitle: Foo\ntags: [a, b]\n---\n\nBody text.",{});
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].title).toBe("Foo");
|
||||
expect(result[0].tags).toBe("a b");
|
||||
expect(result[0].text).toBe("Body text.");
|
||||
expect(result[0].type).toBe("text/x-markdown");
|
||||
});
|
||||
it("converts YAML arrays for list fields to TW bracketed lists", function() {
|
||||
var result = ds("---\ntags: [concept, multi word tag, simple]\n---\n\nbody",{});
|
||||
expect(result[0].tags).toBe("concept [[multi word tag]] simple");
|
||||
});
|
||||
it("falls back to plain body when no frontmatter present", function() {
|
||||
var result = ds("Just a body, no frontmatter.",{});
|
||||
expect(result[0].text).toBe("Just a body, no frontmatter.");
|
||||
expect(result[0].title).toBeUndefined();
|
||||
});
|
||||
it("falls back to plain body when frontmatter is malformed", function() {
|
||||
var result = ds("---\nnot: [valid yaml: at all\n---\n\nbody",{});
|
||||
// Malformed YAML still parses something; we just ensure body is set
|
||||
expect(result[0].text).toBeDefined();
|
||||
});
|
||||
it("parses ISO-8601 created and modified into TW native format", function() {
|
||||
var result = ds("---\ntitle: T\ncreated: 2025-01-02T03:04:05.006Z\nmodified: 2026-02-03T04:05:06Z\n---\n\nb",{});
|
||||
expect(result[0].created).toBe("20250102030405006");
|
||||
expect(result[0].modified).toBe("20260203040506000");
|
||||
});
|
||||
it("accepts a bare YYYY-MM-DD date for created/modified", function() {
|
||||
var result = ds("---\ntitle: T\ncreated: 2025-03-15\n---\n\nb",{});
|
||||
expect(result[0].created).toBe("20250315000000000");
|
||||
});
|
||||
it("passes through TW native timestamps for created/modified", function() {
|
||||
var result = ds("---\ntitle: T\ncreated: \"20250101000000000\"\nmodified: \"20260101000000\"\n---\n\nb",{});
|
||||
expect(result[0].created).toBe("20250101000000000");
|
||||
expect(result[0].modified).toBe("20260101000000000");
|
||||
});
|
||||
it("drops unparseable created/modified values", function() {
|
||||
var result = ds("---\ntitle: T\ncreated: not-a-date\n---\n\nb",{});
|
||||
expect(result[0].created).toBeUndefined();
|
||||
});
|
||||
it("merges existing tags with frontmatter tags", function() {
|
||||
var result = ds("---\ntags: [b, c]\n---\n\nbody",{tags: "a"});
|
||||
// Order: existing first, then new uniques
|
||||
expect(result[0].tags).toBe("a b c");
|
||||
});
|
||||
it("emits non-string non-array values as JSON", function() {
|
||||
var result = ds("---\ntitle: T\nmeta: {nested: deep}\n---\n\nb",{});
|
||||
expect(result[0].meta).toBe('{"nested":"deep"}');
|
||||
});
|
||||
it("handles CRLF line endings around frontmatter", function() {
|
||||
var result = ds("---\r\ntitle: T\r\n---\r\n\r\nbody",{});
|
||||
expect(result[0].title).toBe("T");
|
||||
expect(result[0].text).toBe("body");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Serializer ---
|
||||
|
||||
describe("frontmatter serializer", function() {
|
||||
var ser = serializer["text/x-markdown"];
|
||||
|
||||
it("emits frontmatter and body", function() {
|
||||
var t = new $tw.Tiddler({title: "Foo", text: "body", tags: "a b"});
|
||||
var out = ser(t);
|
||||
expect(out).toContain("---\n");
|
||||
expect(out).toContain("title: Foo");
|
||||
expect(out).toContain("tags:\n - a\n - b");
|
||||
expect(out.split("\n---\n\n")[1]).toBe("body");
|
||||
});
|
||||
it("emits list fields as YAML arrays preserving multi-word tags", function() {
|
||||
var t = new $tw.Tiddler({title: "X", tags: "concept [[multi word tag]] simple", text: "b"});
|
||||
var out = ser(t);
|
||||
expect(out).toContain("- concept");
|
||||
expect(out).toContain("- multi word tag");
|
||||
expect(out).toContain("- simple");
|
||||
});
|
||||
it("skips text, bag, revision", function() {
|
||||
var t = new $tw.Tiddler({
|
||||
title: "X",
|
||||
text: "body",
|
||||
bag: "default",
|
||||
revision: "1"
|
||||
});
|
||||
var out = ser(t);
|
||||
expect(out).not.toContain("bag:");
|
||||
expect(out).not.toContain("revision:");
|
||||
expect(out).not.toContain("text:");
|
||||
});
|
||||
it("emits created and modified as ISO-8601 strings", function() {
|
||||
var t = new $tw.Tiddler({
|
||||
title: "X",
|
||||
text: "b",
|
||||
created: "20250102030405006",
|
||||
modified: "20260203040506000"
|
||||
});
|
||||
var out = ser(t);
|
||||
expect(out).toContain('created: "2025-01-02T03:04:05.006Z"');
|
||||
expect(out).toContain('modified: "2026-02-03T04:05:06.000Z"');
|
||||
});
|
||||
it("drops unparseable created/modified values", function() {
|
||||
var t = new $tw.Tiddler({title: "X", text: "b", created: "garbage"});
|
||||
var out = ser(t);
|
||||
expect(out).not.toContain("created:");
|
||||
});
|
||||
it("skips type when it equals text/x-markdown", function() {
|
||||
var t = new $tw.Tiddler({title: "X", type: "text/x-markdown", text: "b"});
|
||||
expect(ser(t)).not.toContain("type:");
|
||||
});
|
||||
it("emits type when it differs from text/x-markdown", function() {
|
||||
var t = new $tw.Tiddler({title: "X", type: "text/html", text: "b"});
|
||||
expect(ser(t)).toContain("type: text/html");
|
||||
});
|
||||
it("emits no frontmatter when only skipped fields are present", function() {
|
||||
var t = new $tw.Tiddler({text: "body only"});
|
||||
expect(ser(t)).toBe("body only");
|
||||
});
|
||||
it("returns empty string for null tiddler", function() {
|
||||
expect(ser(null)).toBe("");
|
||||
});
|
||||
it("title appears first in output", function() {
|
||||
var t = new $tw.Tiddler({title: "Z", rating: "9", tags: "a", text: "b"});
|
||||
var out = ser(t);
|
||||
var lines = out.split("\n");
|
||||
// First line is "---", second should be "title: Z"
|
||||
expect(lines[0]).toBe("---");
|
||||
expect(lines[1]).toBe("title: Z");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Round-trip ---
|
||||
|
||||
describe("frontmatter round-trip", function() {
|
||||
var ds = deserializer["text/x-markdown"];
|
||||
var ser = serializer["text/x-markdown"];
|
||||
|
||||
it("preserves title, tags, and body across deserialize → serialize", function() {
|
||||
var input = "---\ntitle: My Tiddler\ntags: [concept, synthesis]\nrating: \"7\"\n---\n\nThis is the body.";
|
||||
var fields = ds(input,{})[0];
|
||||
var t = new $tw.Tiddler(fields);
|
||||
var out = ser(t);
|
||||
var reparsed = ds(out,{})[0];
|
||||
expect(reparsed.title).toBe("My Tiddler");
|
||||
expect(reparsed.tags).toBe("concept synthesis");
|
||||
expect(reparsed.rating).toBe("7");
|
||||
expect(reparsed.text).toBe("This is the body.");
|
||||
});
|
||||
it("preserves created and modified across deserialize → serialize", function() {
|
||||
var input = "---\ntitle: T\ncreated: 2025-01-02T03:04:05.006Z\nmodified: 2026-02-03T04:05:06.007Z\n---\n\nbody";
|
||||
var fields = ds(input,{})[0];
|
||||
var t = new $tw.Tiddler(fields);
|
||||
var out = ser(t);
|
||||
var reparsed = ds(out,{})[0];
|
||||
expect(reparsed.created).toBe("20250102030405006");
|
||||
expect(reparsed.modified).toBe("20260203040506007");
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -3,8 +3,7 @@
|
||||
"plugins": [
|
||||
"tiddlywiki/jasmine",
|
||||
"tiddlywiki/wikitext-serialize",
|
||||
"tiddlywiki/geospatial",
|
||||
"tiddlywiki/markdown"
|
||||
"tiddlywiki/geospatial"
|
||||
],
|
||||
"themes": [
|
||||
"tiddlywiki/vanilla",
|
||||
|
||||
@@ -56,11 +56,6 @@ Directory specifications in the `directories` array may take the following forms
|
||||
** ''isEditableFile'' - <<.from-version "5.1.23">> (optional) if `true`, changes to the tiddler be saved back to the original file. The tiddler will be saved back to the original filepath as long as it does not generate a result from the $:/config/FileSystemPath filters, which will override the final filepath generated if a result is returned from a filter.
|
||||
** ''searchSubdirectories'' - <<.from-version "5.1.23">> (optional) if `true`, all subdirectories of the //path// are searched recursively for files that match the (optional) //filesRegExp//. If no //filesRegExp// is provided, all files in all subdirectories of the //path// are loaded. Tiddler titles generated via the //source// attribute //filename// (see above) will only include the filename, not any of the subdirectories of the path. If this results in multiple files with loaded with the same tiddler title, then only the last file loaded under that tiddler title will be in memory. In order to prevent this, you can use the //filepath// attribute instead of //filename//. Alternately, you can include multiple directory objects and customise the title field with a //prefix// or //suffix// alongside the //source// attribute.
|
||||
** ''fields'' - (required) an object containing values that override or customise the fields provided in the tiddler file (see above)
|
||||
** ''dynamicStore'' - <<.from-version "5.5.0">> (optional) an object marking the directory as a //dynamic store// that is both loaded at boot and actively watched on disk. The filesystem syncadaptor uses the watcher to pick up out-of-band changes (e.g. edits made by an external editor) and folds them into the running wiki. The object has the following properties:
|
||||
*** ''saveFilter'' - (optional) a filter evaluated against each tiddler the wiki tries to save. Tiddlers that match are saved back into this directory instead of the default tiddlers folder. The first matching dynamic store wins, so specificity matters when multiple stores are registered.
|
||||
*** ''watch'' - (optional, defaults to `true`) set to `false` to disable the chokidar watcher for this store (tiddlers are still loaded and save-routed, but external changes are not picked up live)
|
||||
*** ''debounce'' - (optional, defaults to `400`) the per-file debounce window in milliseconds. File events that occur within this window of a previous event for the same file are coalesced, which avoids duplicated reloads during atomic-rename saves performed by many editors.
|
||||
*** Changes to a file on disk are diffed against the tiddler currently in the wiki before being reported, so self-writes performed by TiddlyWiki itself do not trigger spurious reload events. JavaScript module tiddlers (tiddlers with `type: application/javascript` and a `module-type` field) are never hot-reloaded, because that would require restarting the TiddlyWiki process.
|
||||
|
||||
Fields can also be overridden for particular files by creating a file with the same name plus the suffix `.meta` -- see TiddlerFiles.
|
||||
|
||||
@@ -140,29 +135,6 @@ From the examples in [[Customising Tiddler File Naming]] we see that the final `
|
||||
|
||||
Then, the `[tag[.txt]then[.txt]]` filter in the $:/config/FileSystemExtensions tiddler forces all these tiddlers to be saved back to disk as *.txt and accompanying *.txt.meta files (overriding the normal tiddler-type to file-type mapping). In this case, allowing the snippets of Tiddlywiki wikitext or markdown-text to be saved back to "text" *.txt files.
|
||||
|
||||
!! Dynamic store for markdown files
|
||||
|
||||
<<.from-version "5.5.0">> This example treats a sibling `content/` folder as a dynamic store: every `.md` file is loaded at boot, every markdown tiddler saved by the wiki is routed back into `content/`, and external edits to those files are picked up automatically by chokidar.
|
||||
|
||||
```
|
||||
{
|
||||
"directories": [
|
||||
{
|
||||
"path": "../content",
|
||||
"filesRegExp": "^.*\\.md$",
|
||||
"isTiddlerFile": true,
|
||||
"searchSubdirectories": true,
|
||||
"dynamicStore": {
|
||||
"saveFilter": "[type[text/x-markdown]]",
|
||||
"watch": true,
|
||||
"debounce": 400
|
||||
},
|
||||
"fields": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
!! Importing and auto-tagging images
|
||||
|
||||
This example imports all the image files in the `files` directory and all its subdirectories as external-image tiddlers, and tags them based on their filepath. Each tiddler is set up with the following fields:
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
title: $:/changenotes/5.5.0/#9806
|
||||
description: Filesystem dynamic stores with live reload via chokidar
|
||||
release: 5.5.0
|
||||
tags: $:/tags/ChangeNote
|
||||
change-type: feature
|
||||
change-category: nodejs
|
||||
github-links: https://github.com/TiddlyWiki/TiddlyWiki5/pull/9806
|
||||
github-contributors: Jermolene
|
||||
|
||||
The filesystem syncadaptor can now be configured to treat a folder as a [[dynamic store|tiddlywiki.files Files]]: tiddlers in the folder are loaded at boot, a configurable filter decides which tiddlers are saved back into that folder instead of the default `tiddlers/` directory, and external edits to files in the folder are picked up live by a [[chokidar|https://github.com/paulmillr/chokidar]] watcher.
|
||||
|
||||
Dynamic stores are declared inside a `tiddlywiki.files` specification via the new `dynamicStore` property on a directory entry. For example, the following declaration loads every `.md` file in a sibling `content/` folder, routes any tiddler with `type: text/x-markdown` back to that folder on save, and hot-reloads changes made by an external editor:
|
||||
|
||||
```
|
||||
{
|
||||
"directories": [{
|
||||
"path": "../content",
|
||||
"filesRegExp": "^.*\\.md$",
|
||||
"isTiddlerFile": true,
|
||||
"dynamicStore": {
|
||||
"saveFilter": "[type[text/x-markdown]]",
|
||||
"watch": true,
|
||||
"debounce": 400
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
* File events are debounced per file (default 400ms, configurable via `debounce`) to cope with editors that save atomically via rename.
|
||||
* Each detected change is diffed against the current in-wiki tiddler before being reported, so self-writes performed by TiddlyWiki itself do not cause reload loops.
|
||||
* Deletions on disk propagate to the wiki via the syncer's standard server-side-deletion path.
|
||||
* JavaScript module tiddlers (those with `type: application/javascript` and a `module-type` field) are never hot-reloaded; reloading them would require restarting the TiddlyWiki process.
|
||||
|
||||
This feature adds [[chokidar|https://github.com/paulmillr/chokidar]] as a new runtime dependency of TiddlyWiki on Node.js.
|
||||
Generated
+1
-32
@@ -8,9 +8,6 @@
|
||||
"name": "tiddlywiki",
|
||||
"version": "5.4.0",
|
||||
"license": "BSD",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3"
|
||||
},
|
||||
"bin": {
|
||||
"tiddlywiki": "tiddlywiki.js"
|
||||
},
|
||||
@@ -22,7 +19,7 @@
|
||||
"globals": "16.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
@@ -332,21 +329,6 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"dev": true,
|
||||
@@ -916,19 +898,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"dev": true,
|
||||
|
||||
+1
-4
@@ -23,14 +23,11 @@
|
||||
"tiddlywiki5",
|
||||
"wiki"
|
||||
],
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.36.0",
|
||||
"@stylistic/eslint-plugin": "5.4.0",
|
||||
"eslint": "9.36.0",
|
||||
"eslint-plugin-es-x": "9.1.0",
|
||||
"@stylistic/eslint-plugin": "5.4.0",
|
||||
"globals": "16.4.0"
|
||||
},
|
||||
"license": "BSD",
|
||||
|
||||
@@ -11,7 +11,6 @@ A sync adaptor module for synchronising with the local filesystem via node.js AP
|
||||
|
||||
// Get a reference to the file system
|
||||
var fs = $tw.node ? require("fs") : null;
|
||||
var path = $tw.node ? require("path") : null;
|
||||
|
||||
function FileSystemAdaptor(options) {
|
||||
this.wiki = options.wiki;
|
||||
@@ -21,19 +20,6 @@ function FileSystemAdaptor(options) {
|
||||
if(this.boot.wikiTiddlersPath) {
|
||||
$tw.utils.createDirectory(this.boot.wikiTiddlersPath);
|
||||
}
|
||||
// Buffers for out-of-band file changes, drained by getUpdatedTiddlers
|
||||
this.modifications = Object.create(null);
|
||||
this.deletions = Object.create(null);
|
||||
this.pendingTimers = Object.create(null);
|
||||
this.watchers = [];
|
||||
this.setupWatchers();
|
||||
// Only advertise getUpdatedTiddlers (and so opt into syncer polling) when
|
||||
// there is actually a dynamic store to report changes from. Otherwise the
|
||||
// syncer would reschedule its poll forever and keep node alive past the
|
||||
// natural end of headless commands like --build.
|
||||
if(!(this.boot.dynamicStores && this.boot.dynamicStores.length > 0)) {
|
||||
this.getUpdatedTiddlers = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
FileSystemAdaptor.prototype.name = "filesystem";
|
||||
@@ -41,56 +27,35 @@ FileSystemAdaptor.prototype.name = "filesystem";
|
||||
FileSystemAdaptor.prototype.supportsLazyLoading = false;
|
||||
|
||||
FileSystemAdaptor.prototype.isReady = function() {
|
||||
// The file system adaptor is always ready
|
||||
return true;
|
||||
};
|
||||
|
||||
FileSystemAdaptor.prototype.getTiddlerInfo = function(tiddler) {
|
||||
//Returns the existing fileInfo for the tiddler. To regenerate, call getTiddlerFileInfo().
|
||||
var title = tiddler.fields.title;
|
||||
return this.boot.files[title];
|
||||
};
|
||||
|
||||
/*
|
||||
Find the dynamic store (if any) that a tiddler should be saved into.
|
||||
Precedence: existing boot.files entry wins; otherwise first matching saveFilter.
|
||||
*/
|
||||
FileSystemAdaptor.prototype.findDynamicStoreForTiddler = function(tiddler) {
|
||||
var stores = this.boot.dynamicStores || [];
|
||||
if(stores.length === 0) {
|
||||
return null;
|
||||
}
|
||||
var title = tiddler.fields.title,
|
||||
existing = this.boot.files[title];
|
||||
if(existing && existing.dynamicStoreId) {
|
||||
for(var i=0; i<stores.length; i++) {
|
||||
if(stores[i].id === existing.dynamicStoreId) {
|
||||
return stores[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
for(var j=0; j<stores.length; j++) {
|
||||
var store = stores[j];
|
||||
if(store.saveFilter) {
|
||||
var source = this.wiki.makeTiddlerIterator([title]),
|
||||
result = this.wiki.filterTiddlers(store.saveFilter,null,source);
|
||||
if(result.length > 0) {
|
||||
return store;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
Return a fileInfo object for a tiddler, creating it if necessary:
|
||||
filepath: the absolute path to the file containing the tiddler
|
||||
type: the type of the tiddler file (NOT the type of the tiddler -- see below)
|
||||
hasMetaFile: true if the file also has a companion .meta file
|
||||
|
||||
/*
|
||||
Return a fileInfo object for a tiddler, creating it if necessary.
|
||||
The boot process populates this.boot.files for each of the tiddler files that it loads.
|
||||
The type is found by looking up the extension in $tw.config.fileExtensionInfo (eg "application/x-tiddler" for ".tid" files).
|
||||
|
||||
It is the responsibility of the filesystem adaptor to update this.boot.files for new files that are created.
|
||||
*/
|
||||
FileSystemAdaptor.prototype.getTiddlerFileInfo = function(tiddler,callback) {
|
||||
// Error if we don't have a this.boot.wikiTiddlersPath
|
||||
if(!this.boot.wikiTiddlersPath) {
|
||||
return callback("filesystemadaptor requires a valid wiki folder");
|
||||
}
|
||||
// Always generate a fileInfo object when this fuction is called
|
||||
var title = tiddler.fields.title, newInfo, pathFilters, extFilters,
|
||||
fileInfo = this.boot.files[title],
|
||||
store = this.findDynamicStoreForTiddler(tiddler),
|
||||
directory = store ? store.directory : this.boot.wikiTiddlersPath;
|
||||
fileInfo = this.boot.files[title];
|
||||
if(this.wiki.tiddlerExists("$:/config/FileSystemPaths")) {
|
||||
pathFilters = this.wiki.getTiddlerText("$:/config/FileSystemPaths","").split("\n");
|
||||
}
|
||||
@@ -98,15 +63,12 @@ FileSystemAdaptor.prototype.getTiddlerFileInfo = function(tiddler,callback) {
|
||||
extFilters = this.wiki.getTiddlerText("$:/config/FileSystemExtensions","").split("\n");
|
||||
}
|
||||
newInfo = $tw.utils.generateTiddlerFileInfo(tiddler,{
|
||||
directory: directory,
|
||||
directory: this.boot.wikiTiddlersPath,
|
||||
pathFilters: pathFilters,
|
||||
extFilters: extFilters,
|
||||
wiki: this.wiki,
|
||||
fileInfo: fileInfo
|
||||
});
|
||||
if(store) {
|
||||
newInfo.dynamicStoreId = store.id;
|
||||
}
|
||||
callback(null,newInfo);
|
||||
};
|
||||
|
||||
@@ -121,7 +83,6 @@ FileSystemAdaptor.prototype.saveTiddler = function(tiddler,callback,options) {
|
||||
if(err) {
|
||||
return callback(err);
|
||||
}
|
||||
var dynamicStoreId = fileInfo && fileInfo.dynamicStoreId || null;
|
||||
$tw.utils.saveTiddlerToFile(tiddler,fileInfo,function(err,fileInfo) {
|
||||
if(err) {
|
||||
if((err.code == "EPERM" || err.code == "EACCES") && err.syscall == "open") {
|
||||
@@ -134,9 +95,6 @@ FileSystemAdaptor.prototype.saveTiddler = function(tiddler,callback,options) {
|
||||
return callback(err);
|
||||
}
|
||||
}
|
||||
if(dynamicStoreId && fileInfo) {
|
||||
fileInfo.dynamicStoreId = dynamicStoreId;
|
||||
}
|
||||
// Store new boot info only after successful writes
|
||||
self.boot.files[tiddler.fields.title] = fileInfo;
|
||||
// Cleanup duplicates if the file moved or changed extensions
|
||||
@@ -158,28 +116,9 @@ FileSystemAdaptor.prototype.saveTiddler = function(tiddler,callback,options) {
|
||||
/*
|
||||
Load a tiddler and invoke the callback with (err,tiddlerFields)
|
||||
|
||||
Most tiddlers are pre-loaded at boot, but the syncer may ask us to load
|
||||
individual tiddlers in response to watcher-driven out-of-band changes.
|
||||
We don't need to implement loading for the file system adaptor, because all the tiddler files will have been loaded during the boot process.
|
||||
*/
|
||||
FileSystemAdaptor.prototype.loadTiddler = function(title,callback) {
|
||||
var fileInfo = this.boot.files[title];
|
||||
if(!fileInfo || !fileInfo.dynamicStoreId || !fs.existsSync(fileInfo.filepath)) {
|
||||
return callback(null,null);
|
||||
}
|
||||
var loaded;
|
||||
try {
|
||||
loaded = $tw.loadTiddlersFromFile(fileInfo.filepath,{});
|
||||
} catch(e) {
|
||||
return callback(e);
|
||||
}
|
||||
if(!loaded || !loaded.tiddlers) {
|
||||
return callback(null,null);
|
||||
}
|
||||
for(var i=0; i<loaded.tiddlers.length; i++) {
|
||||
if(loaded.tiddlers[i] && loaded.tiddlers[i].title === title) {
|
||||
return callback(null,loaded.tiddlers[i]);
|
||||
}
|
||||
}
|
||||
callback(null,null);
|
||||
};
|
||||
|
||||
@@ -189,16 +128,19 @@ Delete a tiddler and invoke the callback with (err)
|
||||
FileSystemAdaptor.prototype.deleteTiddler = function(title,callback,options) {
|
||||
var self = this,
|
||||
fileInfo = this.boot.files[title];
|
||||
// Only delete the tiddler if we have writable information for the file
|
||||
if(fileInfo) {
|
||||
$tw.utils.deleteTiddlerFile(fileInfo,function(err,fileInfo) {
|
||||
if(err) {
|
||||
if((err.code == "EPERM" || err.code == "EACCES") && err.syscall == "unlink") {
|
||||
// Error deleting the file on disk, should fail gracefully
|
||||
$tw.syncer.displayError("Server desynchronized. Error deleting file for deleted tiddler \"" + title + "\"",err);
|
||||
return callback(null,fileInfo);
|
||||
} else {
|
||||
return callback(err);
|
||||
}
|
||||
}
|
||||
// Remove the tiddler from self.boot.files & return null adaptorInfo
|
||||
self.removeTiddlerFileInfo(title);
|
||||
return callback(null,null);
|
||||
});
|
||||
@@ -211,201 +153,10 @@ FileSystemAdaptor.prototype.deleteTiddler = function(title,callback,options) {
|
||||
Delete a tiddler in cache, without modifying file system.
|
||||
*/
|
||||
FileSystemAdaptor.prototype.removeTiddlerFileInfo = function(title) {
|
||||
// Only delete the tiddler info if we have writable information for the file
|
||||
if(this.boot.files[title]) {
|
||||
delete this.boot.files[title];
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Syncer hook: return modifications/deletions that have occurred on disk
|
||||
since the last poll.
|
||||
*/
|
||||
FileSystemAdaptor.prototype.getUpdatedTiddlers = function(syncer,callback) {
|
||||
var modifications = Object.keys(this.modifications),
|
||||
deletions = Object.keys(this.deletions);
|
||||
this.modifications = Object.create(null);
|
||||
this.deletions = Object.create(null);
|
||||
callback(null,{modifications: modifications, deletions: deletions});
|
||||
};
|
||||
|
||||
/*
|
||||
Set up chokidar watchers for each registered dynamic store.
|
||||
*/
|
||||
/*
|
||||
Close all watchers and clear any pending debounce timers. Returns a promise
|
||||
that resolves once chokidar has fully shut down, for clean teardown in tests.
|
||||
*/
|
||||
FileSystemAdaptor.prototype.close = function() {
|
||||
$tw.utils.each(this.pendingTimers,function(timer) { clearTimeout(timer); });
|
||||
this.pendingTimers = Object.create(null);
|
||||
var closes = (this.watchers || []).map(function(w) {
|
||||
try { return w.close(); } catch(e) { return null; }
|
||||
});
|
||||
this.watchers = [];
|
||||
return Promise.all(closes.filter(Boolean));
|
||||
};
|
||||
|
||||
FileSystemAdaptor.prototype.setupWatchers = function() {
|
||||
var self = this,
|
||||
stores = (this.boot.dynamicStores || []).filter(function(s) { return s.watch; });
|
||||
if(stores.length === 0) {
|
||||
return;
|
||||
}
|
||||
var chokidar;
|
||||
try {
|
||||
chokidar = require("chokidar");
|
||||
} catch(e) {
|
||||
this.logger.log("chokidar not available; dynamic store watching disabled",e.message);
|
||||
return;
|
||||
}
|
||||
stores.forEach(function(store) {
|
||||
self.setupWatcher(chokidar,store);
|
||||
});
|
||||
};
|
||||
|
||||
FileSystemAdaptor.prototype.setupWatcher = function(chokidar,store) {
|
||||
var self = this,
|
||||
fileRegExp = new RegExp(store.filesRegExp || "^.*$");
|
||||
var watcher = chokidar.watch(store.directory,{
|
||||
ignoreInitial: true,
|
||||
persistent: true,
|
||||
depth: store.searchSubdirectories ? undefined : 0,
|
||||
awaitWriteFinish: {stabilityThreshold: 100, pollInterval: 50},
|
||||
ignored: function(p) {
|
||||
// chokidar invokes `ignored` for the root too — don't ignore the root
|
||||
if(p === store.directory) return false;
|
||||
var base = path.basename(p);
|
||||
if(/\.meta$/.test(base)) return false;
|
||||
// Allow directories through so recursion works when enabled
|
||||
try {
|
||||
if(fs.existsSync(p) && fs.statSync(p).isDirectory()) return false;
|
||||
} catch(e) {}
|
||||
return !fileRegExp.test(base);
|
||||
}
|
||||
});
|
||||
watcher.on("add",function(filepath) { self.scheduleFileEvent(store,filepath,"change"); });
|
||||
watcher.on("change",function(filepath) { self.scheduleFileEvent(store,filepath,"change"); });
|
||||
watcher.on("unlink",function(filepath) { self.scheduleFileEvent(store,filepath,"unlink"); });
|
||||
watcher.on("error",function(err) {
|
||||
self.logger.log("chokidar error for " + store.directory,err && err.message);
|
||||
});
|
||||
this.watchers.push(watcher);
|
||||
};
|
||||
|
||||
FileSystemAdaptor.prototype.scheduleFileEvent = function(store,filepath,eventType) {
|
||||
var self = this,
|
||||
key = filepath,
|
||||
delay = store.debounce || 400;
|
||||
// A .meta change should trigger re-read of its companion file
|
||||
var targetPath = filepath;
|
||||
if(/\.meta$/.test(filepath)) {
|
||||
targetPath = filepath.replace(/\.meta$/,"");
|
||||
}
|
||||
if(this.pendingTimers[key]) {
|
||||
clearTimeout(this.pendingTimers[key]);
|
||||
}
|
||||
var timer = setTimeout(function() {
|
||||
delete self.pendingTimers[key];
|
||||
try {
|
||||
self.processFileEvent(store,targetPath,eventType);
|
||||
} catch(e) {
|
||||
self.logger.log("Error processing file event for " + targetPath,e.message);
|
||||
}
|
||||
},delay);
|
||||
if(timer && typeof timer.unref === "function") {
|
||||
timer.unref();
|
||||
}
|
||||
this.pendingTimers[key] = timer;
|
||||
};
|
||||
|
||||
FileSystemAdaptor.prototype.processFileEvent = function(store,filepath,eventType) {
|
||||
var self = this;
|
||||
// Deletion: look up any titles that mapped to this filepath and queue deletion.
|
||||
// Do NOT call wiki.deleteTiddler here — the syncer's SyncFromServerTask does that.
|
||||
if(eventType === "unlink" || !fs.existsSync(filepath)) {
|
||||
var deletedTitles = [];
|
||||
$tw.utils.each(this.boot.files,function(info,title) {
|
||||
if(info && info.filepath === filepath) {
|
||||
deletedTitles.push(title);
|
||||
}
|
||||
});
|
||||
deletedTitles.forEach(function(title) {
|
||||
delete self.boot.files[title];
|
||||
self.deletions[title] = true;
|
||||
delete self.modifications[title];
|
||||
});
|
||||
if(deletedTitles.length > 0) {
|
||||
this.logger.log("Dynamic store: detected removal of " + deletedTitles.length + " tiddler(s) at " + filepath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Add/change: re-parse the file and queue modifications
|
||||
var loaded;
|
||||
try {
|
||||
loaded = $tw.loadTiddlersFromFile(filepath,{});
|
||||
} catch(e) {
|
||||
this.logger.log("Failed to load tiddler file " + filepath,e.message);
|
||||
return;
|
||||
}
|
||||
if(!loaded || !loaded.tiddlers) {
|
||||
return;
|
||||
}
|
||||
var newTitles = {};
|
||||
loaded.tiddlers.forEach(function(fields) {
|
||||
if(!fields || !fields.title) {
|
||||
return;
|
||||
}
|
||||
if(fields.type === "application/javascript" && fields["module-type"]) {
|
||||
self.logger.log("Skipping hot-reload of JS module tiddler " + fields.title + " (requires a restart)");
|
||||
return;
|
||||
}
|
||||
var title = fields.title;
|
||||
newTitles[title] = true;
|
||||
// Ensure boot.files tracks the file so loadTiddler can find it on demand
|
||||
self.boot.files[title] = {
|
||||
filepath: loaded.filepath,
|
||||
type: loaded.type,
|
||||
hasMetaFile: loaded.hasMetaFile,
|
||||
isEditableFile: true,
|
||||
dynamicStoreId: store.id
|
||||
};
|
||||
// Diff against the current wiki tiddler to suppress self-write echoes
|
||||
var existing = self.wiki.getTiddler(title);
|
||||
if(existing && self.tiddlerFieldsEqual(existing.fields,fields)) {
|
||||
return;
|
||||
}
|
||||
self.modifications[title] = true;
|
||||
delete self.deletions[title];
|
||||
});
|
||||
// Handle tiddlers that were previously in this file but have now disappeared
|
||||
$tw.utils.each(this.boot.files,function(info,title) {
|
||||
if(info && info.filepath === filepath && !newTitles[title]) {
|
||||
delete self.boot.files[title];
|
||||
self.deletions[title] = true;
|
||||
delete self.modifications[title];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
FileSystemAdaptor.prototype.tiddlerFieldsEqual = function(existingFields,newFields) {
|
||||
// Ignore volatile fields that the syncer / server may add
|
||||
var ignore = {revision: 1, bag: 1};
|
||||
var keys = {};
|
||||
$tw.utils.each(existingFields,function(v,k) { if(!ignore[k]) keys[k] = true; });
|
||||
$tw.utils.each(newFields,function(v,k) { if(!ignore[k]) keys[k] = true; });
|
||||
for(var k in keys) {
|
||||
var a = existingFields[k],
|
||||
b = newFields[k];
|
||||
// Normalise arrays to string form
|
||||
if($tw.utils.isArray(a)) a = $tw.utils.stringifyList(a);
|
||||
if($tw.utils.isArray(b)) b = $tw.utils.stringifyList(b);
|
||||
if(a instanceof Date) a = $tw.utils.stringifyDate(a);
|
||||
if(b instanceof Date) b = $tw.utils.stringifyDate(b);
|
||||
if((a === undefined ? "" : String(a)) !== (b === undefined ? "" : String(b))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
if(fs) {
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
/*\
|
||||
title: $:/plugins/tiddlywiki/markdown/frontmatter-deserializer.js
|
||||
type: application/javascript
|
||||
module-type: tiddlerdeserializer
|
||||
|
||||
Markdown deserializer with YAML frontmatter extraction.
|
||||
|
||||
Parses YAML frontmatter delimited by `---` markers and maps extracted
|
||||
values to tiddler fields. Array values on list fields (tags, list, any
|
||||
field with a registered `stringify` method) are converted to TiddlyWiki
|
||||
bracketed lists. Non-string, non-array values are stored as their JSON
|
||||
representation.
|
||||
|
||||
`created` and `modified` in the frontmatter are accepted in either
|
||||
TiddlyWiki's native `YYYYMMDDHHMMSSmmm` UTC format or any ISO-8601
|
||||
string that `Date()` can parse; both are normalised to TW's native
|
||||
format. Values that cannot be parsed are dropped.
|
||||
|
||||
\*/
|
||||
"use strict";
|
||||
|
||||
var yaml = require("$:/plugins/tiddlywiki/markdown/yaml.js");
|
||||
|
||||
function deserialize(text,fields) {
|
||||
var result = Object.create(null),
|
||||
body = text,
|
||||
frontmatter = null;
|
||||
// Copy incoming fields (e.g. from .meta file or filename)
|
||||
for(var f in fields) {
|
||||
result[f] = fields[f];
|
||||
}
|
||||
// Extract YAML frontmatter if present
|
||||
if(text.indexOf("---") === 0) {
|
||||
var endMarker = text.indexOf("\n---",3);
|
||||
if(endMarker !== -1) {
|
||||
var yamlText = text.substring(3,endMarker).trim();
|
||||
// Body starts after the closing --- and its newline
|
||||
var afterMarker = endMarker + 4;
|
||||
if(text[afterMarker] === "\n") {
|
||||
afterMarker++;
|
||||
} else if(text[afterMarker] === "\r" && text[afterMarker + 1] === "\n") {
|
||||
afterMarker += 2;
|
||||
}
|
||||
// Skip one blank line if present (conventional separator between frontmatter and body)
|
||||
if(text[afterMarker] === "\n") {
|
||||
afterMarker++;
|
||||
} else if(text[afterMarker] === "\r" && text[afterMarker + 1] === "\n") {
|
||||
afterMarker += 2;
|
||||
}
|
||||
body = text.substring(afterMarker);
|
||||
try {
|
||||
frontmatter = yaml.load(yamlText);
|
||||
} catch(e) {
|
||||
// If YAML parsing fails, treat the whole text as body
|
||||
body = text;
|
||||
frontmatter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Map frontmatter fields to tiddler fields
|
||||
if(frontmatter && typeof frontmatter === "object" && !Array.isArray(frontmatter)) {
|
||||
var keys = Object.keys(frontmatter);
|
||||
for(var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i],
|
||||
value = frontmatter[key];
|
||||
// Apply field collision policy
|
||||
if(key === "created" || key === "modified") {
|
||||
var normalised = normaliseDate(value);
|
||||
if(normalised !== null) {
|
||||
result[key] = normalised;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if(key === "tags" && result[key]) {
|
||||
// Merge: parse existing tags, add new ones
|
||||
result[key] = mergeTagValue(result[key],value);
|
||||
continue;
|
||||
}
|
||||
result[key] = fieldValueToString(key,value);
|
||||
}
|
||||
}
|
||||
result.text = body;
|
||||
if(!result.type) {
|
||||
result.type = "text/x-markdown";
|
||||
}
|
||||
return [result];
|
||||
}
|
||||
|
||||
// Register under both types — text/x-markdown is the deserializer type
|
||||
// registered for .md file extensions; text/markdown is the raw content type.
|
||||
exports["text/x-markdown"] = deserialize;
|
||||
exports["text/markdown"] = deserialize;
|
||||
|
||||
/*
|
||||
Convert a parsed YAML value to a tiddler field string.
|
||||
- Arrays on list fields (tags, list, etc.) → TW bracketed list format
|
||||
- Strings → as-is
|
||||
- Everything else → JSON
|
||||
*/
|
||||
function fieldValueToString(key,value) {
|
||||
if(value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if(typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if(Array.isArray(value)) {
|
||||
// Check if this field has a stringify method (i.e. it's a list field)
|
||||
if($tw.Tiddler.fieldModules[key] && $tw.Tiddler.fieldModules[key].stringify) {
|
||||
var stringItems = [];
|
||||
for(var i = 0; i < value.length; i++) {
|
||||
stringItems.push(value[i] == null ? "" : String(value[i]));
|
||||
}
|
||||
return $tw.utils.stringifyList(stringItems);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if(typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/*
|
||||
Normalise a frontmatter date value to TiddlyWiki's YYYYMMDDHHMMSSmmm UTC
|
||||
format. Accepts TW native strings (14 or 17 digits, optional leading "-"
|
||||
for negative years) and anything `Date()` can parse (ISO 8601, RFC 2822,
|
||||
Date objects). Returns null if the value cannot be interpreted as a date.
|
||||
*/
|
||||
function normaliseDate(value) {
|
||||
if(value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if(typeof value === "string") {
|
||||
if(/^-?\d{14}$/.test(value)) {
|
||||
return value + "000";
|
||||
}
|
||||
if(/^-?\d{17}$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
var d = new Date(value);
|
||||
if(!isNaN(d.getTime())) {
|
||||
return $tw.utils.stringifyDate(d);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if(value instanceof Date && !isNaN(value.getTime())) {
|
||||
return $tw.utils.stringifyDate(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
Merge incoming tag value with existing tags string.
|
||||
The incoming value may be a string (TW bracketed list) or an array (from YAML).
|
||||
*/
|
||||
function mergeTagValue(existing,incoming) {
|
||||
var existingTags = $tw.utils.parseStringArray(existing) || [];
|
||||
var newTags;
|
||||
if(Array.isArray(incoming)) {
|
||||
newTags = incoming.map(function(t) { return t == null ? "" : String(t); });
|
||||
} else if(typeof incoming === "string") {
|
||||
newTags = $tw.utils.parseStringArray(incoming) || [];
|
||||
} else {
|
||||
return existing;
|
||||
}
|
||||
var seen = Object.create(null);
|
||||
for(var i = 0; i < existingTags.length; i++) {
|
||||
seen[existingTags[i]] = true;
|
||||
}
|
||||
for(var j = 0; j < newTags.length; j++) {
|
||||
if(!seen[newTags[j]]) {
|
||||
existingTags.push(newTags[j]);
|
||||
seen[newTags[j]] = true;
|
||||
}
|
||||
}
|
||||
return $tw.utils.stringifyList(existingTags);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
/*\
|
||||
title: $:/plugins/tiddlywiki/markdown/frontmatter-serializer.js
|
||||
type: application/javascript
|
||||
module-type: tiddlerserializer
|
||||
|
||||
Markdown serializer with YAML frontmatter.
|
||||
|
||||
Inverse of `frontmatter-deserializer.js`. Given a tiddler, returns a
|
||||
Markdown file body whose first lines are a YAML frontmatter block
|
||||
(`---` … `---`), followed by the tiddler's `text` field.
|
||||
|
||||
Field handling:
|
||||
- `title` is always emitted (frontmatter wins over filename when reloaded).
|
||||
- `text` is the body; not emitted in the frontmatter.
|
||||
- `created`, `modified` are emitted as ISO-8601 strings (symmetric with
|
||||
the deserializer, which accepts either ISO-8601 or TW's native format).
|
||||
- `type` is skipped when it equals `text/x-markdown` (the default for `.md` files).
|
||||
- `bag`, `revision` are skipped (sync metadata, not authored content).
|
||||
- List fields (those with a registered `stringify` method) are emitted as YAML arrays.
|
||||
- All other fields are emitted as YAML strings (preserving their on-disk type).
|
||||
|
||||
\*/
|
||||
"use strict";
|
||||
|
||||
var yaml = require("$:/plugins/tiddlywiki/markdown/yaml.js");
|
||||
|
||||
// Field names to skip when emitting frontmatter
|
||||
var SKIP_FIELDS = {
|
||||
text: true,
|
||||
bag: true,
|
||||
revision: true
|
||||
};
|
||||
|
||||
function serialize(tiddler) {
|
||||
if(!tiddler) {
|
||||
return "";
|
||||
}
|
||||
var fields = tiddler.fields || {},
|
||||
frontmatter = Object.create(null);
|
||||
// Always include title first
|
||||
if(fields.title) {
|
||||
frontmatter.title = fields.title;
|
||||
}
|
||||
// Add other fields
|
||||
$tw.utils.each(fields,function(value,name) {
|
||||
if(SKIP_FIELDS[name] || name === "title") {
|
||||
return;
|
||||
}
|
||||
if(name === "type" && value === "text/x-markdown") {
|
||||
return;
|
||||
}
|
||||
if(name === "created" || name === "modified") {
|
||||
var iso = toIsoDate(value);
|
||||
if(iso) {
|
||||
frontmatter[name] = iso;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// List fields → YAML arrays
|
||||
if($tw.Tiddler.fieldModules[name] && $tw.Tiddler.fieldModules[name].stringify) {
|
||||
var items;
|
||||
if(Array.isArray(value)) {
|
||||
items = value.slice();
|
||||
} else {
|
||||
items = $tw.utils.parseStringArray(value || "") || [];
|
||||
}
|
||||
frontmatter[name] = items;
|
||||
} else if(typeof value === "string") {
|
||||
frontmatter[name] = value;
|
||||
} else {
|
||||
// Fallback: stringify whatever it is
|
||||
frontmatter[name] = String(value);
|
||||
}
|
||||
});
|
||||
var body = fields.text || "";
|
||||
var hasFrontmatter = Object.keys(frontmatter).length > 0;
|
||||
if(!hasFrontmatter) {
|
||||
return body;
|
||||
}
|
||||
return "---\n" + yaml.dump(frontmatter) + "\n---\n\n" + body;
|
||||
}
|
||||
|
||||
/*
|
||||
Convert a TiddlyWiki date field value to an ISO-8601 string. Accepts a
|
||||
native `YYYYMMDDHHMMSSmmm` string or a Date. Returns null if the value
|
||||
cannot be parsed.
|
||||
*/
|
||||
function toIsoDate(value) {
|
||||
if(!value) {
|
||||
return null;
|
||||
}
|
||||
var d;
|
||||
if($tw.utils.isDate(value)) {
|
||||
d = value;
|
||||
} else {
|
||||
d = $tw.utils.parseDate(String(value));
|
||||
}
|
||||
if(d && !isNaN(d.getTime())) {
|
||||
return d.toISOString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Register under both types — text/markdown is what the "New Markdown" button
|
||||
// sets; text/x-markdown is what the deserializer uses after content-type
|
||||
// resolution for .md files loaded from disk.
|
||||
exports["text/x-markdown"] = serialize;
|
||||
exports["text/markdown"] = serialize;
|
||||
@@ -1,473 +0,0 @@
|
||||
/*\
|
||||
title: $:/plugins/tiddlywiki/markdown/yaml.js
|
||||
type: application/javascript
|
||||
module-type: library
|
||||
|
||||
Minimal YAML parser for frontmatter extraction.
|
||||
API-compatible subset of js-yaml: load(string) → object, dump(object) → string.
|
||||
Handles scalars, flow/block arrays, and simple nested maps.
|
||||
|
||||
\*/
|
||||
"use strict";
|
||||
|
||||
function YAMLException(message, mark) {
|
||||
this.name = "YAMLException";
|
||||
this.message = message;
|
||||
this.mark = mark || null;
|
||||
}
|
||||
YAMLException.prototype = Object.create(Error.prototype);
|
||||
YAMLException.prototype.constructor = YAMLException;
|
||||
|
||||
// -- Scalar parsing --
|
||||
|
||||
function parseScalar(raw) {
|
||||
if(raw === "" || raw === "null" || raw === "Null" || raw === "NULL" || raw === "~") {
|
||||
return null;
|
||||
}
|
||||
if(raw === "true" || raw === "True" || raw === "TRUE") {
|
||||
return true;
|
||||
}
|
||||
if(raw === "false" || raw === "False" || raw === "FALSE") {
|
||||
return false;
|
||||
}
|
||||
// Quoted strings
|
||||
if((raw[0] === '"' && raw[raw.length - 1] === '"') ||
|
||||
(raw[0] === "'" && raw[raw.length - 1] === "'")) {
|
||||
var inner = raw.slice(1, -1);
|
||||
if(raw[0] === '"') {
|
||||
// Handle basic escape sequences in double-quoted strings.
|
||||
// Use a single pass so each escape consumes its backslash before
|
||||
// later replacements can re-interpret it (e.g. "\\n" must become
|
||||
// backslash + n, not backslash + newline).
|
||||
inner = inner.replace(/\\(.)/g, function(_, c) {
|
||||
switch(c) {
|
||||
case "n": return "\n";
|
||||
case "t": return "\t";
|
||||
case "r": return "\r";
|
||||
case "\\": return "\\";
|
||||
case '"': return '"';
|
||||
default: return c;
|
||||
}
|
||||
});
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
// Numbers: integers and floats
|
||||
if(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(raw)) {
|
||||
var num = Number(raw);
|
||||
if(!isNaN(num)) {
|
||||
return num;
|
||||
}
|
||||
}
|
||||
// Hex integers
|
||||
if(/^0x[0-9a-fA-F]+$/.test(raw)) {
|
||||
return parseInt(raw, 16);
|
||||
}
|
||||
// Octal integers
|
||||
if(/^0o[0-7]+$/.test(raw)) {
|
||||
return parseInt(raw.slice(2), 8);
|
||||
}
|
||||
// Special floats
|
||||
if(raw === ".inf" || raw === ".Inf" || raw === ".INF") {
|
||||
return Infinity;
|
||||
}
|
||||
if(raw === "-.inf" || raw === "-.Inf" || raw === "-.INF") {
|
||||
return -Infinity;
|
||||
}
|
||||
if(raw === ".nan" || raw === ".NaN" || raw === ".NAN") {
|
||||
return NaN;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
// -- Flow sequence parser: [item, item, ...] --
|
||||
|
||||
function parseFlowSequence(str) {
|
||||
// Strip outer brackets and split respecting nested brackets and quotes
|
||||
var inner = str.slice(1, -1).trim();
|
||||
if(inner === "") {
|
||||
return [];
|
||||
}
|
||||
var items = [],
|
||||
current = "",
|
||||
depth = 0,
|
||||
inSingle = false,
|
||||
inDouble = false;
|
||||
for(var i = 0; i < inner.length; i++) {
|
||||
var ch = inner[i];
|
||||
if(ch === "\\" && inDouble) {
|
||||
current += ch + (inner[i + 1] || "");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if(ch === '"' && !inSingle) {
|
||||
inDouble = !inDouble;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if(ch === "'" && !inDouble) {
|
||||
inSingle = !inSingle;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if(!inSingle && !inDouble) {
|
||||
if(ch === "[" || ch === "{") {
|
||||
depth++;
|
||||
} else if(ch === "]" || ch === "}") {
|
||||
depth--;
|
||||
} else if(ch === "," && depth === 0) {
|
||||
items.push(parseScalar(current.trim()));
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if(current.trim() !== "") {
|
||||
items.push(parseScalar(current.trim()));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// -- Flow mapping parser: {key: value, ...} --
|
||||
|
||||
function parseFlowMapping(str) {
|
||||
var inner = str.slice(1, -1).trim();
|
||||
if(inner === "") {
|
||||
return {};
|
||||
}
|
||||
var result = Object.create(null),
|
||||
pairs = [],
|
||||
current = "",
|
||||
depth = 0,
|
||||
inSingle = false,
|
||||
inDouble = false;
|
||||
for(var i = 0; i < inner.length; i++) {
|
||||
var ch = inner[i];
|
||||
if(ch === "\\" && inDouble) {
|
||||
current += ch + (inner[i + 1] || "");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if(ch === '"' && !inSingle) {
|
||||
inDouble = !inDouble;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if(ch === "'" && !inDouble) {
|
||||
inSingle = !inSingle;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if(!inSingle && !inDouble) {
|
||||
if(ch === "[" || ch === "{") {
|
||||
depth++;
|
||||
} else if(ch === "]" || ch === "}") {
|
||||
depth--;
|
||||
} else if(ch === "," && depth === 0) {
|
||||
pairs.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if(current.trim() !== "") {
|
||||
pairs.push(current.trim());
|
||||
}
|
||||
for(var p = 0; p < pairs.length; p++) {
|
||||
var colonIdx = pairs[p].indexOf(":");
|
||||
if(colonIdx !== -1) {
|
||||
var key = pairs[p].slice(0, colonIdx).trim(),
|
||||
val = pairs[p].slice(colonIdx + 1).trim();
|
||||
result[parseScalar(key)] = parseScalar(val);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// -- Block parser (indentation-based) --
|
||||
|
||||
/*
|
||||
Parse block YAML from an array of {indent, raw} line objects.
|
||||
Returns the parsed value (object, array, or scalar).
|
||||
*/
|
||||
function parseBlock(lines, start, baseIndent) {
|
||||
if(start >= lines.length) {
|
||||
return {value: null, nextIndex: start};
|
||||
}
|
||||
var firstLine = lines[start];
|
||||
// Block sequence: lines starting with "- "
|
||||
if(firstLine.raw.indexOf("- ") === 0 || firstLine.raw === "-") {
|
||||
return parseBlockSequence(lines, start, firstLine.indent);
|
||||
}
|
||||
// Block mapping: lines containing ":"
|
||||
if(firstLine.raw.indexOf(":") !== -1) {
|
||||
return parseBlockMapping(lines, start, firstLine.indent);
|
||||
}
|
||||
// Bare scalar
|
||||
return {value: parseScalar(firstLine.raw), nextIndex: start + 1};
|
||||
}
|
||||
|
||||
function parseBlockSequence(lines, start, seqIndent) {
|
||||
var result = [],
|
||||
i = start;
|
||||
while(i < lines.length && lines[i].indent === seqIndent && (lines[i].raw.indexOf("- ") === 0 || lines[i].raw === "-")) {
|
||||
var itemRaw = lines[i].raw.slice(2); // After "- "
|
||||
// Check for inline flow value
|
||||
var trimmed = itemRaw.trim();
|
||||
if(trimmed[0] === "[") {
|
||||
result.push(parseFlowSequence(trimmed));
|
||||
i++;
|
||||
} else if(trimmed[0] === "{") {
|
||||
result.push(parseFlowMapping(trimmed));
|
||||
i++;
|
||||
} else if(trimmed === "" || trimmed === undefined) {
|
||||
// Multi-line block item — collect indented children
|
||||
i++;
|
||||
var childLines = [];
|
||||
while(i < lines.length && lines[i].indent > seqIndent) {
|
||||
childLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if(childLines.length > 0) {
|
||||
var parsed = parseBlock(childLines, 0, childLines[0].indent);
|
||||
result.push(parsed.value);
|
||||
} else {
|
||||
result.push(null);
|
||||
}
|
||||
} else if(trimmed.indexOf(":") !== -1 && !isQuotedColonValue(trimmed)) {
|
||||
// Inline mapping start as sequence item
|
||||
// Collect this line (re-indented) plus any deeper-indented children
|
||||
var mappingLines = [{indent: seqIndent + 2, raw: trimmed}];
|
||||
i++;
|
||||
while(i < lines.length && lines[i].indent > seqIndent) {
|
||||
mappingLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
var parsedMap = parseBlock(mappingLines, 0, mappingLines[0].indent);
|
||||
result.push(parsedMap.value);
|
||||
} else {
|
||||
result.push(parseScalar(trimmed));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return {value: result, nextIndex: i};
|
||||
}
|
||||
|
||||
function isQuotedColonValue(str) {
|
||||
// Check if the colon is inside quotes (meaning it's a scalar, not a mapping)
|
||||
var colonIdx = str.indexOf(":");
|
||||
if(colonIdx === -1) {
|
||||
return false;
|
||||
}
|
||||
// If the value starts with a quote and the colon is inside, it's a quoted scalar
|
||||
if((str[0] === '"' || str[0] === "'") && colonIdx > 0) {
|
||||
var quote = str[0];
|
||||
var closeIdx = str.indexOf(quote, 1);
|
||||
if(closeIdx > colonIdx) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseBlockMapping(lines, start, mapIndent) {
|
||||
var result = Object.create(null),
|
||||
i = start;
|
||||
while(i < lines.length && lines[i].indent === mapIndent) {
|
||||
var line = lines[i].raw,
|
||||
colonIdx = line.indexOf(":");
|
||||
if(colonIdx === -1) {
|
||||
break;
|
||||
}
|
||||
var key = line.slice(0, colonIdx).trim(),
|
||||
valRaw = line.slice(colonIdx + 1).trim();
|
||||
if(valRaw !== "") {
|
||||
// Inline value
|
||||
if(valRaw[0] === "[") {
|
||||
result[key] = parseFlowSequence(valRaw);
|
||||
} else if(valRaw[0] === "{") {
|
||||
result[key] = parseFlowMapping(valRaw);
|
||||
} else {
|
||||
result[key] = parseScalar(valRaw);
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
// Block value on subsequent indented lines
|
||||
i++;
|
||||
var childLines = [];
|
||||
while(i < lines.length && lines[i].indent > mapIndent) {
|
||||
childLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if(childLines.length > 0) {
|
||||
var parsed = parseBlock(childLines, 0, childLines[0].indent);
|
||||
result[key] = parsed.value;
|
||||
} else {
|
||||
result[key] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {value: result, nextIndex: i};
|
||||
}
|
||||
|
||||
// -- Main API --
|
||||
|
||||
/*
|
||||
Parse a YAML string into a JavaScript value.
|
||||
Compatible with js-yaml's load() function.
|
||||
Handles the subset of YAML used in frontmatter:
|
||||
scalars, flow/block arrays, flow/block mappings, nested maps.
|
||||
*/
|
||||
function load(text) {
|
||||
if(typeof text !== "string") {
|
||||
throw new YAMLException("Input must be a string");
|
||||
}
|
||||
text = text.trim();
|
||||
if(text === "") {
|
||||
return null;
|
||||
}
|
||||
// Tokenise into lines with indent tracking
|
||||
var rawLines = text.split(/\r?\n/),
|
||||
lines = [];
|
||||
for(var i = 0; i < rawLines.length; i++) {
|
||||
var raw = rawLines[i];
|
||||
// Skip blank lines and comment-only lines
|
||||
var trimmed = raw.trim();
|
||||
if(trimmed === "" || trimmed[0] === "#") {
|
||||
continue;
|
||||
}
|
||||
var indent = 0;
|
||||
while(indent < raw.length && raw[indent] === " ") {
|
||||
indent++;
|
||||
}
|
||||
lines.push({indent: indent, raw: trimmed});
|
||||
}
|
||||
if(lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Single-line flow values
|
||||
if(lines.length === 1) {
|
||||
var single = lines[0].raw;
|
||||
if(single[0] === "[") {
|
||||
return parseFlowSequence(single);
|
||||
}
|
||||
if(single[0] === "{") {
|
||||
return parseFlowMapping(single);
|
||||
}
|
||||
}
|
||||
var parsed = parseBlock(lines, 0, lines[0].indent);
|
||||
return parsed.value;
|
||||
}
|
||||
|
||||
/*
|
||||
Serialise a JavaScript value to a YAML string.
|
||||
Compatible with js-yaml's dump() function.
|
||||
Handles the subset of YAML used in frontmatter.
|
||||
*/
|
||||
function dump(obj, options) {
|
||||
options = options || {};
|
||||
var indent = options.indent || 2;
|
||||
return dumpValue(obj, 0, indent);
|
||||
}
|
||||
|
||||
function dumpValue(val, level, indentSize) {
|
||||
if(val === null || val === undefined) {
|
||||
return "null";
|
||||
}
|
||||
if(typeof val === "boolean") {
|
||||
return val ? "true" : "false";
|
||||
}
|
||||
if(typeof val === "number") {
|
||||
if(val !== val) { return ".nan"; }
|
||||
if(val === Infinity) { return ".inf"; }
|
||||
if(val === -Infinity) { return "-.inf"; }
|
||||
return String(val);
|
||||
}
|
||||
if(typeof val === "string") {
|
||||
return dumpString(val);
|
||||
}
|
||||
if(Array.isArray(val)) {
|
||||
return dumpArray(val, level, indentSize);
|
||||
}
|
||||
if(typeof val === "object") {
|
||||
return dumpObject(val, level, indentSize);
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function dumpString(str) {
|
||||
// Use plain style if safe, otherwise double-quote
|
||||
if(str === "") {
|
||||
return "''";
|
||||
}
|
||||
if(/^[\w][\w\s\-\.\/]*$/.test(str) &&
|
||||
str !== "true" && str !== "false" && str !== "null" &&
|
||||
str !== "True" && str !== "False" && str !== "Null" &&
|
||||
str !== "TRUE" && str !== "FALSE" && str !== "NULL" &&
|
||||
!/^-?\d/.test(str)) {
|
||||
return str;
|
||||
}
|
||||
// Double-quote with escaping
|
||||
return '"' + str.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\t/g, "\\t") + '"';
|
||||
}
|
||||
|
||||
function dumpArray(arr, level, indentSize) {
|
||||
if(arr.length === 0) {
|
||||
return "[]";
|
||||
}
|
||||
var prefix = repeat(" ", level * indentSize);
|
||||
var lines = [];
|
||||
for(var i = 0; i < arr.length; i++) {
|
||||
var val = dumpValue(arr[i], level + 1, indentSize);
|
||||
if(typeof arr[i] === "object" && arr[i] !== null && !Array.isArray(arr[i])) {
|
||||
// Object items: first key on same line as dash, rest indented
|
||||
var objLines = val.split("\n");
|
||||
lines.push(prefix + "- " + objLines[0]);
|
||||
for(var j = 1; j < objLines.length; j++) {
|
||||
lines.push(prefix + " " + objLines[j]);
|
||||
}
|
||||
} else {
|
||||
lines.push(prefix + "- " + val);
|
||||
}
|
||||
}
|
||||
return "\n" + lines.join("\n");
|
||||
}
|
||||
|
||||
function dumpObject(obj, level, indentSize) {
|
||||
var keys = Object.keys(obj);
|
||||
if(keys.length === 0) {
|
||||
return "{}";
|
||||
}
|
||||
var prefix = repeat(" ", level * indentSize);
|
||||
var lines = [];
|
||||
for(var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i],
|
||||
val = obj[key];
|
||||
var dumpedVal = dumpValue(val, level + 1, indentSize);
|
||||
if((typeof val === "object" && val !== null) &&
|
||||
((Array.isArray(val) && val.length > 0) || (!Array.isArray(val) && Object.keys(val).length > 0))) {
|
||||
lines.push(prefix + dumpString(key) + ":" + dumpedVal);
|
||||
} else {
|
||||
lines.push(prefix + dumpString(key) + ": " + dumpedVal);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function repeat(str, count) {
|
||||
var result = "";
|
||||
for(var i = 0; i < count; i++) {
|
||||
result += str;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
exports.load = load;
|
||||
exports.dump = dump;
|
||||
exports.YAMLException = YAMLException;
|
||||
@@ -0,0 +1,230 @@
|
||||
/*\
|
||||
title: $:/plugins/tiddlywiki/performance/perf-replay-command.js
|
||||
type: application/javascript
|
||||
module-type: command
|
||||
|
||||
Command to replay a recorded performance timeline against the current wiki
|
||||
|
||||
Usage: --perf-replay <timeline.json> [--no-coalesce]
|
||||
|
||||
Loads the wiki (use --load before this command), builds a widget tree
|
||||
using fakeDocument, then replays the recorded store modifications
|
||||
batch by batch, measuring refresh performance for each batch.
|
||||
|
||||
\*/
|
||||
|
||||
"use strict";
|
||||
|
||||
exports.info = {
|
||||
name: "perf-replay",
|
||||
synchronous: false
|
||||
};
|
||||
|
||||
var Command = function(params,commander,callback) {
|
||||
this.params = params;
|
||||
this.commander = commander;
|
||||
this.callback = callback;
|
||||
};
|
||||
|
||||
Command.prototype.execute = function() {
|
||||
var self = this,
|
||||
fs = require("fs"),
|
||||
path = require("path"),
|
||||
wiki = this.commander.wiki,
|
||||
widget = require("$:/core/modules/widgets/widget.js");
|
||||
// Parse parameters
|
||||
if(this.params.length < 1) {
|
||||
return "Missing timeline filename. Usage: --perf-replay <timeline.json>";
|
||||
}
|
||||
var timelinePath = this.params[0];
|
||||
// Load timeline
|
||||
var timelineData;
|
||||
try {
|
||||
timelineData = JSON.parse(fs.readFileSync(timelinePath,"utf8"));
|
||||
} catch(e) {
|
||||
return "Error reading timeline file: " + e.message;
|
||||
}
|
||||
if(!Array.isArray(timelineData) || timelineData.length === 0) {
|
||||
return "Timeline file is empty or invalid";
|
||||
}
|
||||
// Count tiddlers in wiki
|
||||
var tiddlerCount = 0;
|
||||
wiki.each(function() { tiddlerCount++; });
|
||||
// Build a widget tree against fakeDocument (mirroring what render.js does in the browser)
|
||||
var PAGE_TEMPLATE_TITLE = "$:/core/ui/RootTemplate";
|
||||
// Create root widget
|
||||
var rootWidget = new widget.widget({
|
||||
type: "widget",
|
||||
children: []
|
||||
},{
|
||||
wiki: wiki,
|
||||
document: $tw.fakeDocument
|
||||
});
|
||||
// Enable performance instrumentation
|
||||
var perf = new $tw.Performance(true);
|
||||
// Wrap filter execution with perf measurement
|
||||
var origCompileFilter = wiki.compileFilter;
|
||||
var filterInvocations = 0;
|
||||
wiki.compileFilter = function(filterString) {
|
||||
var compiledFilter = origCompileFilter.call(wiki,filterString);
|
||||
return perf.measure("filter: " + filterString.substring(0,80),function(source,widget) {
|
||||
filterInvocations++;
|
||||
return compiledFilter.call(this,source,widget);
|
||||
});
|
||||
};
|
||||
// Re-initialise parsers so filters get wrapped
|
||||
wiki.clearCache(null);
|
||||
// Build and render the page widget tree
|
||||
var pageWidgetNode = wiki.makeTranscludeWidget(PAGE_TEMPLATE_TITLE,{
|
||||
document: $tw.fakeDocument,
|
||||
parentWidget: rootWidget,
|
||||
recursionMarker: "no"
|
||||
});
|
||||
var pageContainer = $tw.fakeDocument.createElement("div");
|
||||
var renderStart = $tw.utils.timer();
|
||||
pageWidgetNode.render(pageContainer,null);
|
||||
var renderTime = $tw.utils.timer(renderStart);
|
||||
// Link root widget
|
||||
rootWidget.domNodes = [pageContainer];
|
||||
rootWidget.children = [pageWidgetNode];
|
||||
// Group timeline events by batch
|
||||
var batches = [];
|
||||
var currentBatch = null;
|
||||
$tw.utils.each(timelineData,function(event) {
|
||||
if(!currentBatch || event.batch !== currentBatch.batchId) {
|
||||
currentBatch = {batchId: event.batch, events: []};
|
||||
batches.push(currentBatch);
|
||||
}
|
||||
currentBatch.events.push(event);
|
||||
});
|
||||
// Replay each batch and measure refresh
|
||||
var results = [],
|
||||
totalRefreshTime = 0,
|
||||
totalFilterInvocations = 0;
|
||||
self.commander.streams.output.write("\nPerformance Timeline Replay\n");
|
||||
self.commander.streams.output.write("==========================\n");
|
||||
self.commander.streams.output.write("Wiki: " + tiddlerCount + " tiddlers\n");
|
||||
self.commander.streams.output.write("Timeline: " + timelineData.length + " operations in " + batches.length + " batches\n");
|
||||
self.commander.streams.output.write("Initial render: " + renderTime.toFixed(2) + "ms\n\n");
|
||||
self.commander.streams.output.write(padRight("Batch",8) + padRight("Ops",6) + padRight("Changed",10) +
|
||||
padRight("Refresh(ms)",14) + padRight("Filters",10) + "Tiddlers Changed\n");
|
||||
self.commander.streams.output.write(padRight("-----",8) + padRight("---",6) + padRight("-------",10) +
|
||||
padRight("-----------",14) + padRight("-------",10) + "----------------\n");
|
||||
$tw.utils.each(batches,function(batch,index) {
|
||||
// Apply all operations in this batch directly (bypassing the intercepted addTiddler
|
||||
// to avoid re-recording)
|
||||
var changedTiddlers = Object.create(null);
|
||||
$tw.utils.each(batch.events,function(event) {
|
||||
if(event.op === "add") {
|
||||
wiki.addTiddler(new $tw.Tiddler(event.fields));
|
||||
changedTiddlers[event.title] = {modified: true};
|
||||
} else if(event.op === "delete") {
|
||||
wiki.deleteTiddler(event.title);
|
||||
changedTiddlers[event.title] = {deleted: true};
|
||||
}
|
||||
});
|
||||
// Measure refresh
|
||||
filterInvocations = 0;
|
||||
var refreshStart = $tw.utils.timer();
|
||||
pageWidgetNode.refresh(changedTiddlers);
|
||||
var refreshTime = $tw.utils.timer(refreshStart);
|
||||
totalRefreshTime += refreshTime;
|
||||
totalFilterInvocations += filterInvocations;
|
||||
var changedTitles = Object.keys(changedTiddlers);
|
||||
var titlesDisplay = changedTitles.slice(0,3).join(", ");
|
||||
if(changedTitles.length > 3) {
|
||||
titlesDisplay += " (+" + (changedTitles.length - 3) + " more)";
|
||||
}
|
||||
results.push({
|
||||
batch: index + 1,
|
||||
ops: batch.events.length,
|
||||
changed: changedTitles.length,
|
||||
refreshMs: refreshTime,
|
||||
filters: filterInvocations,
|
||||
tiddlers: changedTitles
|
||||
});
|
||||
self.commander.streams.output.write(
|
||||
padRight(String(index + 1),8) +
|
||||
padRight(String(batch.events.length),6) +
|
||||
padRight(String(changedTitles.length),10) +
|
||||
padRight(refreshTime.toFixed(2),14) +
|
||||
padRight(String(filterInvocations),10) +
|
||||
titlesDisplay + "\n"
|
||||
);
|
||||
});
|
||||
// Summary statistics
|
||||
var refreshTimes = results.map(function(r) { return r.refreshMs; }).sort(function(a,b) { return a - b; });
|
||||
var p50 = percentile(refreshTimes,50);
|
||||
var p95 = percentile(refreshTimes,95);
|
||||
var p99 = percentile(refreshTimes,99);
|
||||
var maxRefresh = refreshTimes[refreshTimes.length - 1] || 0;
|
||||
var meanRefresh = batches.length > 0 ? totalRefreshTime / batches.length : 0;
|
||||
self.commander.streams.output.write("\nSummary\n");
|
||||
self.commander.streams.output.write("-------\n");
|
||||
self.commander.streams.output.write("Initial render: " + renderTime.toFixed(2) + "ms\n");
|
||||
self.commander.streams.output.write("Total refresh time: " + totalRefreshTime.toFixed(2) + "ms\n");
|
||||
self.commander.streams.output.write("Mean refresh: " + meanRefresh.toFixed(2) + "ms\n");
|
||||
self.commander.streams.output.write("P50 refresh: " + p50.toFixed(2) + "ms\n");
|
||||
self.commander.streams.output.write("P95 refresh: " + p95.toFixed(2) + "ms\n");
|
||||
self.commander.streams.output.write("P99 refresh: " + p99.toFixed(2) + "ms\n");
|
||||
self.commander.streams.output.write("Max refresh: " + maxRefresh.toFixed(2) + "ms\n");
|
||||
self.commander.streams.output.write("Total filters run: " + totalFilterInvocations + "\n");
|
||||
// Output filter breakdown
|
||||
self.commander.streams.output.write("\nTop Filter Execution Times\n");
|
||||
self.commander.streams.output.write("--------------------------\n");
|
||||
var measures = perf.measures;
|
||||
var orderedMeasures = Object.keys(measures).sort(function(a,b) {
|
||||
return measures[b].time - measures[a].time;
|
||||
}).slice(0,20);
|
||||
$tw.utils.each(orderedMeasures,function(name) {
|
||||
var m = measures[name];
|
||||
self.commander.streams.output.write(
|
||||
padRight(m.time.toFixed(2) + "ms",14) +
|
||||
padRight(String(m.invocations) + "x",10) +
|
||||
padRight((m.time / m.invocations).toFixed(3) + "ms avg",16) +
|
||||
name + "\n"
|
||||
);
|
||||
});
|
||||
// Write JSON results
|
||||
var jsonResultPath = timelinePath.replace(/\.json$/,"") + "-results.json";
|
||||
try {
|
||||
fs.writeFileSync(jsonResultPath,JSON.stringify({
|
||||
wiki: {tiddlerCount: tiddlerCount},
|
||||
timeline: {operations: timelineData.length, batches: batches.length},
|
||||
initialRender: renderTime,
|
||||
summary: {
|
||||
totalRefreshTime: totalRefreshTime,
|
||||
meanRefresh: meanRefresh,
|
||||
p50: p50,
|
||||
p95: p95,
|
||||
p99: p99,
|
||||
maxRefresh: maxRefresh,
|
||||
totalFilterInvocations: totalFilterInvocations
|
||||
},
|
||||
batches: results,
|
||||
topFilters: orderedMeasures.map(function(name) {
|
||||
return {name: name, time: measures[name].time, invocations: measures[name].invocations};
|
||||
})
|
||||
},null,"\t"),"utf8");
|
||||
self.commander.streams.output.write("\nDetailed results written to: " + jsonResultPath + "\n");
|
||||
} catch(e) {
|
||||
self.commander.streams.output.write("\nWarning: Could not write results file: " + e.message + "\n");
|
||||
}
|
||||
self.callback(null);
|
||||
return null;
|
||||
};
|
||||
|
||||
function percentile(sortedArray,p) {
|
||||
if(sortedArray.length === 0) return 0;
|
||||
var index = Math.ceil(sortedArray.length * p / 100) - 1;
|
||||
return sortedArray[Math.max(0,index)];
|
||||
}
|
||||
|
||||
function padRight(str,width) {
|
||||
while(str.length < width) {
|
||||
str += " ";
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
exports.Command = Command;
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "$:/plugins/tiddlywiki/performance",
|
||||
"name": "Performance",
|
||||
"description": "Record and replay wiki store modifications for performance testing",
|
||||
"list": "readme ui",
|
||||
"stability": "STABILITY_1_EXPERIMENTAL"
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
title: $:/plugins/tiddlywiki/performance/readme
|
||||
|
||||
! Performance Testing Plugin
|
||||
|
||||
This plugin provides a framework for measuring the performance of TiddlyWiki's refresh cycle — the process that updates the display when tiddlers are modified.
|
||||
|
||||
The idea is to capture a realistic workload by recording store modifications while a user interacts with a wiki in the browser, and then replaying those modifications under Node.js where the refresh cycle can be precisely measured in isolation.
|
||||
|
||||
!! Motivation
|
||||
|
||||
An important motivation for this framework is to enable LLMs to iteratively optimise TiddlyWiki's performance. The workflow is:
|
||||
|
||||
# An LLM makes a change to the TiddlyWiki codebase (e.g. optimising a filter operator, caching a computation, or restructuring a widget's refresh logic)
|
||||
# The LLM runs `--perf-replay` against a recorded timeline to measure the impact
|
||||
# The LLM reads the JSON results file to determine whether the change improved, regressed, or had no effect on performance
|
||||
# The LLM iterates: tries another approach, measures again, and converges on the best solution
|
||||
|
||||
This tight edit-measure-iterate loop works because `--perf-replay` runs entirely under Node.js with no browser required, produces machine-readable JSON output, and completes in seconds.
|
||||
|
||||
!! How It Works
|
||||
|
||||
The framework has two parts:
|
||||
|
||||
!!! 1. Recording (Browser)
|
||||
|
||||
The plugin intercepts `wiki.addTiddler()` and `wiki.deleteTiddler()` to capture every store modification as it happens. Each operation is recorded with:
|
||||
|
||||
* A sequence number and high-resolution timestamp
|
||||
* The full tiddler fields (so the exact state can be recreated)
|
||||
* A batch identifier that tracks TiddlyWiki's change batching via `$tw.utils.nextTick()`
|
||||
|
||||
The batch tracking is important because TiddlyWiki groups multiple store changes that occur in the same tick into a single refresh cycle. The recorder preserves these batch boundaries so that playback triggers the same pattern of refreshes.
|
||||
|
||||
!!! 2. Playback (Node.js)
|
||||
|
||||
The `--perf-replay` command loads a wiki and builds the full widget tree using TiddlyWiki's `$tw.fakeDocument` — the lightweight DOM implementation used for server-side rendering. It then replays the recorded timeline batch by batch, calling `widgetNode.refresh(changedTiddlers)` after each batch and measuring how long it takes.
|
||||
|
||||
This means we are measuring TiddlyWiki's own refresh logic (widget tree traversal, filter evaluation, DOM diffing) in isolation from browser layout and paint. This is intentional — it lets us identify performance bottlenecks within TiddlyWiki itself, independent of which browser is being used.
|
||||
|
||||
!! Why Store-Level Recording?
|
||||
|
||||
An alternative would be to record DOM events (clicks, keystrokes) and replay them in a headless browser. Store-level recording was chosen instead because:
|
||||
|
||||
* The refresh cycle responds to ''store changes'', not DOM events — store modifications are the natural input
|
||||
* Store changes are fully deterministic and reproducible
|
||||
* No DOM dependency means playback works in pure Node.js with no headless browser to install
|
||||
* A headless browser would add its own overhead, making measurements less precise
|
||||
|
||||
!! Recording
|
||||
|
||||
# Include this plugin in your wiki
|
||||
# Open the Control Panel and find the "Performance Testing Recorder" tab
|
||||
# Click "Start Recording"
|
||||
# Interact with the wiki — open tiddlers, edit, type, navigate, switch tabs
|
||||
# Click "Stop Recording"
|
||||
# Download the `timeline.json` file
|
||||
|
||||
!!! Draft Coalescing
|
||||
|
||||
When editing a tiddler, TiddlyWiki writes to draft tiddlers on every keystroke. By default, the recorder coalesces rapid draft updates within the same batch, keeping only the last update. This produces a more compact timeline that focuses on the refresh-relevant changes.
|
||||
|
||||
Uncheck "Coalesce rapid draft updates" to record every individual keystroke. This is useful when you specifically want to measure the performance impact of rapid typing.
|
||||
|
||||
!! Playback
|
||||
|
||||
```
|
||||
tiddlywiki editions/performance --load mywiki.html --perf-replay timeline.json
|
||||
```
|
||||
|
||||
Or from any edition that includes this plugin:
|
||||
|
||||
```
|
||||
tiddlywiki myedition --perf-replay timeline.json
|
||||
```
|
||||
|
||||
Playback runs at full speed with no delays between batches. The recorded timestamps are preserved in the timeline for reference but are not used for pacing.
|
||||
|
||||
!! What Gets Measured
|
||||
|
||||
* ''Initial render time'' — the time to build and render the full widget tree from scratch
|
||||
* ''Refresh time per batch'' — the time `widgetNode.refresh(changedTiddlers)` takes for each batch of store modifications
|
||||
* ''Filter execution'' — individual filter timings and invocation counts, showing which filters are the most expensive
|
||||
* ''Statistical summary'' — mean, P50, P95, P99, and maximum refresh times across all batches
|
||||
|
||||
!! Output
|
||||
|
||||
The command produces two forms of output:
|
||||
|
||||
!!! Text Report (stdout)
|
||||
|
||||
A human-readable table printed to the console showing per-batch timings, a summary with percentile statistics, and a breakdown of the most expensive filter executions.
|
||||
|
||||
!!! JSON Results File
|
||||
|
||||
A `<timeline-name>-results.json` file is written alongside the input timeline. This is the primary output for automated consumption. The file contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"wiki": {
|
||||
"tiddlerCount": 2076
|
||||
},
|
||||
"timeline": {
|
||||
"operations": 156,
|
||||
"batches": 42
|
||||
},
|
||||
"initialRender": 55.46,
|
||||
"summary": {
|
||||
"totalRefreshTime": 234.5,
|
||||
"meanRefresh": 5.58,
|
||||
"p50": 4.12,
|
||||
"p95": 18.7,
|
||||
"p99": 31.2,
|
||||
"maxRefresh": 31.2,
|
||||
"totalFilterInvocations": 4821
|
||||
},
|
||||
"batches": [
|
||||
{
|
||||
"batch": 1,
|
||||
"ops": 1,
|
||||
"changed": 1,
|
||||
"refreshMs": 12.3,
|
||||
"filters": 293,
|
||||
"tiddlers": ["$:/StoryList"]
|
||||
}
|
||||
],
|
||||
"topFilters": [
|
||||
{
|
||||
"name": "filter: [subfilter{$:/core/config/GlobalImportFilter}]",
|
||||
"time": 5.65,
|
||||
"invocations": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
All times are in milliseconds. The key fields for automated analysis:
|
||||
|
||||
* `summary.totalRefreshTime` — the single most important number: total time spent in refresh across all batches
|
||||
* `summary.meanRefresh` — average refresh time per batch
|
||||
* `summary.p95` / `summary.p99` — tail latency indicators
|
||||
* `initialRender` — time to build the widget tree from scratch (measures startup cost)
|
||||
* `batches[].refreshMs` — per-batch breakdown, useful for identifying which user actions are expensive
|
||||
* `topFilters[]` — the most expensive filters by total execution time, useful for identifying optimisation targets
|
||||
|
||||
!! Example: LLM Optimisation Workflow
|
||||
|
||||
An LLM optimising TiddlyWiki performance would follow this pattern:
|
||||
|
||||
!!! Step 1: Establish baseline
|
||||
|
||||
```
|
||||
node ./tiddlywiki.js editions/performance --load mywiki.html --perf-replay timeline.json
|
||||
```
|
||||
|
||||
Read `timeline-results.json` and note the baseline `summary.totalRefreshTime`.
|
||||
|
||||
!!! Step 2: Make a change
|
||||
|
||||
Edit a source file (e.g. optimise a filter operator in `core/modules/filters/`).
|
||||
|
||||
!!! Step 3: Measure impact
|
||||
|
||||
Run the same `--perf-replay` command again and read the new `timeline-results.json`.
|
||||
|
||||
!!! Step 4: Compare
|
||||
|
||||
Compare `summary.totalRefreshTime` and `summary.p95` between baseline and new results. If improved, keep the change. If regressed, revert and try a different approach.
|
||||
|
||||
!!! Step 5: Iterate
|
||||
|
||||
Repeat steps 2-4 until the target metric is optimised.
|
||||
|
||||
The JSON results file makes step 4 straightforward — an LLM can read two JSON files and compare numeric fields directly without parsing tabular text output.
|
||||
|
||||
!! Timeline Format
|
||||
|
||||
The timeline is a JSON array of operations:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"seq": 0,
|
||||
"t": 123.45,
|
||||
"batch": 0,
|
||||
"op": "add",
|
||||
"title": "$:/StoryList",
|
||||
"isDraft": false,
|
||||
"fields": {
|
||||
"title": "$:/StoryList",
|
||||
"list": "GettingStarted",
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
* `seq` — sequential operation number
|
||||
* `t` — milliseconds since recording started
|
||||
* `batch` — batch identifier (operations in the same batch trigger a single refresh)
|
||||
* `op` — `"add"` or `"delete"`
|
||||
* `isDraft` — whether this is a draft tiddler (used for coalescing)
|
||||
* `fields` — complete tiddler fields (null for delete operations)
|
||||
@@ -0,0 +1,143 @@
|
||||
/*\
|
||||
title: $:/plugins/tiddlywiki/performance/recorder.js
|
||||
type: application/javascript
|
||||
module-type: startup
|
||||
|
||||
Store modification recorder for performance testing.
|
||||
Intercepts wiki.addTiddler() and wiki.deleteTiddler() to capture
|
||||
a timeline of all store modifications with batch boundary tracking.
|
||||
|
||||
\*/
|
||||
|
||||
"use strict";
|
||||
|
||||
exports.name = "perf-recorder";
|
||||
exports.platforms = ["browser"];
|
||||
exports.after = ["load-modules"];
|
||||
exports.synchronous = true;
|
||||
|
||||
exports.startup = function() {
|
||||
var STATE_TIDDLER = "$:/state/performance/recording",
|
||||
TIMELINE_TIDDLER = "$:/temp/performance/timeline",
|
||||
COALESCE_CONFIG = "$:/config/performance/coalesce-drafts",
|
||||
timeline = [],
|
||||
seq = 0,
|
||||
startTime = null,
|
||||
recording = false,
|
||||
batchId = 0,
|
||||
currentBatch = 0,
|
||||
origNextTick = $tw.utils.nextTick,
|
||||
origAddTiddler = $tw.wiki.addTiddler.bind($tw.wiki),
|
||||
origDeleteTiddler = $tw.wiki.deleteTiddler.bind($tw.wiki);
|
||||
|
||||
// Patch nextTick to track batch boundaries
|
||||
$tw.utils.nextTick = function(fn) {
|
||||
origNextTick(function() {
|
||||
if(recording) {
|
||||
currentBatch = ++batchId;
|
||||
}
|
||||
fn();
|
||||
});
|
||||
};
|
||||
|
||||
// Patch addTiddler
|
||||
$tw.wiki.addTiddler = function(tiddler) {
|
||||
if(recording) {
|
||||
if(!(tiddler instanceof $tw.Tiddler)) {
|
||||
tiddler = new $tw.Tiddler(tiddler);
|
||||
}
|
||||
var title = tiddler.fields.title;
|
||||
// Skip our own state/timeline tiddlers
|
||||
if(title !== STATE_TIDDLER && title !== TIMELINE_TIDDLER) {
|
||||
timeline.push({
|
||||
seq: seq++,
|
||||
t: $tw.utils.timer(startTime),
|
||||
batch: currentBatch,
|
||||
op: "add",
|
||||
title: title,
|
||||
isDraft: tiddler.hasField("draft.of"),
|
||||
fields: tiddler.getFieldStrings()
|
||||
});
|
||||
}
|
||||
}
|
||||
return origAddTiddler.apply(null,arguments);
|
||||
};
|
||||
|
||||
// Patch deleteTiddler
|
||||
$tw.wiki.deleteTiddler = function(title) {
|
||||
if(recording) {
|
||||
if(title !== STATE_TIDDLER && title !== TIMELINE_TIDDLER) {
|
||||
timeline.push({
|
||||
seq: seq++,
|
||||
t: $tw.utils.timer(startTime),
|
||||
batch: currentBatch,
|
||||
op: "delete",
|
||||
title: title,
|
||||
isDraft: false,
|
||||
fields: null
|
||||
});
|
||||
}
|
||||
}
|
||||
return origDeleteTiddler.apply(null,arguments);
|
||||
};
|
||||
|
||||
// Listen for recording state changes
|
||||
$tw.wiki.addEventListener("change",function(changes) {
|
||||
if(STATE_TIDDLER in changes) {
|
||||
var state = $tw.wiki.getTiddlerText(STATE_TIDDLER,"").trim();
|
||||
if(state === "yes" && !recording) {
|
||||
// Start recording
|
||||
timeline = [];
|
||||
seq = 0;
|
||||
batchId = 0;
|
||||
currentBatch = 0;
|
||||
startTime = $tw.utils.timer();
|
||||
recording = true;
|
||||
console.log("performance: Recording started");
|
||||
} else if(state !== "yes" && recording) {
|
||||
// Stop recording and save timeline
|
||||
recording = false;
|
||||
var coalesce = $tw.wiki.getTiddlerText(COALESCE_CONFIG,"yes").trim() === "yes";
|
||||
var output = coalesce ? coalesceDrafts(timeline) : timeline;
|
||||
origAddTiddler(new $tw.Tiddler({
|
||||
title: TIMELINE_TIDDLER,
|
||||
type: "application/json",
|
||||
text: JSON.stringify(output,null,"\t")
|
||||
}));
|
||||
console.log("performance: Recording stopped. " + timeline.length + " operations captured" +
|
||||
(coalesce ? " (" + output.length + " after coalescing drafts)" : ""));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
Coalesce rapid draft tiddler updates within the same batch.
|
||||
Keeps only the last update for each draft tiddler per batch.
|
||||
*/
|
||||
function coalesceDrafts(events) {
|
||||
var result = [],
|
||||
i = 0;
|
||||
while(i < events.length) {
|
||||
var event = events[i];
|
||||
if(event.isDraft && event.op === "add") {
|
||||
// Look ahead for later updates to this same draft in the same batch
|
||||
var lastIndex = i;
|
||||
for(var j = i + 1; j < events.length; j++) {
|
||||
if(events[j].batch !== event.batch) {
|
||||
break;
|
||||
}
|
||||
if(events[j].title === event.title && events[j].op === "add") {
|
||||
lastIndex = j;
|
||||
}
|
||||
}
|
||||
// Keep only the last one, but fix its seq to maintain ordering
|
||||
result.push(events[lastIndex]);
|
||||
i = lastIndex + 1;
|
||||
} else {
|
||||
result.push(event);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
title: $:/plugins/tiddlywiki/performance/ui
|
||||
caption: Recorder
|
||||
|
||||
! Performance Testing Recorder
|
||||
|
||||
<$let
|
||||
state="$:/state/performance/recording"
|
||||
timeline="$:/temp/performance/timeline"
|
||||
coalesceConfig="$:/config/performance/coalesce-drafts"
|
||||
>
|
||||
|
||||
!! Recording
|
||||
|
||||
<$reveal state=<<state>> type="nomatch" text="yes">
|
||||
<$button set=<<state>> setTo="yes" class="tc-btn-big-green">Start Recording</$button>
|
||||
</$reveal>
|
||||
|
||||
<$reveal state=<<state>> type="match" text="yes">
|
||||
<$button set=<<state>> setTo="no" class="tc-btn-big-green" style="background: #d33;">Stop Recording</$button>
|
||||
//Recording in progress...//
|
||||
</$reveal>
|
||||
|
||||
!! Options
|
||||
|
||||
<$checkbox tiddler=<<coalesceConfig>> field="text" checked="yes" unchecked="no" default="yes"> Coalesce rapid draft updates</$checkbox>
|
||||
|
||||
!! Timeline
|
||||
|
||||
<$reveal type="nomatch" state=<<timeline>> text="">
|
||||
|
||||
<$let timelineText={{$(timeline)$}}>
|
||||
<$vars count={{{ [<timeline>get[text]jsonextract[]] +[count[]] }}}>
|
||||
Timeline contains <<count>> operations.
|
||||
</$vars>
|
||||
|
||||
<$button>
|
||||
<$action-sendmessage $message="tm-download-file" $param=<<timeline>> filename="timeline.json"/>
|
||||
Download timeline.json
|
||||
</$button>
|
||||
|
||||
</$let>
|
||||
</$reveal>
|
||||
|
||||
<$reveal type="match" state=<<timeline>> text="">
|
||||
No timeline recorded yet. Click "Start Recording", interact with the wiki, then click "Stop Recording".
|
||||
</$reveal>
|
||||
|
||||
</$let>
|
||||
Reference in New Issue
Block a user