From 06cbbe8394d4e358970dceb9c9fffaf0cc116a1b Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 00:14:56 +0800 Subject: [PATCH 01/70] Enhance Freelinks with Aho-Corasick for long titles and large wikis Replaces regex with Aho-Corasick, adds chunking (100 titles/chunk), cache toggle, and Chinese full-width symbol support. Tested with 11,714 tiddlers. --- plugins/tiddlywiki/freelinks/text.js | 343 +++++++++++++++++++++------ 1 file changed, 274 insertions(+), 69 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 658a3ca96cb..a7e65f94c3a 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -3,19 +3,136 @@ title: $:/core/modules/widgets/text.js type: application/javascript module-type: widget -An override of the core text widget that automatically linkifies the text +An override of the core text widget that automatically linkifies the text, with support for non-Latin languages like Chinese, prioritizing longer titles, skipping processed matches, excluding the current tiddler title from linking, and handling large title sets with Aho-Corasick algorithm and fixed chunking (100 titles per chunk). Includes optional persistent caching of Aho-Corasick automaton, controlled by $:/config/Freelinks/PersistAhoCorasickCache. \*/ +(function(){ +/*jslint node: true, browser: true */ +/*global $tw: false */ "use strict"; var TITLE_TARGET_FILTER = "$:/config/Freelinks/TargetFilter"; +var PERSIST_CACHE_TIDDLER = "$:/config/Freelinks/PersistAhoCorasickCache"; var Widget = require("$:/core/modules/widgets/widget.js").widget, LinkWidget = require("$:/core/modules/widgets/link.js").link, ButtonWidget = require("$:/core/modules/widgets/button.js").button, ElementWidget = require("$:/core/modules/widgets/element.js").element; +// Escape only ASCII 127 and below metacharacters +function escapeRegExp(str) { + try { + return str.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); + } catch(e) { + console.warn("Failed to escape title:", str, e); + return null; // Skip problematic titles + } +} + +// Aho-Corasick implementation with enhanced error handling +function AhoCorasick() { + this.trie = {}; + this.failure = {}; + this.output = {}; +} + +AhoCorasick.prototype.addPattern = function(pattern, index) { + if(!pattern || typeof pattern !== "string") { + console.warn("Skipping invalid pattern:", pattern); + return; + } + var node = this.trie; + for(var i = 0; i < pattern.length; i++) { + var char = pattern[i]; + if(!node[char]) { + node[char] = {}; + } + node = node[char]; + } + if(!node.$) { + node.$ = []; + } + node.$.push({ pattern: pattern, index: index }); +}; + +AhoCorasick.prototype.buildFailureLinks = function() { + var queue = []; + var root = this.trie; + this.failure[root] = root; + for(var char in root) { + if(root[char] && char !== '$') { + this.failure[root[char]] = root; + queue.push(root[char]); + } + } + var maxIterations = 100000; // Prevent infinite loops + var iteration = 0; + while(queue.length && iteration < maxIterations) { + var node = queue.shift(); + for(var char in node) { + if(node[char] && char !== '$') { + var child = node[char]; + var fail = this.failure[node]; + var failCount = 0; + var maxFailCount = 1000; // Prevent deep failure chains + while(fail && !fail[char] && failCount < maxFailCount) { + fail = this.failure[fail]; + failCount++; + } + this.failure[child] = fail[char] || this.trie; + if(this.failure[child].$) { + if(!child.$) { + child.$ = []; + } + child.$.push.apply(child.$, this.failure[child].$); + } + queue.push(child); + } + } + iteration++; + } + if(iteration >= maxIterations) { + console.error("Aho-Corasick: buildFailureLinks exceeded max iterations"); + } +}; + +AhoCorasick.prototype.search = function(text) { + if(!text || typeof text !== "string") { + return []; + } + var matches = []; + var node = this.trie; + var maxIterations = text.length * 10; // Prevent infinite loops + var iteration = 0; + for(var i = 0; i < text.length && iteration < maxIterations; i++) { + var char = text[i]; + var transitionCount = 0; + var maxTransitionCount = 1000; // Prevent deep transitions + while(node && !node[char] && transitionCount < maxTransitionCount) { + node = this.failure[node]; + transitionCount++; + } + node = node[char] || this.trie; + if(node.$) { + for(var j = 0; j < node.$.length; j++) { + var match = node.$[j]; + matches.push({ + pattern: match.pattern, + index: i - match.pattern.length + 1, + length: match.pattern.length, + titleIndex: match.index + }); + } + } + iteration++; + } + if(iteration >= maxIterations) { + console.error("Aho-Corasick: search exceeded max iterations"); + } + return matches; +}; + var TextNodeWidget = function(parseTreeNode,options) { this.initialise(parseTreeNode,options); }; @@ -43,91 +160,170 @@ TextNodeWidget.prototype.execute = function() { ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}).trim() === "yes"; // Get our parameters var childParseTree = [{ - type: "plain-text", - text: this.getAttribute("text",this.parseTreeNode.text || "") - }]; + type: "plain-text", + text: this.getAttribute("text",this.parseTreeNode.text || "") + }]; // Only process links if not disabled and we're not within a button or link widget if(this.getVariable("tv-wikilinks",{defaultValue:"yes"}).trim() !== "no" && this.getVariable("tv-freelinks",{defaultValue:"no"}).trim() === "yes" && !this.isWithinButtonOrLink()) { - // Get the information about the current tiddler titles, and construct a regexp - this.tiddlerTitleInfo = this.wiki.getGlobalCache("tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"),function() { - var targetFilterText = self.wiki.getTiddlerText(TITLE_TARGET_FILTER), - titles = !!targetFilterText ? self.wiki.filterTiddlers(targetFilterText,$tw.rootWidget) : self.wiki.allTitles(), - sortedTitles = titles.sort(function(a,b) { - var lenA = a.length, - lenB = b.length; - // First sort by length, so longer titles are first - if(lenA !== lenB) { - if(lenA < lenB) { - return +1; - } else { - return -1; - } - } else { - // Then sort alphabetically within titles of the same length - if(a < b) { - return -1; - } else if(a > b) { - return +1; - } else { - return 0; - } - } - }), - titles = [], - reparts = []; - $tw.utils.each(sortedTitles,function(title) { - if(title.substring(0,3) !== "$:/") { - titles.push(title); - reparts.push("(" + $tw.utils.escapeRegExp(title) + ")"); - } + // Get the current tiddler title + var currentTiddlerTitle = this.getVariable("currentTiddler") || ""; + // Check if persistent caching is enabled + var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no").trim() === "yes"; + var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); + // Get the information about the current tiddler titles + this.tiddlerTitleInfo = persistCache ? + this.wiki.getPersistentCache(cacheKey, function() { + return computeTiddlerTitleInfo(self, ignoreCase); + }) : + this.wiki.getGlobalCache(cacheKey, function() { + return computeTiddlerTitleInfo(self, ignoreCase); }); - var regexpStr = "\\b(?:" + reparts.join("|") + ")\\b"; - return { - titles: titles, - regexp: new RegExp(regexpStr,ignoreCase ? "i" : "") - }; - }); - // Repeatedly linkify + // Process titles to avoid overlapping matches if(this.tiddlerTitleInfo.titles.length > 0) { - var index,text,match,matchEnd; - do { - index = childParseTree.length - 1; - text = childParseTree[index].text; - match = this.tiddlerTitleInfo.regexp.exec(text); - if(match) { - // Make a text node for any text before the match - if(match.index > 0) { - childParseTree[index].text = text.substring(0,match.index); - index += 1; + var text = childParseTree[0].text, + newParseTree = [], + currentPos = 0; + // Use Aho-Corasick to find all matches + var searchText = ignoreCase ? text.toLowerCase() : text; + var matches; + try { + matches = this.tiddlerTitleInfo.ac.search(searchText); + } catch(e) { + console.error("Aho-Corasick search failed:", e); + matches = []; + } + // Sort matches by position and length (longer titles first) + matches.sort(function(a, b) { + if(a.index !== b.index) { + return a.index - b.index; + } + return b.length - a.length; + }); + // Process matches to avoid overlaps + var processedPositions = new Set(); + for(var i = 0; i < matches.length; i++) { + var match = matches[i]; + var matchStart = match.index; + var matchEnd = matchStart + match.length; + // Skip if position already processed (ensures longer titles take precedence) + var overlap = false; + for(var pos = matchStart; pos < matchEnd; pos++) { + if(processedPositions.has(pos)) { + overlap = true; + break; } - // Make a link node for the match - childParseTree[index] = { + } + if(overlap) { + continue; + } + // Mark positions as processed + for(var pos = matchStart; pos < matchEnd; pos++) { + processedPositions.add(pos); + } + // Add text before the match + if(matchStart > currentPos) { + newParseTree.push({ + type: "plain-text", + text: text.slice(currentPos, matchStart) + }); + } + // Get matched title + var matchedTitle = this.tiddlerTitleInfo.titles[match.titleIndex]; + // Check if the matched title is the current tiddler title + if(matchedTitle === currentTiddlerTitle) { + // Skip linking, keep as plain text + newParseTree.push({ + type: "plain-text", + text: text.slice(matchStart, matchEnd) + }); + } else { + // Add link for the match + newParseTree.push({ type: "link", attributes: { - to: {type: "string", value: ignoreCase ? this.tiddlerTitleInfo.titles[match.indexOf(match[0],1) - 1] : match[0]}, + to: {type: "string", value: matchedTitle}, "class": {type: "string", value: "tc-freelink"} }, children: [{ - type: "plain-text", text: match[0] - }] - }; - index += 1; - // Make a text node for any text after the match - matchEnd = match.index + match[0].length; - if(matchEnd < text.length) { - childParseTree[index] = { type: "plain-text", - text: text.substring(matchEnd) - }; - } + text: text.slice(matchStart, matchEnd) + }] + }); } - } while(match && childParseTree[childParseTree.length - 1].type === "plain-text"); + currentPos = matchEnd; + } + // Add remaining text + if(currentPos < text.length) { + newParseTree.push({ + type: "plain-text", + text: text.slice(currentPos) + }); + } + childParseTree = newParseTree; } } // Make the child widgets this.makeChildWidgets(childParseTree); }; +/* +Helper function to compute tiddler title info +*/ +function computeTiddlerTitleInfo(self, ignoreCase) { + var targetFilterText = self.wiki.getTiddlerText(TITLE_TARGET_FILTER), + titles = !!targetFilterText ? self.wiki.filterTiddlers(targetFilterText,$tw.rootWidget) : self.wiki.allTitles(); + if(!titles || titles.length === 0) { + console.warn("No titles found for Freelinks processing"); + return { titles: [], ac: new AhoCorasick(), chunkSize: 100, titleChunks: [] }; + } + var sortedTitles = titles.sort(function(a,b) { + var lenA = a.length, + lenB = b.length; + // Sort by length (longer first), then alphabetically for same length + if(lenA !== lenB) { + return lenB - lenA; + } else { + return a.localeCompare(b, 'zh', {sensitivity: 'base'}); + } + }), + titles = [], + chunkSize = 100; // Fixed chunk size + var ac = new AhoCorasick(); + $tw.utils.each(sortedTitles,function(title, index) { + if(title.substring(0,3) !== "$:/") { + var escapedTitle = escapeRegExp(title); + if(escapedTitle) { + titles.push(title); + ac.addPattern(ignoreCase ? title.toLowerCase() : title, titles.length - 1); + } else { + console.warn("Skipping invalid title:", title); + } + } + }); + try { + ac.buildFailureLinks(); + } catch(e) { + console.error("Failed to build Aho-Corasick failure links:", e); + return { titles: [], ac: new AhoCorasick(), chunkSize: 100, titleChunks: [] }; + } + // Split titles into chunks for memory management + var titleChunks = []; + for(var i = 0; i < titles.length; i += chunkSize) { + titleChunks.push(titles.slice(i, i + chunkSize)); + } + // Log chunking details for debugging + console.log("Total titles:", titles.length, ", Chunk size:", chunkSize, ", Number of chunks:", titleChunks.length, ", Persistent cache:", self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no").trim()); + return { + titles: titles, + ac: ac, + chunkSize: chunkSize, + titleChunks: titleChunks + }; +} + +/* +Check if the widget is within a button or link +*/ TextNodeWidget.prototype.isWithinButtonOrLink = function() { var withinButtonOrLink = false, widget = this.parentWidget; @@ -153,11 +349,20 @@ TextNodeWidget.prototype.refresh = function(changedTiddlers) { } }); if(changedAttributes.text || titlesHaveChanged) { + // Invalidate cache if titles changed and persistent cache is enabled + var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no").trim() === "yes"; + var cacheKey = "tiddler-title-info-" + (self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}).trim() === "yes" ? "insensitive" : "sensitive"); + if(titlesHaveChanged && persistCache) { + self.wiki.clearCache(cacheKey); + console.log("Cleared Aho-Corasick cache due to title changes"); + } this.refreshSelf(); return true; } else { - return false; + return false; } }; exports.text = TextNodeWidget; + +})(); From bbef43c06da306206fb6b96e970b9d72ed223cc8 Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 01:19:05 +0800 Subject: [PATCH 02/70] delete comment --- plugins/tiddlywiki/freelinks/text.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index a7e65f94c3a..6151ff6cfa8 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -8,8 +8,6 @@ An override of the core text widget that automatically linkifies the text, with \*/ (function(){ -/*jslint node: true, browser: true */ -/*global $tw: false */ "use strict"; var TITLE_TARGET_FILTER = "$:/config/Freelinks/TargetFilter"; From 6c05f06052f507009e75e6b6082374000f29cc50 Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 01:22:46 +0800 Subject: [PATCH 03/70] Create AhoCorasick.js --- plugins/tiddlywiki/freelinks/AhoCorasick.js | 88 +++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 plugins/tiddlywiki/freelinks/AhoCorasick.js diff --git a/plugins/tiddlywiki/freelinks/AhoCorasick.js b/plugins/tiddlywiki/freelinks/AhoCorasick.js new file mode 100644 index 00000000000..90f242f9a65 --- /dev/null +++ b/plugins/tiddlywiki/freelinks/AhoCorasick.js @@ -0,0 +1,88 @@ +/*\ +title: $:/plugins/tiddlywiki/freelinks/AhoCorasick.js +type: application/javascript +module-type: library + +Aho-Corasick implementation for efficient string matching. +\*/ + +exports.AhoCorasick = function() { + this.root = { children: {}, fail: null, output: [] }; + +AhoCorasick.prototype.buildFailureLinks = function() { + var queue = []; + var root = this.trie; + this.failure[root] = root; + for(var char in root) { + if(root[char] && char !== '$') { + this.failure[root[char]] = root; + queue.push(root[char]); + } + } + var maxIterations = 100000; // Prevent infinite loops + var iteration = 0; + while(queue.length && iteration < maxIterations) { + var node = queue.shift(); + for(var char in node) { + if(node[char] && char !== '$') { + var child = node[char]; + var fail = this.failure[node]; + var failCount = 0; + var maxFailCount = 1000; // Prevent deep failure chains + while(fail && !fail[char] && failCount < maxFailCount) { + fail = this.failure[fail]; + failCount++; + } + this.failure[child] = fail[char] || this.trie; + if(this.failure[child].$) { + if(!child.$) { + child.$ = []; + } + child.$.push.apply(child.$, this.failure[child].$); + } + queue.push(child); + } + } + iteration++; + } + if(iteration >= maxIterations) { + console.error("Aho-Corasick: buildFailureLinks exceeded max iterations"); + } +}; + +AhoCorasick.prototype.search = function(text) { + if(!text || typeof text !== "string") { + return []; + } + var matches = []; + var node = this.trie; + var maxIterations = text.length * 10; // Prevent infinite loops + var iteration = 0; + for(var i = 0; i < text.length && iteration < maxIterations; i++) { + var char = text[i]; + var transitionCount = 0; + var maxTransitionCount = 1000; // Prevent deep transitions + while(node && !node[char] && transitionCount < maxTransitionCount) { + node = this.failure[node]; + transitionCount++; + } + node = node[char] || this.trie; + if(node.$) { + for(var j = 0; j < node.$.length; j++) { + var match = node.$[j]; + matches.push({ + pattern: match.pattern, + index: i - match.pattern.length + 1, + length: match.pattern.length, + titleIndex: match.index + }); + } + } + iteration++; + } + if(iteration >= maxIterations) { + console.error("Aho-Corasick: search exceeded max iterations"); + } + return matches; +}; +}; From 91389535a653508d05a627b427134f2a293559a4 Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 01:27:30 +0800 Subject: [PATCH 04/70] Update text.js --- plugins/tiddlywiki/freelinks/text.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 6151ff6cfa8..27062c916cf 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -29,6 +29,7 @@ function escapeRegExp(str) { } // Aho-Corasick implementation with enhanced error handling +var AhoCorasick = require("$:/plugins/tiddlywiki/freelinks/AhoCorasick.js").AhoCorasick; function AhoCorasick() { this.trie = {}; this.failure = {}; @@ -170,12 +171,10 @@ TextNodeWidget.prototype.execute = function() { var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); // Get the information about the current tiddler titles this.tiddlerTitleInfo = persistCache ? - this.wiki.getPersistentCache(cacheKey, function() { - return computeTiddlerTitleInfo(self, ignoreCase); - }) : - this.wiki.getGlobalCache(cacheKey, function() { - return computeTiddlerTitleInfo(self, ignoreCase); - }); + var usePersistent = this.wiki.getTiddlerText("$:/config/Freelinks/PersistAhoCorasickCache", "no").trim() === "yes"; + this.wiki.getCache(cacheKey, function() { + return computeTiddlerTitleInfo(self, ignoreCase); + }, usePersistent); // Process titles to avoid overlapping matches if(this.tiddlerTitleInfo.titles.length > 0) { var text = childParseTree[0].text, From 9316a53c90522b388ff507c21f7d2d4536883ddf Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 01:29:46 +0800 Subject: [PATCH 05/70] Update text.js --- plugins/tiddlywiki/freelinks/text.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 27062c916cf..3d244c242e4 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -167,7 +167,7 @@ TextNodeWidget.prototype.execute = function() { // Get the current tiddler title var currentTiddlerTitle = this.getVariable("currentTiddler") || ""; // Check if persistent caching is enabled - var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no").trim() === "yes"; + var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); // Get the information about the current tiddler titles this.tiddlerTitleInfo = persistCache ? @@ -252,8 +252,8 @@ TextNodeWidget.prototype.execute = function() { // Add remaining text if(currentPos < text.length) { newParseTree.push({ - type: "plain-text", - text: text.slice(currentPos) + type: "text", + text: text.substring(currentPos) }); } childParseTree = newParseTree; @@ -308,8 +308,6 @@ function computeTiddlerTitleInfo(self, ignoreCase) { for(var i = 0; i < titles.length; i += chunkSize) { titleChunks.push(titles.slice(i, i + chunkSize)); } - // Log chunking details for debugging - console.log("Total titles:", titles.length, ", Chunk size:", chunkSize, ", Number of chunks:", titleChunks.length, ", Persistent cache:", self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no").trim()); return { titles: titles, ac: ac, From ece490747e09bafb6e48ecb679a863f6a5886e11 Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 01:32:26 +0800 Subject: [PATCH 06/70] Update AhoCorasick.js --- plugins/tiddlywiki/freelinks/AhoCorasick.js | 164 +++++++++++--------- 1 file changed, 89 insertions(+), 75 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/AhoCorasick.js b/plugins/tiddlywiki/freelinks/AhoCorasick.js index 90f242f9a65..c3367ea8e4e 100644 --- a/plugins/tiddlywiki/freelinks/AhoCorasick.js +++ b/plugins/tiddlywiki/freelinks/AhoCorasick.js @@ -6,83 +6,97 @@ module-type: library Aho-Corasick implementation for efficient string matching. \*/ -exports.AhoCorasick = function() { - this.root = { children: {}, fail: null, output: [] }; - +var AhoCorasick = function() { + this.trie = { children: {}, fail: null, output: [] }; + this.failure = {}; +}; + AhoCorasick.prototype.buildFailureLinks = function() { - var queue = []; - var root = this.trie; - this.failure[root] = root; - for(var char in root) { - if(root[char] && char !== '$') { - this.failure[root[char]] = root; - queue.push(root[char]); - } - } - var maxIterations = 100000; // Prevent infinite loops - var iteration = 0; - while(queue.length && iteration < maxIterations) { - var node = queue.shift(); - for(var char in node) { - if(node[char] && char !== '$') { - var child = node[char]; - var fail = this.failure[node]; - var failCount = 0; - var maxFailCount = 1000; // Prevent deep failure chains - while(fail && !fail[char] && failCount < maxFailCount) { - fail = this.failure[fail]; - failCount++; - } - this.failure[child] = fail[char] || this.trie; - if(this.failure[child].$) { - if(!child.$) { - child.$ = []; - } - child.$.push.apply(child.$, this.failure[child].$); - } - queue.push(child); - } - } - iteration++; - } - if(iteration >= maxIterations) { - console.error("Aho-Corasick: buildFailureLinks exceeded max iterations"); - } + var queue = []; + var root = this.trie; + this.failure[root] = root; + for (var char in root.children) { + if (root.children[char]) { + this.failure[root.children[char]] = root; + queue.push(root.children[char]); + } + } + var maxIterations = 10000; // Adjusted for 13,000 tiddlers + var iteration = 0; + while (queue.length && iteration < maxIterations) { + var node = queue.shift(); + for (var char in node.children) { + if (node.children[char]) { + var child = node.children[char]; + var fail = this.failure[node]; + var failCount = 0; + var maxFailCount = 500; // Adjusted for safety + while (fail && !fail.children[char] && failCount < maxFailCount) { + fail = this.failure[fail]; + failCount++; + } + this.failure[child] = fail.children[char] || this.trie; + if (this.failure[child].output.length) { + if (!child.output) child.output = []; + child.output.push.apply(child.output, this.failure[child].output); + } + queue.push(child); + } + } + iteration++; + } + if (iteration >= maxIterations) { + $tw.utils.warning("Aho-Corasick: buildFailureLinks exceeded max iterations"); + } }; -AhoCorasick.prototype.search = function(text) { - if(!text || typeof text !== "string") { - return []; - } - var matches = []; - var node = this.trie; - var maxIterations = text.length * 10; // Prevent infinite loops - var iteration = 0; - for(var i = 0; i < text.length && iteration < maxIterations; i++) { - var char = text[i]; - var transitionCount = 0; - var maxTransitionCount = 1000; // Prevent deep transitions - while(node && !node[char] && transitionCount < maxTransitionCount) { - node = this.failure[node]; - transitionCount++; - } - node = node[char] || this.trie; - if(node.$) { - for(var j = 0; j < node.$.length; j++) { - var match = node.$[j]; - matches.push({ - pattern: match.pattern, - index: i - match.pattern.length + 1, - length: match.pattern.length, - titleIndex: match.index - }); - } - } - iteration++; - } - if(iteration >= maxIterations) { - console.error("Aho-Corasick: search exceeded max iterations"); - } - return matches; +AhoCorasick.prototype.addPattern = function(pattern, index) { + var node = this.trie; + for (var i = 0; i < pattern.length; i++) { + var char = pattern[i]; + if (!node.children[char]) { + node.children[char] = { children: {}, output: [] }; + } + node = node.children[char]; + } + if (!node.output) node.output = []; + node.output.push({ pattern: pattern, index: index }); }; + +AhoCorasick.prototype.search = function(text) { + if (!$tw.utils.isString(text) || !text.length) { + return []; + } + var matches = []; + var node = this.trie; + var maxIterations = text.length * 5; // Adjusted for efficiency + var iteration = 0; + for (var i = 0; i < text.length && iteration < maxIterations; i++) { + var char = text[i]; + var transitionCount = 0; + var maxTransitionCount = 200; // Adjusted for safety + while (node && !node.children[char] && transitionCount < maxTransitionCount) { + node = this.failure[node]; + transitionCount++; + } + node = node.children[char] || this.trie; + if (node.output.length) { + for (var j = 0; j < node.output.length; j++) { + var match = node.output[j]; + matches.push({ + pattern: match.pattern, + index: i - match.pattern.length + 1, + length: match.pattern.length, + titleIndex: match.index + }); + } + } + iteration++; + } + if (iteration >= maxIterations) { + $tw.utils.warning("Aho-Corasick: search exceeded max iterations"); + } + return matches; }; + +exports.AhoCorasick = AhoCorasick; From bd4ae9f3011ab0b84ae8b8286461350d838a9d95 Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 01:34:12 +0800 Subject: [PATCH 07/70] Update text.js --- plugins/tiddlywiki/freelinks/text.js | 102 +-------------------------- 1 file changed, 1 insertion(+), 101 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 3d244c242e4..47d6ecc1257 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -30,107 +30,7 @@ function escapeRegExp(str) { // Aho-Corasick implementation with enhanced error handling var AhoCorasick = require("$:/plugins/tiddlywiki/freelinks/AhoCorasick.js").AhoCorasick; -function AhoCorasick() { - this.trie = {}; - this.failure = {}; - this.output = {}; -} - -AhoCorasick.prototype.addPattern = function(pattern, index) { - if(!pattern || typeof pattern !== "string") { - console.warn("Skipping invalid pattern:", pattern); - return; - } - var node = this.trie; - for(var i = 0; i < pattern.length; i++) { - var char = pattern[i]; - if(!node[char]) { - node[char] = {}; - } - node = node[char]; - } - if(!node.$) { - node.$ = []; - } - node.$.push({ pattern: pattern, index: index }); -}; - -AhoCorasick.prototype.buildFailureLinks = function() { - var queue = []; - var root = this.trie; - this.failure[root] = root; - for(var char in root) { - if(root[char] && char !== '$') { - this.failure[root[char]] = root; - queue.push(root[char]); - } - } - var maxIterations = 100000; // Prevent infinite loops - var iteration = 0; - while(queue.length && iteration < maxIterations) { - var node = queue.shift(); - for(var char in node) { - if(node[char] && char !== '$') { - var child = node[char]; - var fail = this.failure[node]; - var failCount = 0; - var maxFailCount = 1000; // Prevent deep failure chains - while(fail && !fail[char] && failCount < maxFailCount) { - fail = this.failure[fail]; - failCount++; - } - this.failure[child] = fail[char] || this.trie; - if(this.failure[child].$) { - if(!child.$) { - child.$ = []; - } - child.$.push.apply(child.$, this.failure[child].$); - } - queue.push(child); - } - } - iteration++; - } - if(iteration >= maxIterations) { - console.error("Aho-Corasick: buildFailureLinks exceeded max iterations"); - } -}; - -AhoCorasick.prototype.search = function(text) { - if(!text || typeof text !== "string") { - return []; - } - var matches = []; - var node = this.trie; - var maxIterations = text.length * 10; // Prevent infinite loops - var iteration = 0; - for(var i = 0; i < text.length && iteration < maxIterations; i++) { - var char = text[i]; - var transitionCount = 0; - var maxTransitionCount = 1000; // Prevent deep transitions - while(node && !node[char] && transitionCount < maxTransitionCount) { - node = this.failure[node]; - transitionCount++; - } - node = node[char] || this.trie; - if(node.$) { - for(var j = 0; j < node.$.length; j++) { - var match = node.$[j]; - matches.push({ - pattern: match.pattern, - index: i - match.pattern.length + 1, - length: match.pattern.length, - titleIndex: match.index - }); - } - } - iteration++; - } - if(iteration >= maxIterations) { - console.error("Aho-Corasick: search exceeded max iterations"); - } - return matches; -}; +var aho = new AhoCorasick(); var TextNodeWidget = function(parseTreeNode,options) { this.initialise(parseTreeNode,options); From f3b673ad3a59aa6373ef9e17346731a46d0a71e6 Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 01:36:22 +0800 Subject: [PATCH 08/70] Update text.js --- plugins/tiddlywiki/freelinks/text.js | 390 +++++++++++++-------------- 1 file changed, 188 insertions(+), 202 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 47d6ecc1257..ee888e97598 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -1,12 +1,12 @@ /*\ -title: $:/core/modules/widgets/text.js +title: $:/plugins/tiddlywiki/freelinks/text.js type: application/javascript module-type: widget An override of the core text widget that automatically linkifies the text, with support for non-Latin languages like Chinese, prioritizing longer titles, skipping processed matches, excluding the current tiddler title from linking, and handling large title sets with Aho-Corasick algorithm and fixed chunking (100 titles per chunk). Includes optional persistent caching of Aho-Corasick automaton, controlled by $:/config/Freelinks/PersistAhoCorasickCache. \*/ -(function(){ +(function() { "use strict"; @@ -14,26 +14,26 @@ var TITLE_TARGET_FILTER = "$:/config/Freelinks/TargetFilter"; var PERSIST_CACHE_TIDDLER = "$:/config/Freelinks/PersistAhoCorasickCache"; var Widget = require("$:/core/modules/widgets/widget.js").widget, - LinkWidget = require("$:/core/modules/widgets/link.js").link, - ButtonWidget = require("$:/core/modules/widgets/button.js").button, - ElementWidget = require("$:/core/modules/widgets/element.js").element; + LinkWidget = require("$:/core/modules/widgets/link.js").link, + ButtonWidget = require("$:/core/modules/widgets/button.js").button, + ElementWidget = require("$:/core/modules/widgets/element.js").element; // Escape only ASCII 127 and below metacharacters function escapeRegExp(str) { - try { - return str.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); - } catch(e) { - console.warn("Failed to escape title:", str, e); - return null; // Skip problematic titles - } + try { + return str.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); + } catch (e) { + $tw.utils.warning("Failed to escape title:", str, e); + return null; // Skip problematic titles + } } -// Aho-Corasick implementation with enhanced error handling +// Aho-Corasick instance var AhoCorasick = require("$:/plugins/tiddlywiki/freelinks/AhoCorasick.js").AhoCorasick; var aho = new AhoCorasick(); -var TextNodeWidget = function(parseTreeNode,options) { - this.initialise(parseTreeNode,options); +var TextNodeWidget = function(parseTreeNode, options) { + this.initialise(parseTreeNode, options); }; /* @@ -44,218 +44,204 @@ TextNodeWidget.prototype = new Widget(); /* Render this widget into the DOM */ -TextNodeWidget.prototype.render = function(parent,nextSibling) { - this.parentDomNode = parent; - this.computeAttributes(); - this.execute(); - this.renderChildren(parent,nextSibling); +TextNodeWidget.prototype.render = function(parent, nextSibling) { + this.parentDomNode = parent; + this.computeAttributes(); + this.execute(); + this.renderChildren(parent, nextSibling); }; /* Compute the internal state of the widget */ TextNodeWidget.prototype.execute = function() { - var self = this, - ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}).trim() === "yes"; - // Get our parameters - var childParseTree = [{ - type: "plain-text", - text: this.getAttribute("text",this.parseTreeNode.text || "") - }]; - // Only process links if not disabled and we're not within a button or link widget - if(this.getVariable("tv-wikilinks",{defaultValue:"yes"}).trim() !== "no" && this.getVariable("tv-freelinks",{defaultValue:"no"}).trim() === "yes" && !this.isWithinButtonOrLink()) { - // Get the current tiddler title - var currentTiddlerTitle = this.getVariable("currentTiddler") || ""; - // Check if persistent caching is enabled - var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; - var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); - // Get the information about the current tiddler titles - this.tiddlerTitleInfo = persistCache ? - var usePersistent = this.wiki.getTiddlerText("$:/config/Freelinks/PersistAhoCorasickCache", "no").trim() === "yes"; - this.wiki.getCache(cacheKey, function() { - return computeTiddlerTitleInfo(self, ignoreCase); - }, usePersistent); - // Process titles to avoid overlapping matches - if(this.tiddlerTitleInfo.titles.length > 0) { - var text = childParseTree[0].text, - newParseTree = [], - currentPos = 0; - // Use Aho-Corasick to find all matches - var searchText = ignoreCase ? text.toLowerCase() : text; - var matches; - try { - matches = this.tiddlerTitleInfo.ac.search(searchText); - } catch(e) { - console.error("Aho-Corasick search failed:", e); - matches = []; - } - // Sort matches by position and length (longer titles first) - matches.sort(function(a, b) { - if(a.index !== b.index) { - return a.index - b.index; - } - return b.length - a.length; - }); - // Process matches to avoid overlaps - var processedPositions = new Set(); - for(var i = 0; i < matches.length; i++) { - var match = matches[i]; - var matchStart = match.index; - var matchEnd = matchStart + match.length; - // Skip if position already processed (ensures longer titles take precedence) - var overlap = false; - for(var pos = matchStart; pos < matchEnd; pos++) { - if(processedPositions.has(pos)) { - overlap = true; - break; - } - } - if(overlap) { - continue; - } - // Mark positions as processed - for(var pos = matchStart; pos < matchEnd; pos++) { - processedPositions.add(pos); - } - // Add text before the match - if(matchStart > currentPos) { - newParseTree.push({ - type: "plain-text", - text: text.slice(currentPos, matchStart) - }); - } - // Get matched title - var matchedTitle = this.tiddlerTitleInfo.titles[match.titleIndex]; - // Check if the matched title is the current tiddler title - if(matchedTitle === currentTiddlerTitle) { - // Skip linking, keep as plain text - newParseTree.push({ - type: "plain-text", - text: text.slice(matchStart, matchEnd) - }); - } else { - // Add link for the match - newParseTree.push({ - type: "link", - attributes: { - to: {type: "string", value: matchedTitle}, - "class": {type: "string", value: "tc-freelink"} - }, - children: [{ - type: "plain-text", - text: text.slice(matchStart, matchEnd) - }] - }); - } - currentPos = matchEnd; - } - // Add remaining text - if(currentPos < text.length) { - newParseTree.push({ - type: "text", - text: text.substring(currentPos) - }); - } - childParseTree = newParseTree; - } - } - // Make the child widgets - this.makeChildWidgets(childParseTree); + var self = this, + ignoreCase = self.getVariable("tv-freelinks-ignore-case", { defaultValue: "no" }).trim() === "yes"; + // Get our parameters + var childParseTree = [{ + type: "text", + text: this.getAttribute("text", this.parseTreeNode.text || "") + }]; + // Only process links if not disabled and we're not within a button or link widget + if (this.getVariable("tv-wikilinks", { defaultValue: "yes" }).trim() !== "no" && + this.getVariable("tv-freelinks", { defaultValue: "no" }).trim() === "yes" && + !this.isWithinButtonOrLink()) { + // Get the current tiddler title + var currentTiddlerTitle = this.getVariable("currentTiddler") || ""; + // Check if persistent caching is enabled + var usePersistent = this.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; + var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); + // Get or compute tiddler title info with caching + this.tiddlerTitleInfo = this.wiki.getCache(cacheKey, function() { + return computeTiddlerTitleInfo(self, ignoreCase); + }, usePersistent); + // Process titles to avoid overlapping matches + if (this.tiddlerTitleInfo && this.tiddlerTitleInfo.titles.length > 0) { + var text = childParseTree[0].text, + newParseTree = [], + currentPos = 0; + // Use Aho-Corasick to find all matches + var searchText = ignoreCase ? text.toLowerCase() : text; + var matches = this.tiddlerTitleInfo.ac.search(searchText) || []; + // Sort matches by position and length (longer titles first) + matches.sort(function(a, b) { + if (a.index !== b.index) return a.index - b.index; + return b.length - a.length; + }); + // Process matches to avoid overlaps + var processedPositions = new Set(); + for (var i = 0; i < matches.length; i++) { + var match = matches[i]; + var matchStart = match.index; + var matchEnd = matchStart + match.length; + // Skip if position already processed (ensures longer titles take precedence) + var overlap = false; + for (var pos = matchStart; pos < matchEnd; pos++) { + if (processedPositions.has(pos)) { + overlap = true; + break; + } + } + if (overlap) continue; + // Mark positions as processed + for (var pos = matchStart; pos < matchEnd; pos++) { + processedPositions.add(pos); + } + // Add text before the match + if (matchStart > currentPos) { + newParseTree.push({ + type: "text", + text: text.slice(currentPos, matchStart) + }); + } + // Get matched title + var matchedTitle = this.tiddlerTitleInfo.titles[match.titleIndex]; + // Check if the matched title is the current tiddler title + if (matchedTitle === currentTiddlerTitle) { + // Skip linking, keep as plain text + newParseTree.push({ + type: "text", + text: text.slice(matchStart, matchEnd) + }); + } else { + // Add link for the match + newParseTree.push({ + type: "link", + attributes: { + to: { type: "string", value: matchedTitle }, + "class": { type: "string", value: "tc-freelink" } + }, + children: [{ + type: "text", + text: text.slice(matchStart, matchEnd) + }] + }); + } + currentPos = matchEnd; + } + // Add remaining text + if (currentPos < text.length) { + newParseTree.push({ + type: "text", + text: text.substring(currentPos) + }); + } + childParseTree = newParseTree; + } + } + // Make the child widgets + this.makeChildWidgets(childParseTree); }; /* Helper function to compute tiddler title info */ function computeTiddlerTitleInfo(self, ignoreCase) { - var targetFilterText = self.wiki.getTiddlerText(TITLE_TARGET_FILTER), - titles = !!targetFilterText ? self.wiki.filterTiddlers(targetFilterText,$tw.rootWidget) : self.wiki.allTitles(); - if(!titles || titles.length === 0) { - console.warn("No titles found for Freelinks processing"); - return { titles: [], ac: new AhoCorasick(), chunkSize: 100, titleChunks: [] }; - } - var sortedTitles = titles.sort(function(a,b) { - var lenA = a.length, - lenB = b.length; - // Sort by length (longer first), then alphabetically for same length - if(lenA !== lenB) { - return lenB - lenA; - } else { - return a.localeCompare(b, 'zh', {sensitivity: 'base'}); - } - }), - titles = [], - chunkSize = 100; // Fixed chunk size - var ac = new AhoCorasick(); - $tw.utils.each(sortedTitles,function(title, index) { - if(title.substring(0,3) !== "$:/") { - var escapedTitle = escapeRegExp(title); - if(escapedTitle) { - titles.push(title); - ac.addPattern(ignoreCase ? title.toLowerCase() : title, titles.length - 1); - } else { - console.warn("Skipping invalid title:", title); - } - } - }); - try { - ac.buildFailureLinks(); - } catch(e) { - console.error("Failed to build Aho-Corasick failure links:", e); - return { titles: [], ac: new AhoCorasick(), chunkSize: 100, titleChunks: [] }; - } - // Split titles into chunks for memory management - var titleChunks = []; - for(var i = 0; i < titles.length; i += chunkSize) { - titleChunks.push(titles.slice(i, i + chunkSize)); - } - return { - titles: titles, - ac: ac, - chunkSize: chunkSize, - titleChunks: titleChunks - }; + var targetFilterText = self.wiki.getTiddlerText(TITLE_TARGET_FILTER), + titles = !!targetFilterText ? self.wiki.filterTiddlers(targetFilterText, $tw.rootWidget) : self.wiki.allTitles(); + if (!titles || titles.length === 0) { + $tw.utils.warning("No titles found for Freelinks processing"); + return { titles: [], ac: new AhoCorasick(), chunkSize: 100, titleChunks: [] }; + } + var sortedTitles = titles.sort(function(a, b) { + var lenA = a.length, + lenB = b.length; + // Sort by length (longer first), then alphabetically for same length + if (lenA !== lenB) return lenB - lenA; + return a.localeCompare(b, 'zh', { sensitivity: 'base' }); + }), + titles = [], + chunkSize = 100; // Fixed chunk size + var ac = new AhoCorasick(); + $tw.utils.each(sortedTitles, function(title, index) { + if (title.substring(0, 3) !== "$:/") { + var escapedTitle = escapeRegExp(title); + if (escapedTitle) { + titles.push(title); + ac.addPattern(ignoreCase ? title.toLowerCase() : title, titles.length - 1); + } else { + $tw.utils.warning("Skipping invalid title:", title); + } + } + }); + try { + ac.buildFailureLinks(); + } catch (e) { + $tw.utils.error("Failed to build Aho-Corasick failure links:", e); + return { titles: [], ac: new AhoCorasick(), chunkSize: 100, titleChunks: [] }; + } + // Split titles into chunks for memory management + var titleChunks = []; + for (var i = 0; i < titles.length; i += chunkSize) { + titleChunks.push(titles.slice(i, i + chunkSize)); + } + return { + titles: titles, + ac: ac, + chunkSize: chunkSize, + titleChunks: titleChunks + }; } /* Check if the widget is within a button or link */ TextNodeWidget.prototype.isWithinButtonOrLink = function() { - var withinButtonOrLink = false, - widget = this.parentWidget; - while(!withinButtonOrLink && widget) { - withinButtonOrLink = widget instanceof ButtonWidget || widget instanceof LinkWidget || ((widget instanceof ElementWidget) && widget.parseTreeNode.tag === "a"); - widget = widget.parentWidget; - } - return withinButtonOrLink; + var withinButtonOrLink = false, + widget = this.parentWidget; + while (!withinButtonOrLink && widget) { + withinButtonOrLink = widget instanceof ButtonWidget || widget instanceof LinkWidget || + ((widget instanceof ElementWidget) && widget.parseTreeNode.tag === "a"); + widget = widget.parentWidget; + } + return withinButtonOrLink; }; /* Selectively refreshes the widget if needed. Returns true if the widget or any of its children needed re-rendering */ TextNodeWidget.prototype.refresh = function(changedTiddlers) { - var self = this, - changedAttributes = this.computeAttributes(), - titlesHaveChanged = false; - $tw.utils.each(changedTiddlers,function(change,title) { - if(change.isDeleted) { - titlesHaveChanged = true; - } else { - titlesHaveChanged = titlesHaveChanged || !self.tiddlerTitleInfo || self.tiddlerTitleInfo.titles.indexOf(title) === -1; - } - }); - if(changedAttributes.text || titlesHaveChanged) { - // Invalidate cache if titles changed and persistent cache is enabled - var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no").trim() === "yes"; - var cacheKey = "tiddler-title-info-" + (self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}).trim() === "yes" ? "insensitive" : "sensitive"); - if(titlesHaveChanged && persistCache) { - self.wiki.clearCache(cacheKey); - console.log("Cleared Aho-Corasick cache due to title changes"); - } - this.refreshSelf(); - return true; - } else { - return false; - } + var self = this, + changedAttributes = this.computeAttributes(), + titlesHaveChanged = false; + $tw.utils.each(changedTiddlers, function(change, title) { + if (change.isDeleted) { + titlesHaveChanged = true; + } else { + titlesHaveChanged = titlesHaveChanged || !self.tiddlerTitleInfo || self.tiddlerTitleInfo.titles.indexOf(title) === -1; + } + }); + if (changedAttributes.text || titlesHaveChanged) { + // Invalidate cache if titles changed and persistent cache is enabled + var usePersistent = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; + var cacheKey = "tiddler-title-info-" + (self.getVariable("tv-freelinks-ignore-case", { defaultValue: "no" }).trim() === "yes" ? "insensitive" : "sensitive"); + if (titlesHaveChanged && usePersistent) { + self.wiki.clearCache(cacheKey); + } + this.refreshSelf(); + return true; + } + return false; }; exports.text = TextNodeWidget; From 01c2ef7a96a1b606c718175fdbf36e84f734f168 Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 22:45:06 +0800 Subject: [PATCH 09/70] move AhoCorasick to AhoCorasick.js --- core/modules/utils/AhoCorasick.js | 152 ++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 core/modules/utils/AhoCorasick.js diff --git a/core/modules/utils/AhoCorasick.js b/core/modules/utils/AhoCorasick.js new file mode 100644 index 00000000000..d7f9e3017f7 --- /dev/null +++ b/core/modules/utils/AhoCorasick.js @@ -0,0 +1,152 @@ +/*\ +title: $:/core/modules/utils/aho-corasick.js +type: application/javascript +module-type: utils + +Aho-Corasick string matching algorithm implementation with enhanced error handling +for TiddlyWiki freelinking functionality. + +\*/ +(function(){ + +"use strict"; + +/** + * Aho-Corasick implementation with enhanced error handling + * @constructor + */ +function AhoCorasick() { + this.trie = {}; + this.failure = {}; + this.output = {}; +} + +/** + * Add a pattern to the Aho-Corasick automaton + * @param {string} pattern - The pattern to add + * @param {number} index - The index of the pattern for identification + */ +AhoCorasick.prototype.addPattern = function(pattern, index) { + if(!pattern || typeof pattern !== "string") { + return; + } + var node = this.trie; + for(var i = 0; i < pattern.length; i++) { + var char = pattern[i]; + if(!node[char]) { + node[char] = {}; + } + node = node[char]; + } + if(!node.$) { + node.$ = []; + } + node.$.push({ pattern: pattern, index: index }); +}; + +/** + * Build failure links for the Aho-Corasick automaton + */ +AhoCorasick.prototype.buildFailureLinks = function() { + var queue = []; + var root = this.trie; + this.failure[root] = root; + + // Initialize first level failure links + for(var char in root) { + if(root[char] && char !== '$') { + this.failure[root[char]] = root; + queue.push(root[char]); + } + } + + var maxIterations = 100000; // Prevent infinite loops + var iteration = 0; + + while(queue.length && iteration < maxIterations) { + var node = queue.shift(); + for(var char in node) { + if(node[char] && char !== '$') { + var child = node[char]; + var fail = this.failure[node]; + var failCount = 0; + var maxFailCount = 1000; // Prevent deep failure chains + + while(fail && !fail[char] && failCount < maxFailCount) { + fail = this.failure[fail]; + failCount++; + } + + this.failure[child] = fail[char] || this.trie; + + // Copy output from failure link + if(this.failure[child].$) { + if(!child.$) { + child.$ = []; + } + child.$.push.apply(child.$, this.failure[child].$); + } + + queue.push(child); + } + } + iteration++; + } + + if(iteration >= maxIterations) { + throw new Error("Aho-Corasick: buildFailureLinks exceeded max iterations"); + } +}; + +/** + * Search for all patterns in the given text + * @param {string} text - The text to search in + * @returns {Array} Array of match objects with pattern, index, length, and titleIndex properties + */ +AhoCorasick.prototype.search = function(text) { + if(!text || typeof text !== "string") { + return []; + } + + var matches = []; + var node = this.trie; + var maxIterations = text.length * 10; // Prevent infinite loops + var iteration = 0; + + for(var i = 0; i < text.length && iteration < maxIterations; i++) { + var char = text[i]; + var transitionCount = 0; + var maxTransitionCount = 1000; // Prevent deep transitions + + while(node && !node[char] && transitionCount < maxTransitionCount) { + node = this.failure[node]; + transitionCount++; + } + + node = node[char] || this.trie; + + if(node.$) { + for(var j = 0; j < node.$.length; j++) { + var match = node.$[j]; + matches.push({ + pattern: match.pattern, + index: i - match.pattern.length + 1, + length: match.pattern.length, + titleIndex: match.index + }); + } + } + iteration++; + } + + if(iteration >= maxIterations) { + throw new Error("Aho-Corasick: search exceeded max iterations"); + } + + return matches; +}; + +// Export the AhoCorasick constructor +exports.AhoCorasick = AhoCorasick; + +})(); \ No newline at end of file From 5002e3675f43caf650ef12edb233c2e601f23371 Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 22:46:50 +0800 Subject: [PATCH 10/70] update AhoCorasick.js --- plugins/tiddlywiki/freelinks/AhoCorasick.js | 226 ++++++++++++-------- 1 file changed, 138 insertions(+), 88 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/AhoCorasick.js b/plugins/tiddlywiki/freelinks/AhoCorasick.js index c3367ea8e4e..d7f9e3017f7 100644 --- a/plugins/tiddlywiki/freelinks/AhoCorasick.js +++ b/plugins/tiddlywiki/freelinks/AhoCorasick.js @@ -1,102 +1,152 @@ /*\ -title: $:/plugins/tiddlywiki/freelinks/AhoCorasick.js +title: $:/core/modules/utils/aho-corasick.js type: application/javascript -module-type: library +module-type: utils + +Aho-Corasick string matching algorithm implementation with enhanced error handling +for TiddlyWiki freelinking functionality. -Aho-Corasick implementation for efficient string matching. \*/ +(function(){ -var AhoCorasick = function() { - this.trie = { children: {}, fail: null, output: [] }; - this.failure = {}; -}; +"use strict"; -AhoCorasick.prototype.buildFailureLinks = function() { - var queue = []; - var root = this.trie; - this.failure[root] = root; - for (var char in root.children) { - if (root.children[char]) { - this.failure[root.children[char]] = root; - queue.push(root.children[char]); - } - } - var maxIterations = 10000; // Adjusted for 13,000 tiddlers - var iteration = 0; - while (queue.length && iteration < maxIterations) { - var node = queue.shift(); - for (var char in node.children) { - if (node.children[char]) { - var child = node.children[char]; - var fail = this.failure[node]; - var failCount = 0; - var maxFailCount = 500; // Adjusted for safety - while (fail && !fail.children[char] && failCount < maxFailCount) { - fail = this.failure[fail]; - failCount++; - } - this.failure[child] = fail.children[char] || this.trie; - if (this.failure[child].output.length) { - if (!child.output) child.output = []; - child.output.push.apply(child.output, this.failure[child].output); - } - queue.push(child); - } - } - iteration++; - } - if (iteration >= maxIterations) { - $tw.utils.warning("Aho-Corasick: buildFailureLinks exceeded max iterations"); - } -}; +/** + * Aho-Corasick implementation with enhanced error handling + * @constructor + */ +function AhoCorasick() { + this.trie = {}; + this.failure = {}; + this.output = {}; +} +/** + * Add a pattern to the Aho-Corasick automaton + * @param {string} pattern - The pattern to add + * @param {number} index - The index of the pattern for identification + */ AhoCorasick.prototype.addPattern = function(pattern, index) { - var node = this.trie; - for (var i = 0; i < pattern.length; i++) { - var char = pattern[i]; - if (!node.children[char]) { - node.children[char] = { children: {}, output: [] }; - } - node = node.children[char]; - } - if (!node.output) node.output = []; - node.output.push({ pattern: pattern, index: index }); + if(!pattern || typeof pattern !== "string") { + return; + } + var node = this.trie; + for(var i = 0; i < pattern.length; i++) { + var char = pattern[i]; + if(!node[char]) { + node[char] = {}; + } + node = node[char]; + } + if(!node.$) { + node.$ = []; + } + node.$.push({ pattern: pattern, index: index }); }; +/** + * Build failure links for the Aho-Corasick automaton + */ +AhoCorasick.prototype.buildFailureLinks = function() { + var queue = []; + var root = this.trie; + this.failure[root] = root; + + // Initialize first level failure links + for(var char in root) { + if(root[char] && char !== '$') { + this.failure[root[char]] = root; + queue.push(root[char]); + } + } + + var maxIterations = 100000; // Prevent infinite loops + var iteration = 0; + + while(queue.length && iteration < maxIterations) { + var node = queue.shift(); + for(var char in node) { + if(node[char] && char !== '$') { + var child = node[char]; + var fail = this.failure[node]; + var failCount = 0; + var maxFailCount = 1000; // Prevent deep failure chains + + while(fail && !fail[char] && failCount < maxFailCount) { + fail = this.failure[fail]; + failCount++; + } + + this.failure[child] = fail[char] || this.trie; + + // Copy output from failure link + if(this.failure[child].$) { + if(!child.$) { + child.$ = []; + } + child.$.push.apply(child.$, this.failure[child].$); + } + + queue.push(child); + } + } + iteration++; + } + + if(iteration >= maxIterations) { + throw new Error("Aho-Corasick: buildFailureLinks exceeded max iterations"); + } +}; + +/** + * Search for all patterns in the given text + * @param {string} text - The text to search in + * @returns {Array} Array of match objects with pattern, index, length, and titleIndex properties + */ AhoCorasick.prototype.search = function(text) { - if (!$tw.utils.isString(text) || !text.length) { - return []; - } - var matches = []; - var node = this.trie; - var maxIterations = text.length * 5; // Adjusted for efficiency - var iteration = 0; - for (var i = 0; i < text.length && iteration < maxIterations; i++) { - var char = text[i]; - var transitionCount = 0; - var maxTransitionCount = 200; // Adjusted for safety - while (node && !node.children[char] && transitionCount < maxTransitionCount) { - node = this.failure[node]; - transitionCount++; - } - node = node.children[char] || this.trie; - if (node.output.length) { - for (var j = 0; j < node.output.length; j++) { - var match = node.output[j]; - matches.push({ - pattern: match.pattern, - index: i - match.pattern.length + 1, - length: match.pattern.length, - titleIndex: match.index - }); - } - } - iteration++; - } - if (iteration >= maxIterations) { - $tw.utils.warning("Aho-Corasick: search exceeded max iterations"); - } - return matches; + if(!text || typeof text !== "string") { + return []; + } + + var matches = []; + var node = this.trie; + var maxIterations = text.length * 10; // Prevent infinite loops + var iteration = 0; + + for(var i = 0; i < text.length && iteration < maxIterations; i++) { + var char = text[i]; + var transitionCount = 0; + var maxTransitionCount = 1000; // Prevent deep transitions + + while(node && !node[char] && transitionCount < maxTransitionCount) { + node = this.failure[node]; + transitionCount++; + } + + node = node[char] || this.trie; + + if(node.$) { + for(var j = 0; j < node.$.length; j++) { + var match = node.$[j]; + matches.push({ + pattern: match.pattern, + index: i - match.pattern.length + 1, + length: match.pattern.length, + titleIndex: match.index + }); + } + } + iteration++; + } + + if(iteration >= maxIterations) { + throw new Error("Aho-Corasick: search exceeded max iterations"); + } + + return matches; }; +// Export the AhoCorasick constructor exports.AhoCorasick = AhoCorasick; + +})(); \ No newline at end of file From da803cfcfb00e8c9e1b3050ccb9227e5c340e19d Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 22:49:20 +0800 Subject: [PATCH 11/70] Delete core/modules/utils/AhoCorasick.js wrong place --- core/modules/utils/AhoCorasick.js | 152 ------------------------------ 1 file changed, 152 deletions(-) delete mode 100644 core/modules/utils/AhoCorasick.js diff --git a/core/modules/utils/AhoCorasick.js b/core/modules/utils/AhoCorasick.js deleted file mode 100644 index d7f9e3017f7..00000000000 --- a/core/modules/utils/AhoCorasick.js +++ /dev/null @@ -1,152 +0,0 @@ -/*\ -title: $:/core/modules/utils/aho-corasick.js -type: application/javascript -module-type: utils - -Aho-Corasick string matching algorithm implementation with enhanced error handling -for TiddlyWiki freelinking functionality. - -\*/ -(function(){ - -"use strict"; - -/** - * Aho-Corasick implementation with enhanced error handling - * @constructor - */ -function AhoCorasick() { - this.trie = {}; - this.failure = {}; - this.output = {}; -} - -/** - * Add a pattern to the Aho-Corasick automaton - * @param {string} pattern - The pattern to add - * @param {number} index - The index of the pattern for identification - */ -AhoCorasick.prototype.addPattern = function(pattern, index) { - if(!pattern || typeof pattern !== "string") { - return; - } - var node = this.trie; - for(var i = 0; i < pattern.length; i++) { - var char = pattern[i]; - if(!node[char]) { - node[char] = {}; - } - node = node[char]; - } - if(!node.$) { - node.$ = []; - } - node.$.push({ pattern: pattern, index: index }); -}; - -/** - * Build failure links for the Aho-Corasick automaton - */ -AhoCorasick.prototype.buildFailureLinks = function() { - var queue = []; - var root = this.trie; - this.failure[root] = root; - - // Initialize first level failure links - for(var char in root) { - if(root[char] && char !== '$') { - this.failure[root[char]] = root; - queue.push(root[char]); - } - } - - var maxIterations = 100000; // Prevent infinite loops - var iteration = 0; - - while(queue.length && iteration < maxIterations) { - var node = queue.shift(); - for(var char in node) { - if(node[char] && char !== '$') { - var child = node[char]; - var fail = this.failure[node]; - var failCount = 0; - var maxFailCount = 1000; // Prevent deep failure chains - - while(fail && !fail[char] && failCount < maxFailCount) { - fail = this.failure[fail]; - failCount++; - } - - this.failure[child] = fail[char] || this.trie; - - // Copy output from failure link - if(this.failure[child].$) { - if(!child.$) { - child.$ = []; - } - child.$.push.apply(child.$, this.failure[child].$); - } - - queue.push(child); - } - } - iteration++; - } - - if(iteration >= maxIterations) { - throw new Error("Aho-Corasick: buildFailureLinks exceeded max iterations"); - } -}; - -/** - * Search for all patterns in the given text - * @param {string} text - The text to search in - * @returns {Array} Array of match objects with pattern, index, length, and titleIndex properties - */ -AhoCorasick.prototype.search = function(text) { - if(!text || typeof text !== "string") { - return []; - } - - var matches = []; - var node = this.trie; - var maxIterations = text.length * 10; // Prevent infinite loops - var iteration = 0; - - for(var i = 0; i < text.length && iteration < maxIterations; i++) { - var char = text[i]; - var transitionCount = 0; - var maxTransitionCount = 1000; // Prevent deep transitions - - while(node && !node[char] && transitionCount < maxTransitionCount) { - node = this.failure[node]; - transitionCount++; - } - - node = node[char] || this.trie; - - if(node.$) { - for(var j = 0; j < node.$.length; j++) { - var match = node.$[j]; - matches.push({ - pattern: match.pattern, - index: i - match.pattern.length + 1, - length: match.pattern.length, - titleIndex: match.index - }); - } - } - iteration++; - } - - if(iteration >= maxIterations) { - throw new Error("Aho-Corasick: search exceeded max iterations"); - } - - return matches; -}; - -// Export the AhoCorasick constructor -exports.AhoCorasick = AhoCorasick; - -})(); \ No newline at end of file From a0cf9943ad6050c7152ae4f34894152fb2ceeb50 Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 22:50:18 +0800 Subject: [PATCH 12/70] Update text.js --- plugins/tiddlywiki/freelinks/text.js | 447 +++++++++++++++------------ 1 file changed, 255 insertions(+), 192 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index ee888e97598..3ff11e87b3c 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -1,12 +1,12 @@ /*\ -title: $:/plugins/tiddlywiki/freelinks/text.js +title: $:/core/modules/widgets/text.js type: application/javascript module-type: widget An override of the core text widget that automatically linkifies the text, with support for non-Latin languages like Chinese, prioritizing longer titles, skipping processed matches, excluding the current tiddler title from linking, and handling large title sets with Aho-Corasick algorithm and fixed chunking (100 titles per chunk). Includes optional persistent caching of Aho-Corasick automaton, controlled by $:/config/Freelinks/PersistAhoCorasickCache. \*/ -(function() { +(function(){ "use strict"; @@ -14,26 +14,33 @@ var TITLE_TARGET_FILTER = "$:/config/Freelinks/TargetFilter"; var PERSIST_CACHE_TIDDLER = "$:/config/Freelinks/PersistAhoCorasickCache"; var Widget = require("$:/core/modules/widgets/widget.js").widget, - LinkWidget = require("$:/core/modules/widgets/link.js").link, - ButtonWidget = require("$:/core/modules/widgets/button.js").button, - ElementWidget = require("$:/core/modules/widgets/element.js").element; + LinkWidget = require("$:/core/modules/widgets/link.js").link, + ButtonWidget = require("$:/core/modules/widgets/button.js").button, + ElementWidget = require("$:/core/modules/widgets/element.js").element, + AhoCorasick = require("$:/core/modules/utils/aho-corasick.js").AhoCorasick; -// Escape only ASCII 127 and below metacharacters +/** + * Escape only ASCII 127 and below metacharacters + * @param {string} str - String to escape + * @returns {string|null} Escaped string or null if failed + */ function escapeRegExp(str) { - try { - return str.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); - } catch (e) { - $tw.utils.warning("Failed to escape title:", str, e); - return null; // Skip problematic titles - } + try { + return str.replace(/[\\^$*+?.()|[\]{}]/g, '\\function escapeRegExp(str) { + try { + return str.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); + } catch(e) { + console.warn("Failed to escape title:", str, e); + return null; // Skip problematic titles + } +}'); + } catch(e) { + return null; // Skip problematic titles + } } -// Aho-Corasick instance -var AhoCorasick = require("$:/plugins/tiddlywiki/freelinks/AhoCorasick.js").AhoCorasick; -var aho = new AhoCorasick(); - -var TextNodeWidget = function(parseTreeNode, options) { - this.initialise(parseTreeNode, options); +var TextNodeWidget = function(parseTreeNode,options) { + this.initialise(parseTreeNode,options); }; /* @@ -44,204 +51,260 @@ TextNodeWidget.prototype = new Widget(); /* Render this widget into the DOM */ -TextNodeWidget.prototype.render = function(parent, nextSibling) { - this.parentDomNode = parent; - this.computeAttributes(); - this.execute(); - this.renderChildren(parent, nextSibling); +TextNodeWidget.prototype.render = function(parent,nextSibling) { + this.parentDomNode = parent; + this.computeAttributes(); + this.execute(); + this.renderChildren(parent,nextSibling); }; /* Compute the internal state of the widget */ TextNodeWidget.prototype.execute = function() { - var self = this, - ignoreCase = self.getVariable("tv-freelinks-ignore-case", { defaultValue: "no" }).trim() === "yes"; - // Get our parameters - var childParseTree = [{ - type: "text", - text: this.getAttribute("text", this.parseTreeNode.text || "") - }]; - // Only process links if not disabled and we're not within a button or link widget - if (this.getVariable("tv-wikilinks", { defaultValue: "yes" }).trim() !== "no" && - this.getVariable("tv-freelinks", { defaultValue: "no" }).trim() === "yes" && - !this.isWithinButtonOrLink()) { - // Get the current tiddler title - var currentTiddlerTitle = this.getVariable("currentTiddler") || ""; - // Check if persistent caching is enabled - var usePersistent = this.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; - var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); - // Get or compute tiddler title info with caching - this.tiddlerTitleInfo = this.wiki.getCache(cacheKey, function() { - return computeTiddlerTitleInfo(self, ignoreCase); - }, usePersistent); - // Process titles to avoid overlapping matches - if (this.tiddlerTitleInfo && this.tiddlerTitleInfo.titles.length > 0) { - var text = childParseTree[0].text, - newParseTree = [], - currentPos = 0; - // Use Aho-Corasick to find all matches - var searchText = ignoreCase ? text.toLowerCase() : text; - var matches = this.tiddlerTitleInfo.ac.search(searchText) || []; - // Sort matches by position and length (longer titles first) - matches.sort(function(a, b) { - if (a.index !== b.index) return a.index - b.index; - return b.length - a.length; - }); - // Process matches to avoid overlaps - var processedPositions = new Set(); - for (var i = 0; i < matches.length; i++) { - var match = matches[i]; - var matchStart = match.index; - var matchEnd = matchStart + match.length; - // Skip if position already processed (ensures longer titles take precedence) - var overlap = false; - for (var pos = matchStart; pos < matchEnd; pos++) { - if (processedPositions.has(pos)) { - overlap = true; - break; - } - } - if (overlap) continue; - // Mark positions as processed - for (var pos = matchStart; pos < matchEnd; pos++) { - processedPositions.add(pos); - } - // Add text before the match - if (matchStart > currentPos) { - newParseTree.push({ - type: "text", - text: text.slice(currentPos, matchStart) - }); - } - // Get matched title - var matchedTitle = this.tiddlerTitleInfo.titles[match.titleIndex]; - // Check if the matched title is the current tiddler title - if (matchedTitle === currentTiddlerTitle) { - // Skip linking, keep as plain text - newParseTree.push({ - type: "text", - text: text.slice(matchStart, matchEnd) - }); - } else { - // Add link for the match - newParseTree.push({ - type: "link", - attributes: { - to: { type: "string", value: matchedTitle }, - "class": { type: "string", value: "tc-freelink" } - }, - children: [{ - type: "text", - text: text.slice(matchStart, matchEnd) - }] - }); - } - currentPos = matchEnd; - } - // Add remaining text - if (currentPos < text.length) { - newParseTree.push({ - type: "text", - text: text.substring(currentPos) - }); - } - childParseTree = newParseTree; - } - } - // Make the child widgets - this.makeChildWidgets(childParseTree); + var self = this, + ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}).trim() === "yes"; + + // Get our parameters + var childParseTree = [{ + type: "text", + text: this.getAttribute("text",this.parseTreeNode.text || "") + }]; + + // Only process links if not disabled and we're not within a button or link widget + if(this.getVariable("tv-wikilinks",{defaultValue:"yes"}).trim() !== "no" && + this.getVariable("tv-freelinks",{defaultValue:"no"}).trim() === "yes" && + !this.isWithinButtonOrLink()) { + + // Get the current tiddler title + var currentTiddlerTitle = this.getVariable("currentTiddler") || ""; + + // Check if persistent caching is enabled + var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; + var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); + + // Get the information about the current tiddler titles using unified cache strategy + this.tiddlerTitleInfo = persistCache ? + this.wiki.getPersistentCache(cacheKey, function() { + return computeTiddlerTitleInfo(self, ignoreCase); + }) : + this.wiki.getGlobalCache(cacheKey, function() { + return computeTiddlerTitleInfo(self, ignoreCase); + }); + + // Process titles to avoid overlapping matches + if(this.tiddlerTitleInfo.titles.length > 0) { + var text = childParseTree[0].text, + newParseTree = [], + currentPos = 0; + + // Use Aho-Corasick to find all matches + var searchText = ignoreCase ? text.toLowerCase() : text; + var matches; + try { + matches = this.tiddlerTitleInfo.ac.search(searchText); + } catch(e) { + matches = []; + } + + // Sort matches by position and length (longer titles first) + matches.sort(function(a, b) { + if(a.index !== b.index) { + return a.index - b.index; + } + return b.length - a.length; + }); + + // Process matches to avoid overlaps + var processedPositions = new Set(); + for(var i = 0; i < matches.length; i++) { + var match = matches[i]; + var matchStart = match.index; + var matchEnd = matchStart + match.length; + + // Skip if position already processed (ensures longer titles take precedence) + var overlap = false; + for(var pos = matchStart; pos < matchEnd; pos++) { + if(processedPositions.has(pos)) { + overlap = true; + break; + } + } + if(overlap) { + continue; + } + + // Mark positions as processed + for(var pos = matchStart; pos < matchEnd; pos++) { + processedPositions.add(pos); + } + + // Add text before the match + if(matchStart > currentPos) { + newParseTree.push({ + type: "text", + text: text.slice(currentPos, matchStart) + }); + } + + // Get matched title + var matchedTitle = this.tiddlerTitleInfo.titles[match.titleIndex]; + + // Check if the matched title is the current tiddler title + if(matchedTitle === currentTiddlerTitle) { + // Skip linking, keep as plain text + newParseTree.push({ + type: "text", + text: text.slice(matchStart, matchEnd) + }); + } else { + // Add link for the match + newParseTree.push({ + type: "link", + attributes: { + to: {type: "string", value: matchedTitle}, + "class": {type: "string", value: "tc-freelink"} + }, + children: [{ + type: "text", + text: text.slice(matchStart, matchEnd) + }] + }); + } + currentPos = matchEnd; + } + + // Add remaining text + if(currentPos < text.length) { + newParseTree.push({ + type: "text", + text: text.slice(currentPos) + }); + } + childParseTree = newParseTree; + } + } + + // Make the child widgets + this.makeChildWidgets(childParseTree); }; /* Helper function to compute tiddler title info */ function computeTiddlerTitleInfo(self, ignoreCase) { - var targetFilterText = self.wiki.getTiddlerText(TITLE_TARGET_FILTER), - titles = !!targetFilterText ? self.wiki.filterTiddlers(targetFilterText, $tw.rootWidget) : self.wiki.allTitles(); - if (!titles || titles.length === 0) { - $tw.utils.warning("No titles found for Freelinks processing"); - return { titles: [], ac: new AhoCorasick(), chunkSize: 100, titleChunks: [] }; - } - var sortedTitles = titles.sort(function(a, b) { - var lenA = a.length, - lenB = b.length; - // Sort by length (longer first), then alphabetically for same length - if (lenA !== lenB) return lenB - lenA; - return a.localeCompare(b, 'zh', { sensitivity: 'base' }); - }), - titles = [], - chunkSize = 100; // Fixed chunk size - var ac = new AhoCorasick(); - $tw.utils.each(sortedTitles, function(title, index) { - if (title.substring(0, 3) !== "$:/") { - var escapedTitle = escapeRegExp(title); - if (escapedTitle) { - titles.push(title); - ac.addPattern(ignoreCase ? title.toLowerCase() : title, titles.length - 1); - } else { - $tw.utils.warning("Skipping invalid title:", title); - } - } - }); - try { - ac.buildFailureLinks(); - } catch (e) { - $tw.utils.error("Failed to build Aho-Corasick failure links:", e); - return { titles: [], ac: new AhoCorasick(), chunkSize: 100, titleChunks: [] }; - } - // Split titles into chunks for memory management - var titleChunks = []; - for (var i = 0; i < titles.length; i += chunkSize) { - titleChunks.push(titles.slice(i, i + chunkSize)); - } - return { - titles: titles, - ac: ac, - chunkSize: chunkSize, - titleChunks: titleChunks - }; + var targetFilterText = self.wiki.getTiddlerText(TITLE_TARGET_FILTER), + titles = !!targetFilterText ? + self.wiki.filterTiddlers(targetFilterText,$tw.rootWidget) : + self.wiki.allTitles(); + + if(!titles || titles.length === 0) { + return { + titles: [], + ac: new AhoCorasick(), + chunkSize: 100, + titleChunks: [] + }; + } + + var sortedTitles = titles.sort(function(a,b) { + var lenA = a.length, + lenB = b.length; + // Sort by length (longer first), then alphabetically for same length + if(lenA !== lenB) { + return lenB - lenA; + } else { + return a.localeCompare(b, 'zh', {sensitivity: 'base'}); + } + }), + validTitles = [], + chunkSize = 100; // Fixed chunk size + + var ac = new AhoCorasick(); + + $tw.utils.each(sortedTitles,function(title, index) { + if(title.substring(0,3) !== "$:/") { + var escapedTitle = escapeRegExp(title); + if(escapedTitle) { + validTitles.push(title); + ac.addPattern(ignoreCase ? title.toLowerCase() : title, validTitles.length - 1); + } + } + }); + + try { + ac.buildFailureLinks(); + } catch(e) { + return { + titles: [], + ac: new AhoCorasick(), + chunkSize: 100, + titleChunks: [] + }; + } + + // Split titles into chunks for memory management + var titleChunks = []; + for(var i = 0; i < validTitles.length; i += chunkSize) { + titleChunks.push(validTitles.slice(i, i + chunkSize)); + } + + return { + titles: validTitles, + ac: ac, + chunkSize: chunkSize, + titleChunks: titleChunks + }; } /* Check if the widget is within a button or link */ TextNodeWidget.prototype.isWithinButtonOrLink = function() { - var withinButtonOrLink = false, - widget = this.parentWidget; - while (!withinButtonOrLink && widget) { - withinButtonOrLink = widget instanceof ButtonWidget || widget instanceof LinkWidget || - ((widget instanceof ElementWidget) && widget.parseTreeNode.tag === "a"); - widget = widget.parentWidget; - } - return withinButtonOrLink; + var withinButtonOrLink = false, + widget = this.parentWidget; + while(!withinButtonOrLink && widget) { + withinButtonOrLink = widget instanceof ButtonWidget || + widget instanceof LinkWidget || + ((widget instanceof ElementWidget) && widget.parseTreeNode.tag === "a"); + widget = widget.parentWidget; + } + return withinButtonOrLink; }; /* Selectively refreshes the widget if needed. Returns true if the widget or any of its children needed re-rendering */ TextNodeWidget.prototype.refresh = function(changedTiddlers) { - var self = this, - changedAttributes = this.computeAttributes(), - titlesHaveChanged = false; - $tw.utils.each(changedTiddlers, function(change, title) { - if (change.isDeleted) { - titlesHaveChanged = true; - } else { - titlesHaveChanged = titlesHaveChanged || !self.tiddlerTitleInfo || self.tiddlerTitleInfo.titles.indexOf(title) === -1; - } - }); - if (changedAttributes.text || titlesHaveChanged) { - // Invalidate cache if titles changed and persistent cache is enabled - var usePersistent = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; - var cacheKey = "tiddler-title-info-" + (self.getVariable("tv-freelinks-ignore-case", { defaultValue: "no" }).trim() === "yes" ? "insensitive" : "sensitive"); - if (titlesHaveChanged && usePersistent) { - self.wiki.clearCache(cacheKey); - } - this.refreshSelf(); - return true; - } - return false; + var self = this, + changedAttributes = this.computeAttributes(), + titlesHaveChanged = false; + + $tw.utils.each(changedTiddlers,function(change,title) { + if(change.isDeleted) { + titlesHaveChanged = true; + } else { + titlesHaveChanged = titlesHaveChanged || + !self.tiddlerTitleInfo || + self.tiddlerTitleInfo.titles.indexOf(title) === -1; + } + }); + + if(changedAttributes.text || titlesHaveChanged) { + // Invalidate cache if titles changed and persistent cache is enabled + var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; + var ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}).trim() === "yes"; + var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); + + if(titlesHaveChanged && persistCache) { + self.wiki.clearCache(cacheKey); + } + + this.refreshSelf(); + return true; + } else { + return false; + } }; exports.text = TextNodeWidget; From 6b702caf59c353e717cb5d354029ffaf45668953 Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 23:03:57 +0800 Subject: [PATCH 13/70] indentation modify --- plugins/tiddlywiki/freelinks/text.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 3ff11e87b3c..6a8ac319429 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -73,8 +73,8 @@ TextNodeWidget.prototype.execute = function() { // Only process links if not disabled and we're not within a button or link widget if(this.getVariable("tv-wikilinks",{defaultValue:"yes"}).trim() !== "no" && - this.getVariable("tv-freelinks",{defaultValue:"no"}).trim() === "yes" && - !this.isWithinButtonOrLink()) { + this.getVariable("tv-freelinks",{defaultValue:"no"}).trim() === "yes" && + !this.isWithinButtonOrLink()) { // Get the current tiddler title var currentTiddlerTitle = this.getVariable("currentTiddler") || ""; From 002ab044eb4360bec4530d017b52c27f13f2f786 Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 9 Jun 2025 23:46:53 +0800 Subject: [PATCH 14/70] remove function {} --- plugins/tiddlywiki/freelinks/text.js | 30 ++++++++++------------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 6a8ac319429..411339cd723 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -6,7 +6,6 @@ module-type: widget An override of the core text widget that automatically linkifies the text, with support for non-Latin languages like Chinese, prioritizing longer titles, skipping processed matches, excluding the current tiddler title from linking, and handling large title sets with Aho-Corasick algorithm and fixed chunking (100 titles per chunk). Includes optional persistent caching of Aho-Corasick automaton, controlled by $:/config/Freelinks/PersistAhoCorasickCache. \*/ -(function(){ "use strict"; @@ -25,15 +24,8 @@ var Widget = require("$:/core/modules/widgets/widget.js").widget, * @returns {string|null} Escaped string or null if failed */ function escapeRegExp(str) { - try { - return str.replace(/[\\^$*+?.()|[\]{}]/g, '\\function escapeRegExp(str) { try { return str.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); - } catch(e) { - console.warn("Failed to escape title:", str, e); - return null; // Skip problematic titles - } -}'); } catch(e) { return null; // Skip problematic titles } @@ -63,17 +55,17 @@ Compute the internal state of the widget */ TextNodeWidget.prototype.execute = function() { var self = this, - ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}).trim() === "yes"; + ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}) === "yes"; // Get our parameters var childParseTree = [{ - type: "text", + type: "plain-text", text: this.getAttribute("text",this.parseTreeNode.text || "") }]; // Only process links if not disabled and we're not within a button or link widget - if(this.getVariable("tv-wikilinks",{defaultValue:"yes"}).trim() !== "no" && - this.getVariable("tv-freelinks",{defaultValue:"no"}).trim() === "yes" && + if(this.getVariable("tv-wikilinks",{defaultValue:"yes"}) !== "no" && + this.getVariable("tv-freelinks",{defaultValue:"no"}) === "yes" && !this.isWithinButtonOrLink()) { // Get the current tiddler title @@ -83,7 +75,7 @@ TextNodeWidget.prototype.execute = function() { var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); - // Get the information about the current tiddler titles using unified cache strategy + // Get the information about the current tiddler titles this.tiddlerTitleInfo = persistCache ? this.wiki.getPersistentCache(cacheKey, function() { return computeTiddlerTitleInfo(self, ignoreCase); @@ -142,7 +134,7 @@ TextNodeWidget.prototype.execute = function() { // Add text before the match if(matchStart > currentPos) { newParseTree.push({ - type: "text", + type: "plain-text", text: text.slice(currentPos, matchStart) }); } @@ -154,7 +146,7 @@ TextNodeWidget.prototype.execute = function() { if(matchedTitle === currentTiddlerTitle) { // Skip linking, keep as plain text newParseTree.push({ - type: "text", + type: "plain-text", text: text.slice(matchStart, matchEnd) }); } else { @@ -166,7 +158,7 @@ TextNodeWidget.prototype.execute = function() { "class": {type: "string", value: "tc-freelink"} }, children: [{ - type: "text", + type: "plain-text", text: text.slice(matchStart, matchEnd) }] }); @@ -177,7 +169,7 @@ TextNodeWidget.prototype.execute = function() { // Add remaining text if(currentPos < text.length) { newParseTree.push({ - type: "text", + type: "plain-text", text: text.slice(currentPos) }); } @@ -293,7 +285,7 @@ TextNodeWidget.prototype.refresh = function(changedTiddlers) { if(changedAttributes.text || titlesHaveChanged) { // Invalidate cache if titles changed and persistent cache is enabled var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; - var ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}).trim() === "yes"; + var ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}) === "yes"; var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); if(titlesHaveChanged && persistCache) { @@ -308,5 +300,3 @@ TextNodeWidget.prototype.refresh = function(changedTiddlers) { }; exports.text = TextNodeWidget; - -})(); From 3402cc5993411f37eca31e79a6ff9ea91a9b42eb Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 10 Jun 2025 00:34:20 +0800 Subject: [PATCH 15/70] remove function {} --- plugins/tiddlywiki/freelinks/AhoCorasick.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/AhoCorasick.js b/plugins/tiddlywiki/freelinks/AhoCorasick.js index d7f9e3017f7..709c7568ab9 100644 --- a/plugins/tiddlywiki/freelinks/AhoCorasick.js +++ b/plugins/tiddlywiki/freelinks/AhoCorasick.js @@ -7,7 +7,6 @@ Aho-Corasick string matching algorithm implementation with enhanced error handli for TiddlyWiki freelinking functionality. \*/ -(function(){ "use strict"; @@ -148,5 +147,3 @@ AhoCorasick.prototype.search = function(text) { // Export the AhoCorasick constructor exports.AhoCorasick = AhoCorasick; - -})(); \ No newline at end of file From 0b13f17d0b890c8356d81ae0343b64bd6191d9f5 Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 10 Jun 2025 12:13:28 +0800 Subject: [PATCH 16/70] Rename AhoCorasick.js to aho-corasick.js correct filename --- plugins/tiddlywiki/freelinks/{AhoCorasick.js => aho-corasick.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename plugins/tiddlywiki/freelinks/{AhoCorasick.js => aho-corasick.js} (100%) diff --git a/plugins/tiddlywiki/freelinks/AhoCorasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js similarity index 100% rename from plugins/tiddlywiki/freelinks/AhoCorasick.js rename to plugins/tiddlywiki/freelinks/aho-corasick.js From 9bcb30949ceb606e93dc732a3de388d27a02973f Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 10 Jun 2025 12:21:02 +0800 Subject: [PATCH 17/70] Update tiddlywiki.info add freelink --- editions/tw5.com/tiddlywiki.info | 1 + 1 file changed, 1 insertion(+) diff --git a/editions/tw5.com/tiddlywiki.info b/editions/tw5.com/tiddlywiki.info index 47de3c45597..c0449ac4ee2 100644 --- a/editions/tw5.com/tiddlywiki.info +++ b/editions/tw5.com/tiddlywiki.info @@ -8,6 +8,7 @@ "tiddlywiki/menubar", "tiddlywiki/railroad", "tiddlywiki/tour" + "tiddlywiki/freelink" ], "themes": [ "tiddlywiki/vanilla", From 2093f2664332b39b81e3cf110edbd19d66a24112 Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 10 Jun 2025 13:48:54 +0800 Subject: [PATCH 18/70] missing a comma here --- editions/tw5.com/tiddlywiki.info | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editions/tw5.com/tiddlywiki.info b/editions/tw5.com/tiddlywiki.info index c0449ac4ee2..5715e3ccc4c 100644 --- a/editions/tw5.com/tiddlywiki.info +++ b/editions/tw5.com/tiddlywiki.info @@ -7,7 +7,7 @@ "tiddlywiki/internals", "tiddlywiki/menubar", "tiddlywiki/railroad", - "tiddlywiki/tour" + "tiddlywiki/tour", "tiddlywiki/freelink" ], "themes": [ From 19447834665ac14af5952649da8049cec6b00172 Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 10 Jun 2025 13:57:55 +0800 Subject: [PATCH 19/70] clean up comments & use old style --- plugins/tiddlywiki/freelinks/text.js | 58 ++++++++-------------------- 1 file changed, 16 insertions(+), 42 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 411339cd723..d9cdcd44011 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -18,16 +18,14 @@ var Widget = require("$:/core/modules/widgets/widget.js").widget, ElementWidget = require("$:/core/modules/widgets/element.js").element, AhoCorasick = require("$:/core/modules/utils/aho-corasick.js").AhoCorasick; -/** - * Escape only ASCII 127 and below metacharacters - * @param {string} str - String to escape - * @returns {string|null} Escaped string or null if failed - */ +/* +Escape only ASCII 127 and below metacharacters to avoid issues with Unicode titles +*/ function escapeRegExp(str) { try { return str.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); } catch(e) { - return null; // Skip problematic titles + return null; } } @@ -35,14 +33,8 @@ var TextNodeWidget = function(parseTreeNode,options) { this.initialise(parseTreeNode,options); }; -/* -Inherit from the base widget class -*/ TextNodeWidget.prototype = new Widget(); -/* -Render this widget into the DOM -*/ TextNodeWidget.prototype.render = function(parent,nextSibling) { this.parentDomNode = parent; this.computeAttributes(); @@ -50,32 +42,26 @@ TextNodeWidget.prototype.render = function(parent,nextSibling) { this.renderChildren(parent,nextSibling); }; -/* -Compute the internal state of the widget -*/ TextNodeWidget.prototype.execute = function() { var self = this, ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}) === "yes"; - // Get our parameters var childParseTree = [{ type: "plain-text", text: this.getAttribute("text",this.parseTreeNode.text || "") }]; - // Only process links if not disabled and we're not within a button or link widget + // Only process if freelinks enabled and not within interactive elements (prevents nested links) if(this.getVariable("tv-wikilinks",{defaultValue:"yes"}) !== "no" && this.getVariable("tv-freelinks",{defaultValue:"no"}) === "yes" && !this.isWithinButtonOrLink()) { - // Get the current tiddler title var currentTiddlerTitle = this.getVariable("currentTiddler") || ""; - // Check if persistent caching is enabled + // Cache strategy: persistent vs session-based depending on configuration var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); - // Get the information about the current tiddler titles this.tiddlerTitleInfo = persistCache ? this.wiki.getPersistentCache(cacheKey, function() { return computeTiddlerTitleInfo(self, ignoreCase); @@ -84,13 +70,11 @@ TextNodeWidget.prototype.execute = function() { return computeTiddlerTitleInfo(self, ignoreCase); }); - // Process titles to avoid overlapping matches if(this.tiddlerTitleInfo.titles.length > 0) { var text = childParseTree[0].text, newParseTree = [], currentPos = 0; - // Use Aho-Corasick to find all matches var searchText = ignoreCase ? text.toLowerCase() : text; var matches; try { @@ -99,7 +83,7 @@ TextNodeWidget.prototype.execute = function() { matches = []; } - // Sort matches by position and length (longer titles first) + // Prioritize longer matches first, then by position matches.sort(function(a, b) { if(a.index !== b.index) { return a.index - b.index; @@ -107,14 +91,13 @@ TextNodeWidget.prototype.execute = function() { return b.length - a.length; }); - // Process matches to avoid overlaps + // Prevent overlapping matches - longer titles take precedence var processedPositions = new Set(); for(var i = 0; i < matches.length; i++) { var match = matches[i]; var matchStart = match.index; var matchEnd = matchStart + match.length; - // Skip if position already processed (ensures longer titles take precedence) var overlap = false; for(var pos = matchStart; pos < matchEnd; pos++) { if(processedPositions.has(pos)) { @@ -126,12 +109,10 @@ TextNodeWidget.prototype.execute = function() { continue; } - // Mark positions as processed for(var pos = matchStart; pos < matchEnd; pos++) { processedPositions.add(pos); } - // Add text before the match if(matchStart > currentPos) { newParseTree.push({ type: "plain-text", @@ -139,18 +120,15 @@ TextNodeWidget.prototype.execute = function() { }); } - // Get matched title var matchedTitle = this.tiddlerTitleInfo.titles[match.titleIndex]; - // Check if the matched title is the current tiddler title + // Self-referential links are rendered as plain text to avoid circular navigation if(matchedTitle === currentTiddlerTitle) { - // Skip linking, keep as plain text newParseTree.push({ type: "plain-text", text: text.slice(matchStart, matchEnd) }); } else { - // Add link for the match newParseTree.push({ type: "link", attributes: { @@ -166,7 +144,6 @@ TextNodeWidget.prototype.execute = function() { currentPos = matchEnd; } - // Add remaining text if(currentPos < text.length) { newParseTree.push({ type: "plain-text", @@ -177,12 +154,11 @@ TextNodeWidget.prototype.execute = function() { } } - // Make the child widgets this.makeChildWidgets(childParseTree); }; /* -Helper function to compute tiddler title info +Builds optimized title search structure with chunking for memory management */ function computeTiddlerTitleInfo(self, ignoreCase) { var targetFilterText = self.wiki.getTiddlerText(TITLE_TARGET_FILTER), @@ -199,10 +175,10 @@ function computeTiddlerTitleInfo(self, ignoreCase) { }; } + // Longer titles prioritized for better matching, with Chinese locale support var sortedTitles = titles.sort(function(a,b) { var lenA = a.length, lenB = b.length; - // Sort by length (longer first), then alphabetically for same length if(lenA !== lenB) { return lenB - lenA; } else { @@ -210,11 +186,12 @@ function computeTiddlerTitleInfo(self, ignoreCase) { } }), validTitles = [], - chunkSize = 100; // Fixed chunk size + chunkSize = 100; var ac = new AhoCorasick(); $tw.utils.each(sortedTitles,function(title, index) { + // Exclude system tiddlers from linking if(title.substring(0,3) !== "$:/") { var escapedTitle = escapeRegExp(title); if(escapedTitle) { @@ -235,7 +212,7 @@ function computeTiddlerTitleInfo(self, ignoreCase) { }; } - // Split titles into chunks for memory management + // Memory optimization through fixed-size chunking var titleChunks = []; for(var i = 0; i < validTitles.length; i += chunkSize) { titleChunks.push(validTitles.slice(i, i + chunkSize)); @@ -250,7 +227,7 @@ function computeTiddlerTitleInfo(self, ignoreCase) { } /* -Check if the widget is within a button or link +Guards against nested interactive elements which break accessibility */ TextNodeWidget.prototype.isWithinButtonOrLink = function() { var withinButtonOrLink = false, @@ -264,9 +241,6 @@ TextNodeWidget.prototype.isWithinButtonOrLink = function() { return withinButtonOrLink; }; -/* -Selectively refreshes the widget if needed. Returns true if the widget or any of its children needed re-rendering -*/ TextNodeWidget.prototype.refresh = function(changedTiddlers) { var self = this, changedAttributes = this.computeAttributes(), @@ -283,7 +257,7 @@ TextNodeWidget.prototype.refresh = function(changedTiddlers) { }); if(changedAttributes.text || titlesHaveChanged) { - // Invalidate cache if titles changed and persistent cache is enabled + // Cache invalidation strategy for persistent vs session caches var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; var ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}) === "yes"; var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); From f786038ca02d0f4f8ed3914a831ba0b4068ef38c Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 10 Jun 2025 20:18:30 +0800 Subject: [PATCH 20/70] try add it to editions/tw5.com-server for testing --- editions/tw5.com-server/tiddlywiki.info | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/editions/tw5.com-server/tiddlywiki.info b/editions/tw5.com-server/tiddlywiki.info index cc460be7e34..bf524a535f3 100644 --- a/editions/tw5.com-server/tiddlywiki.info +++ b/editions/tw5.com-server/tiddlywiki.info @@ -4,7 +4,8 @@ "tiddlywiki/tiddlyweb", "tiddlywiki/filesystem", "tiddlywiki/highlight", - "tiddlywiki/internals" + "tiddlywiki/internals", + "tiddlywiki/freelink" ], "themes": [ "tiddlywiki/vanilla", From 7c3b62b7652eeddf50e5ec1f5cf0ccc01472c8bf Mon Sep 17 00:00:00 2001 From: s793016 Date: Wed, 11 Jun 2025 17:51:37 +0800 Subject: [PATCH 21/70] try add it to editions/prerelease for testing --- editions/prerelease/tiddlywiki.info | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/editions/prerelease/tiddlywiki.info b/editions/prerelease/tiddlywiki.info index c469dcf996a..f44419dc107 100644 --- a/editions/prerelease/tiddlywiki.info +++ b/editions/prerelease/tiddlywiki.info @@ -17,7 +17,8 @@ "tiddlywiki/jszip", "tiddlywiki/confetti", "tiddlywiki/dynannotate", - "tiddlywiki/tour" + "tiddlywiki/tour", + "tiddlywiki/freelink" ], "themes": [ "tiddlywiki/vanilla", From 89e9fff58b628c470dacb6d648b4b8a6785c5c03 Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 00:07:43 +0800 Subject: [PATCH 22/70] optimized --- plugins/tiddlywiki/freelinks/text.js | 312 +++++++++++++++------------ 1 file changed, 173 insertions(+), 139 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index d9cdcd44011..c238d2beed9 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -3,7 +3,7 @@ title: $:/core/modules/widgets/text.js type: application/javascript module-type: widget -An override of the core text widget that automatically linkifies the text, with support for non-Latin languages like Chinese, prioritizing longer titles, skipping processed matches, excluding the current tiddler title from linking, and handling large title sets with Aho-Corasick algorithm and fixed chunking (100 titles per chunk). Includes optional persistent caching of Aho-Corasick automaton, controlled by $:/config/Freelinks/PersistAhoCorasickCache. +An optimized override of the core text widget that automatically linkifies the text, with support for non-Latin languages like Chinese, prioritizing longer titles, skipping processed matches, excluding the current tiddler title from linking, and handling large title sets with enhanced Aho-Corasick algorithm and memory optimization. \*/ @@ -18,17 +18,29 @@ var Widget = require("$:/core/modules/widgets/widget.js").widget, ElementWidget = require("$:/core/modules/widgets/element.js").element, AhoCorasick = require("$:/core/modules/utils/aho-corasick.js").AhoCorasick; -/* -Escape only ASCII 127 and below metacharacters to avoid issues with Unicode titles -*/ +var ESCAPE_REGEX = /[\\^$*+?.()|[\]{}]/g; + function escapeRegExp(str) { try { - return str.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); + return str.replace(ESCAPE_REGEX, '\\$&'); } catch(e) { return null; } } +/* Fast Set-like structure using native Set for reliability */ +function FastPositionSet() { + this.set = new Set(); +} + +FastPositionSet.prototype.add = function(pos) { + this.set.add(pos); +}; + +FastPositionSet.prototype.has = function(pos) { + return this.set.has(pos); +}; + var TextNodeWidget = function(parseTreeNode,options) { this.initialise(parseTreeNode,options); }; @@ -51,14 +63,19 @@ TextNodeWidget.prototype.execute = function() { text: this.getAttribute("text",this.parseTreeNode.text || "") }]; - // Only process if freelinks enabled and not within interactive elements (prevents nested links) + var text = childParseTree[0].text; + + if(!text || text.length < 2) { + this.makeChildWidgets(childParseTree); + return; + } + if(this.getVariable("tv-wikilinks",{defaultValue:"yes"}) !== "no" && - this.getVariable("tv-freelinks",{defaultValue:"no"}) === "yes" && - !this.isWithinButtonOrLink()) { + this.getVariable("tv-freelinks",{defaultValue:"no"}) === "yes" && + !this.isWithinButtonOrLink()) { var currentTiddlerTitle = this.getVariable("currentTiddler") || ""; - // Cache strategy: persistent vs session-based depending on configuration var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); @@ -71,95 +88,112 @@ TextNodeWidget.prototype.execute = function() { }); if(this.tiddlerTitleInfo.titles.length > 0) { - var text = childParseTree[0].text, - newParseTree = [], - currentPos = 0; - - var searchText = ignoreCase ? text.toLowerCase() : text; - var matches; - try { - matches = this.tiddlerTitleInfo.ac.search(searchText); - } catch(e) { - matches = []; + var newParseTree = this.processTextWithMatches(text, currentTiddlerTitle, ignoreCase); + if(newParseTree.length > 1 || newParseTree[0].type !== "plain-text") { + childParseTree = newParseTree; } - - // Prioritize longer matches first, then by position - matches.sort(function(a, b) { - if(a.index !== b.index) { - return a.index - b.index; - } - return b.length - a.length; - }); - - // Prevent overlapping matches - longer titles take precedence - var processedPositions = new Set(); - for(var i = 0; i < matches.length; i++) { - var match = matches[i]; - var matchStart = match.index; - var matchEnd = matchStart + match.length; - - var overlap = false; - for(var pos = matchStart; pos < matchEnd; pos++) { - if(processedPositions.has(pos)) { - overlap = true; - break; - } - } - if(overlap) { - continue; - } - - for(var pos = matchStart; pos < matchEnd; pos++) { - processedPositions.add(pos); - } - - if(matchStart > currentPos) { - newParseTree.push({ - type: "plain-text", - text: text.slice(currentPos, matchStart) - }); - } - - var matchedTitle = this.tiddlerTitleInfo.titles[match.titleIndex]; - - // Self-referential links are rendered as plain text to avoid circular navigation - if(matchedTitle === currentTiddlerTitle) { - newParseTree.push({ - type: "plain-text", - text: text.slice(matchStart, matchEnd) - }); - } else { - newParseTree.push({ - type: "link", - attributes: { - to: {type: "string", value: matchedTitle}, - "class": {type: "string", value: "tc-freelink"} - }, - children: [{ - type: "plain-text", - text: text.slice(matchStart, matchEnd) - }] - }); - } - currentPos = matchEnd; + } + } + + this.makeChildWidgets(childParseTree); +}; + +TextNodeWidget.prototype.processTextWithMatches = function(text, currentTiddlerTitle, ignoreCase) { + var searchText = ignoreCase ? text.toLowerCase() : text; + var matches; + + try { + matches = this.tiddlerTitleInfo.ac.search(searchText); + } catch(e) { + return [{type: "plain-text", text: text}]; + } + + if(!matches || matches.length === 0) { + return [{type: "plain-text", text: text}]; + } + + matches.sort(function(a, b) { + var posDiff = a.index - b.index; + return posDiff !== 0 ? posDiff : b.length - a.length; + }); + + var processedPositions = new FastPositionSet(); + var validMatches = []; + + /* Pre-filter matches to remove overlaps */ + for(var i = 0; i < matches.length; i++) { + var match = matches[i]; + var matchStart = match.index; + var matchEnd = matchStart + match.length; + + var hasOverlap = false; + for(var pos = matchStart; pos < matchEnd && !hasOverlap; pos++) { + if(processedPositions.has(pos)) { + hasOverlap = true; } - - if(currentPos < text.length) { - newParseTree.push({ - type: "plain-text", - text: text.slice(currentPos) - }); + } + + if(!hasOverlap) { + for(var pos = matchStart; pos < matchEnd; pos++) { + processedPositions.add(pos); } - childParseTree = newParseTree; + validMatches.push(match); } } - this.makeChildWidgets(childParseTree); + if(validMatches.length === 0) { + return [{type: "plain-text", text: text}]; + } + + var newParseTree = []; + var currentPos = 0; + + for(var i = 0; i < validMatches.length; i++) { + var match = validMatches[i]; + var matchStart = match.index; + var matchEnd = matchStart + match.length; + + if(matchStart > currentPos) { + newParseTree.push({ + type: "plain-text", + text: text.slice(currentPos, matchStart) + }); + } + + var matchedTitle = this.tiddlerTitleInfo.titles[match.titleIndex]; + + if(matchedTitle === currentTiddlerTitle) { + newParseTree.push({ + type: "plain-text", + text: text.slice(matchStart, matchEnd) + }); + } else { + newParseTree.push({ + type: "link", + attributes: { + to: {type: "string", value: matchedTitle}, + "class": {type: "string", value: "tc-freelink"} + }, + children: [{ + type: "plain-text", + text: text.slice(matchStart, matchEnd) + }] + }); + } + currentPos = matchEnd; + } + + if(currentPos < text.length) { + newParseTree.push({ + type: "plain-text", + text: text.slice(currentPos) + }); + } + + return newParseTree; }; -/* -Builds optimized title search structure with chunking for memory management -*/ +/* Compute tiddler title info with sorting and filtering optimizations */ function computeTiddlerTitleInfo(self, ignoreCase) { var targetFilterText = self.wiki.getTiddlerText(TITLE_TARGET_FILTER), titles = !!targetFilterText ? @@ -175,31 +209,31 @@ function computeTiddlerTitleInfo(self, ignoreCase) { }; } - // Longer titles prioritized for better matching, with Chinese locale support - var sortedTitles = titles.sort(function(a,b) { - var lenA = a.length, - lenB = b.length; - if(lenA !== lenB) { - return lenB - lenA; - } else { - return a.localeCompare(b, 'zh', {sensitivity: 'base'}); - } - }), - validTitles = [], - chunkSize = 100; + var filteredTitles = []; + for(var i = 0; i < titles.length; i++) { + var title = titles[i]; + if(title && title.length > 0 && title.substring(0,3) !== "$:/") { + filteredTitles.push(title); + } + } + + var sortedTitles = filteredTitles.sort(function(a,b) { + var lenDiff = b.length - a.length; + return lenDiff !== 0 ? lenDiff : a.localeCompare(b, 'zh', {sensitivity: 'base'}); + }); + var validTitles = []; var ac = new AhoCorasick(); + var chunkSize = 100; - $tw.utils.each(sortedTitles,function(title, index) { - // Exclude system tiddlers from linking - if(title.substring(0,3) !== "$:/") { - var escapedTitle = escapeRegExp(title); - if(escapedTitle) { - validTitles.push(title); - ac.addPattern(ignoreCase ? title.toLowerCase() : title, validTitles.length - 1); - } + for(var i = 0; i < sortedTitles.length; i++) { + var title = sortedTitles[i]; + var escapedTitle = escapeRegExp(title); + if(escapedTitle) { + validTitles.push(title); + ac.addPattern(ignoreCase ? title.toLowerCase() : title, validTitles.length - 1); } - }); + } try { ac.buildFailureLinks(); @@ -212,7 +246,6 @@ function computeTiddlerTitleInfo(self, ignoreCase) { }; } - // Memory optimization through fixed-size chunking var titleChunks = []; for(var i = 0; i < validTitles.length; i += chunkSize) { titleChunks.push(validTitles.slice(i, i + chunkSize)); @@ -226,19 +259,17 @@ function computeTiddlerTitleInfo(self, ignoreCase) { }; } -/* -Guards against nested interactive elements which break accessibility -*/ TextNodeWidget.prototype.isWithinButtonOrLink = function() { - var withinButtonOrLink = false, - widget = this.parentWidget; - while(!withinButtonOrLink && widget) { - withinButtonOrLink = widget instanceof ButtonWidget || - widget instanceof LinkWidget || - ((widget instanceof ElementWidget) && widget.parseTreeNode.tag === "a"); + var widget = this.parentWidget; + while(widget) { + if(widget instanceof ButtonWidget || + widget instanceof LinkWidget || + ((widget instanceof ElementWidget) && widget.parseTreeNode.tag === "a")) { + return true; + } widget = widget.parentWidget; } - return withinButtonOrLink; + return false; }; TextNodeWidget.prototype.refresh = function(changedTiddlers) { @@ -246,30 +277,33 @@ TextNodeWidget.prototype.refresh = function(changedTiddlers) { changedAttributes = this.computeAttributes(), titlesHaveChanged = false; - $tw.utils.each(changedTiddlers,function(change,title) { - if(change.isDeleted) { - titlesHaveChanged = true; - } else { - titlesHaveChanged = titlesHaveChanged || - !self.tiddlerTitleInfo || - self.tiddlerTitleInfo.titles.indexOf(title) === -1; - } - }); + if(changedTiddlers) { + $tw.utils.each(changedTiddlers,function(change,title) { + if(change.isDeleted) { + titlesHaveChanged = true; + } else { + titlesHaveChanged = titlesHaveChanged || + !self.tiddlerTitleInfo || + self.tiddlerTitleInfo.titles.indexOf(title) === -1; + } + }); + } if(changedAttributes.text || titlesHaveChanged) { - // Cache invalidation strategy for persistent vs session caches - var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; - var ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}) === "yes"; - var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); - - if(titlesHaveChanged && persistCache) { - self.wiki.clearCache(cacheKey); + if(titlesHaveChanged) { + var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; + var ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}) === "yes"; + var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); + + if(persistCache) { + self.wiki.clearCache(cacheKey); + } } this.refreshSelf(); return true; } else { - return false; + return this.refreshChildren(changedTiddlers); } }; From 5f0b98d1fd50c71db4f71be25bbfaf0e0d6a6765 Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 00:11:13 +0800 Subject: [PATCH 23/70] optimized --- plugins/tiddlywiki/freelinks/aho-corasick.js | 134 +++++++++++-------- 1 file changed, 78 insertions(+), 56 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index 709c7568ab9..396b1520038 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -3,33 +3,27 @@ title: $:/core/modules/utils/aho-corasick.js type: application/javascript module-type: utils -Aho-Corasick string matching algorithm implementation with enhanced error handling -for TiddlyWiki freelinking functionality. +Optimized Aho-Corasick string matching algorithm implementation with enhanced performance +and error handling for TiddlyWiki freelinking functionality. \*/ "use strict"; -/** - * Aho-Corasick implementation with enhanced error handling - * @constructor - */ +/* Optimized Aho-Corasick implementation with performance enhancements */ function AhoCorasick() { this.trie = {}; this.failure = {}; - this.output = {}; + this.maxFailureDepth = 100; } -/** - * Add a pattern to the Aho-Corasick automaton - * @param {string} pattern - The pattern to add - * @param {number} index - The index of the pattern for identification - */ AhoCorasick.prototype.addPattern = function(pattern, index) { - if(!pattern || typeof pattern !== "string") { + if(!pattern || typeof pattern !== "string" || pattern.length === 0) { return; } + var node = this.trie; + for(var i = 0; i < pattern.length; i++) { var char = pattern[i]; if(!node[char]) { @@ -37,21 +31,23 @@ AhoCorasick.prototype.addPattern = function(pattern, index) { } node = node[char]; } + if(!node.$) { node.$ = []; } - node.$.push({ pattern: pattern, index: index }); + node.$.push({ + pattern: pattern, + index: index, + length: pattern.length + }); }; -/** - * Build failure links for the Aho-Corasick automaton - */ +/* Build failure links with depth and node count limits */ AhoCorasick.prototype.buildFailureLinks = function() { var queue = []; var root = this.trie; this.failure[root] = root; - // Initialize first level failure links for(var char in root) { if(root[char] && char !== '$') { this.failure[root[char]] = root; @@ -59,91 +55,117 @@ AhoCorasick.prototype.buildFailureLinks = function() { } } - var maxIterations = 100000; // Prevent infinite loops - var iteration = 0; + var processedNodes = 0; + var maxNodes = 100000; - while(queue.length && iteration < maxIterations) { + while(queue.length > 0 && processedNodes < maxNodes) { var node = queue.shift(); + processedNodes++; + for(var char in node) { if(node[char] && char !== '$') { var child = node[char]; var fail = this.failure[node]; - var failCount = 0; - var maxFailCount = 1000; // Prevent deep failure chains + var failureDepth = 0; - while(fail && !fail[char] && failCount < maxFailCount) { + while(fail && !fail[char] && failureDepth < this.maxFailureDepth) { fail = this.failure[fail]; - failCount++; + failureDepth++; } - this.failure[child] = fail[char] || this.trie; + var failureLink = (fail && fail[char]) ? fail[char] : root; + this.failure[child] = failureLink; - // Copy output from failure link - if(this.failure[child].$) { + var failureOutput = this.failure[child]; + if(failureOutput && failureOutput.$) { if(!child.$) { child.$ = []; } - child.$.push.apply(child.$, this.failure[child].$); + child.$.push.apply(child.$, failureOutput.$); } queue.push(child); } } - iteration++; } - if(iteration >= maxIterations) { - throw new Error("Aho-Corasick: buildFailureLinks exceeded max iterations"); + if(processedNodes >= maxNodes) { + throw new Error("Aho-Corasick: buildFailureLinks exceeded maximum nodes"); } }; -/** - * Search for all patterns in the given text - * @param {string} text - The text to search in - * @returns {Array} Array of match objects with pattern, index, length, and titleIndex properties - */ AhoCorasick.prototype.search = function(text) { - if(!text || typeof text !== "string") { + if(!text || typeof text !== "string" || text.length === 0) { return []; } var matches = []; var node = this.trie; - var maxIterations = text.length * 10; // Prevent infinite loops - var iteration = 0; + var textLength = text.length; + var maxMatches = Math.min(textLength * 2, 10000); - for(var i = 0; i < text.length && iteration < maxIterations; i++) { + for(var i = 0; i < textLength; i++) { var char = text[i]; var transitionCount = 0; - var maxTransitionCount = 1000; // Prevent deep transitions - while(node && !node[char] && transitionCount < maxTransitionCount) { + while(node && !node[char] && transitionCount < this.maxFailureDepth) { node = this.failure[node]; transitionCount++; } - node = node[char] || this.trie; + node = (node && node[char]) ? node[char] : this.trie; - if(node.$) { - for(var j = 0; j < node.$.length; j++) { - var match = node.$[j]; + if(node && node.$) { + var outputs = node.$; + for(var j = 0; j < outputs.length && matches.length < maxMatches; j++) { + var output = outputs[j]; matches.push({ - pattern: match.pattern, - index: i - match.pattern.length + 1, - length: match.pattern.length, - titleIndex: match.index + pattern: output.pattern, + index: i - output.length + 1, + length: output.length, + titleIndex: output.index }); } } - iteration++; } - if(iteration >= maxIterations) { - throw new Error("Aho-Corasick: search exceeded max iterations"); + return matches; +}; + +AhoCorasick.prototype.clear = function() { + this.trie = {}; + this.failure = {}; +}; + +AhoCorasick.prototype.getStats = function() { + var nodeCount = 0; + var patternCount = 0; + var failureCount = 0; + + function countNodes(node) { + if(!node) return; + nodeCount++; + if(node.$) { + patternCount += node.$.length; + } + for(var key in node) { + if(node[key] && typeof node[key] === 'object' && key !== '$') { + countNodes(node[key]); + } + } } - return matches; + countNodes(this.trie); + + for(var key in this.failure) { + failureCount++; + } + + return { + nodeCount: nodeCount, + patternCount: patternCount, + failureLinks: failureCount + }; }; -// Export the AhoCorasick constructor exports.AhoCorasick = AhoCorasick; From b022fcf7ddc383bffefe5bc2cc3cc29b8d55b94c Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 00:17:57 +0800 Subject: [PATCH 24/70] add setting for "Persist AhoCorasick cache" --- plugins/tiddlywiki/freelinks/settings.tid | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/tiddlywiki/freelinks/settings.tid b/plugins/tiddlywiki/freelinks/settings.tid index 70eaae4b331..4fadda9bbb0 100644 --- a/plugins/tiddlywiki/freelinks/settings.tid +++ b/plugins/tiddlywiki/freelinks/settings.tid @@ -7,3 +7,5 @@ Filter defining tiddlers to which freelinks are made: <$edit-text tiddler="$:/co <$checkbox tiddler="$:/config/Freelinks/Enable" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/Enable">Enable freelinking within tiddler view templates <$checkbox tiddler="$:/config/Freelinks/IgnoreCase" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/IgnoreCase">Ignore case + +<$checkbox tiddler="$:/config/Freelinks/PersistAhoCorasickCache" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/PersistAhoCorasickCache">Persist AhoCorasick cache for performance From c0de611ee6c55812a1a8d39699ebdabb483179e7 Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 00:57:45 +0800 Subject: [PATCH 25/70] add dynamic limits --- plugins/tiddlywiki/freelinks/aho-corasick.js | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index 396b1520038..4043b32c59b 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -3,8 +3,8 @@ title: $:/core/modules/utils/aho-corasick.js type: application/javascript module-type: utils -Optimized Aho-Corasick string matching algorithm implementation with enhanced performance -and error handling for TiddlyWiki freelinking functionality. +Optimized Aho-Corasick string matching algorithm implementation with dynamic limits +and performance enhancements for TiddlyWiki freelinking functionality. \*/ @@ -15,6 +15,7 @@ function AhoCorasick() { this.trie = {}; this.failure = {}; this.maxFailureDepth = 100; + this.titlesLength = titlesLength || 0; } AhoCorasick.prototype.addPattern = function(pattern, index) { @@ -56,7 +57,7 @@ AhoCorasick.prototype.buildFailureLinks = function() { } var processedNodes = 0; - var maxNodes = 100000; + var maxNodes = Math.min(200000, this.titlesLength * 15); while(queue.length > 0 && processedNodes < maxNodes) { var node = queue.shift(); @@ -90,7 +91,7 @@ AhoCorasick.prototype.buildFailureLinks = function() { } if(processedNodes >= maxNodes) { - throw new Error("Aho-Corasick: buildFailureLinks exceeded maximum nodes"); + throw new Error("Aho-Corasick: buildFailureLinks exceeded dynamic max nodes: " + maxNodes); } }; @@ -102,19 +103,22 @@ AhoCorasick.prototype.search = function(text) { var matches = []; var node = this.trie; var textLength = text.length; - var maxMatches = Math.min(textLength * 2, 10000); + var maxMatches = Math.min(textLength * 3, this.titlesLength * 2); for(var i = 0; i < textLength; i++) { var char = text[i]; var transitionCount = 0; while(node && !node[char] && transitionCount < this.maxFailureDepth) { + var cachedNode = node[char] || (node[char] = this.trie); // 快取當前節點 + if (cachedNode) { + node = cachedNode; + break; + } node = this.failure[node]; transitionCount++; } - - node = (node && node[char]) ? node[char] : this.trie; - + if(node && node.$) { var outputs = node.$; for(var j = 0; j < outputs.length && matches.length < maxMatches; j++) { From 43f45210f19fcb2410e4fa3e0ce2901e760f907a Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 01:00:24 +0800 Subject: [PATCH 26/70] remove comment --- plugins/tiddlywiki/freelinks/aho-corasick.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index 4043b32c59b..3da5bee7f99 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -110,7 +110,7 @@ AhoCorasick.prototype.search = function(text) { var transitionCount = 0; while(node && !node[char] && transitionCount < this.maxFailureDepth) { - var cachedNode = node[char] || (node[char] = this.trie); // 快取當前節點 + var cachedNode = node[char] || (node[char] = this.trie); if (cachedNode) { node = cachedNode; break; From a27c8671bcf5d660a0acb475cca332500127cddb Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 01:24:47 +0800 Subject: [PATCH 27/70] revert to 5f0b98d1fd50c71db4f71be25bbfaf0e0d6a6765 --- plugins/tiddlywiki/freelinks/aho-corasick.js | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index 3da5bee7f99..396b1520038 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -3,8 +3,8 @@ title: $:/core/modules/utils/aho-corasick.js type: application/javascript module-type: utils -Optimized Aho-Corasick string matching algorithm implementation with dynamic limits -and performance enhancements for TiddlyWiki freelinking functionality. +Optimized Aho-Corasick string matching algorithm implementation with enhanced performance +and error handling for TiddlyWiki freelinking functionality. \*/ @@ -15,7 +15,6 @@ function AhoCorasick() { this.trie = {}; this.failure = {}; this.maxFailureDepth = 100; - this.titlesLength = titlesLength || 0; } AhoCorasick.prototype.addPattern = function(pattern, index) { @@ -57,7 +56,7 @@ AhoCorasick.prototype.buildFailureLinks = function() { } var processedNodes = 0; - var maxNodes = Math.min(200000, this.titlesLength * 15); + var maxNodes = 100000; while(queue.length > 0 && processedNodes < maxNodes) { var node = queue.shift(); @@ -91,7 +90,7 @@ AhoCorasick.prototype.buildFailureLinks = function() { } if(processedNodes >= maxNodes) { - throw new Error("Aho-Corasick: buildFailureLinks exceeded dynamic max nodes: " + maxNodes); + throw new Error("Aho-Corasick: buildFailureLinks exceeded maximum nodes"); } }; @@ -103,22 +102,19 @@ AhoCorasick.prototype.search = function(text) { var matches = []; var node = this.trie; var textLength = text.length; - var maxMatches = Math.min(textLength * 3, this.titlesLength * 2); + var maxMatches = Math.min(textLength * 2, 10000); for(var i = 0; i < textLength; i++) { var char = text[i]; var transitionCount = 0; while(node && !node[char] && transitionCount < this.maxFailureDepth) { - var cachedNode = node[char] || (node[char] = this.trie); - if (cachedNode) { - node = cachedNode; - break; - } node = this.failure[node]; transitionCount++; } - + + node = (node && node[char]) ? node[char] : this.trie; + if(node && node.$) { var outputs = node.$; for(var j = 0; j < outputs.length && matches.length < maxMatches; j++) { From 1de0e70d5516211669d0e4968f06f4b22824c181 Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 13:44:36 +0800 Subject: [PATCH 28/70] try sort alphabet --- editions/tw5.com/tiddlywiki.info | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/editions/tw5.com/tiddlywiki.info b/editions/tw5.com/tiddlywiki.info index 5715e3ccc4c..eab8641956d 100644 --- a/editions/tw5.com/tiddlywiki.info +++ b/editions/tw5.com/tiddlywiki.info @@ -4,11 +4,11 @@ "tiddlywiki/browser-sniff", "tiddlywiki/confetti", "tiddlywiki/dynannotate", + "tiddlywiki/freelink", "tiddlywiki/internals", "tiddlywiki/menubar", "tiddlywiki/railroad", - "tiddlywiki/tour", - "tiddlywiki/freelink" + "tiddlywiki/tour" ], "themes": [ "tiddlywiki/vanilla", From 21dde769ea0a1cce57e8896fbb60f0614fa75d84 Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 13:45:15 +0800 Subject: [PATCH 29/70] try sort alphabet --- editions/tw5.com-server/tiddlywiki.info | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/editions/tw5.com-server/tiddlywiki.info b/editions/tw5.com-server/tiddlywiki.info index bf524a535f3..d2851571886 100644 --- a/editions/tw5.com-server/tiddlywiki.info +++ b/editions/tw5.com-server/tiddlywiki.info @@ -3,9 +3,9 @@ "plugins": [ "tiddlywiki/tiddlyweb", "tiddlywiki/filesystem", + "tiddlywiki/freelink", "tiddlywiki/highlight", - "tiddlywiki/internals", - "tiddlywiki/freelink" + "tiddlywiki/internals" ], "themes": [ "tiddlywiki/vanilla", From 873e23879a9a9400f7a09237929bc0b442de7676 Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 13:48:01 +0800 Subject: [PATCH 30/70] try sort alphabet --- editions/prerelease/tiddlywiki.info | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/editions/prerelease/tiddlywiki.info b/editions/prerelease/tiddlywiki.info index bd12d391cca..e70e8307a86 100644 --- a/editions/prerelease/tiddlywiki.info +++ b/editions/prerelease/tiddlywiki.info @@ -1,21 +1,20 @@ { "description": "Content for the current prerelease", "plugins": [ - "tiddlywiki/browser-sniff", - "tiddlywiki/help", - "tiddlywiki/powered-by-tiddlywiki", - "tiddlywiki/internals", - "tiddlywiki/highlight", "tiddlywiki/bibtex", - "tiddlywiki/dynaview", - "tiddlywiki/dynannotate", + "tiddlywiki/browser-sniff", "tiddlywiki/codemirror", - "tiddlywiki/menubar", - "tiddlywiki/jszip", "tiddlywiki/confetti", "tiddlywiki/dynannotate", - "tiddlywiki/tour", - "tiddlywiki/freelink" + "tiddlywiki/dynaview", + "tiddlywiki/freelink", + "tiddlywiki/help", + "tiddlywiki/highlight", + "tiddlywiki/internals", + "tiddlywiki/jszip", + "tiddlywiki/menubar", + "tiddlywiki/powered-by-tiddlywiki", + "tiddlywiki/tour" ], "themes": [ "tiddlywiki/vanilla", From 5cff76129eb0647455bb934fb10c1563183d4b61 Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 14:13:11 +0800 Subject: [PATCH 31/70] typo freelink -> freelinks --- editions/tw5.com/tiddlywiki.info | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editions/tw5.com/tiddlywiki.info b/editions/tw5.com/tiddlywiki.info index eab8641956d..cce3ff1fd5d 100644 --- a/editions/tw5.com/tiddlywiki.info +++ b/editions/tw5.com/tiddlywiki.info @@ -4,7 +4,7 @@ "tiddlywiki/browser-sniff", "tiddlywiki/confetti", "tiddlywiki/dynannotate", - "tiddlywiki/freelink", + "tiddlywiki/freelinks", "tiddlywiki/internals", "tiddlywiki/menubar", "tiddlywiki/railroad", From e832a5b57879f517e51319b9675a305e0b1b4e04 Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 14:14:40 +0800 Subject: [PATCH 32/70] typo freelink -> freelinks --- editions/tw5.com-server/tiddlywiki.info | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editions/tw5.com-server/tiddlywiki.info b/editions/tw5.com-server/tiddlywiki.info index d2851571886..8d2b44ea748 100644 --- a/editions/tw5.com-server/tiddlywiki.info +++ b/editions/tw5.com-server/tiddlywiki.info @@ -3,7 +3,7 @@ "plugins": [ "tiddlywiki/tiddlyweb", "tiddlywiki/filesystem", - "tiddlywiki/freelink", + "tiddlywiki/freelinks", "tiddlywiki/highlight", "tiddlywiki/internals" ], From 5330d091a1079cc680b90327f2eda7bf5d617964 Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 14:15:02 +0800 Subject: [PATCH 33/70] typo freelink -> freelinks --- editions/prerelease/tiddlywiki.info | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editions/prerelease/tiddlywiki.info b/editions/prerelease/tiddlywiki.info index e70e8307a86..577da2a7994 100644 --- a/editions/prerelease/tiddlywiki.info +++ b/editions/prerelease/tiddlywiki.info @@ -7,7 +7,7 @@ "tiddlywiki/confetti", "tiddlywiki/dynannotate", "tiddlywiki/dynaview", - "tiddlywiki/freelink", + "tiddlywiki/freelinks", "tiddlywiki/help", "tiddlywiki/highlight", "tiddlywiki/internals", From 167f64ce610c801d6532e6d2675cefa272d2e7ae Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 14:42:28 +0800 Subject: [PATCH 34/70] Update readme.tid --- plugins/tiddlywiki/freelinks/readme.tid | 32 +++++++++++++++---------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/readme.tid b/plugins/tiddlywiki/freelinks/readme.tid index 5372ce019aa..15fa153a450 100644 --- a/plugins/tiddlywiki/freelinks/readme.tid +++ b/plugins/tiddlywiki/freelinks/readme.tid @@ -1,42 +1,50 @@ title: $:/plugins/tiddlywiki/freelinks/readme -This plugin adds automatic generation of links to tiddler titles. +This plugin enhances TiddlyWiki by automatically generating links to existing tiddler titles within text, improving navigation and connectivity. -''Note that automatic link generation can be very slow when there are a large number of tiddlers''. +''Note: Automatic link generation may experience performance slowdowns with a large number of tiddlers. For optimal performance, consider enabling the persistent cache option in the settings.'' -Freelinking is activated for runs of text that have the following variables set: +Freelinking is activated for text runs where the following variables are configured: -* `tv-wikilinks` is NOT equal to `no` +* `tv-wikilinks` is NOT set to `no` * `tv-freelinks` is set to `yes` -Freelinks are case sensitive by default but can be configured to ignore case in the settings tab. +By default, freelinking is case-sensitive, but this can be toggled to ignore case via the settings tab. Additionally, a persistent Aho-Corasick cache can be enabled to boost performance, configurable under the Freelinks settings panel. -Within view templates, the variable `tv-freelinks` is automatically set to the content of $:/config/Freelinks/Enable, which can be set via the settings panel of this plugin. +Within view templates, the variable `tv-freelinks` is automatically set to the value of `$:/config/Freelinks/Enable`, which can be adjusted through the plugin's settings interface. -!! Notes +!! Configuration Notes -To change within which tiddlers freelinking occurs requires customising the shadow tiddler [[$:/plugins/tiddlywiki/freelinks/macros/view]]. This tiddler is tagged $:/tags/Macro/View which means that it will be included as a local macro in each view template. By default, its content is: +To customize which tiddlers trigger freelinking, modify the shadow tiddler [[$:/plugins/tiddlywiki/freelinks/macros/view]]. This tiddler is tagged `$:/tags/Macro/View`, ensuring it acts as a local macro in each view template. The default content is: ``` <$set name="tv-freelinks" value={{$:/config/Freelinks/Enable}}/> ``` -That means that for each tiddler the variable tv-freelinks will be set to the tiddler $:/config/Freelinks/Enable, which is set to "yes" or "no" by the settings in control panel. +This sets `tv-freelinks` to the value of `$:/config/Freelinks/Enable` (either "yes" or "no") for each tiddler, as defined in the control panel settings. -Instead, we can use a filter expression to, say, only freelink within the tiddler with the title "HelloThere": +For more specific control, you can use filter expressions. For example, to enable freelinking only within the tiddler titled "HelloThere": ``` <$set name="tv-freelinks" value={{{ [match[HelloThere]then[yes]else[no]] }}}/> ``` -Or, we can make a filter that will only freelink within tiddlers with the tag "MyTag": +To limit freelinking to tiddlers tagged "MyTag": ``` <$set name="tv-freelinks" value={{{ [tag[MyTags]then[yes]else[no]] }}}/> ``` -Or we can combine both approaches: +You can also combine conditions, such as freelinking only for "HelloThere" with the "MyTag" tag: ``` <$set name="tv-freelinks" value={{{ [match[HelloThere]] ~[tag[MyTag]] +[then[yes]else[no]] }}}/> ``` + +!! Additional Features +- Use the settings panel to enable a persistent cache, leveraging an optimized Aho-Corasick algorithm to handle large tiddler sets efficiently. +- Adjust the target filter (`$:/config/Freelinks/TargetFilter`) to define which tiddlers are considered for linking. + +- Powered by Grok & Claude, enhancing development with advanced AI assistance. + +For further customization or troubleshooting, refer to the plugin's source code or consult the TiddlyWiki community. From 7270600f682668684f87ffed4ea3dbfb41a50a78 Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 23:55:04 +0800 Subject: [PATCH 35/70] Update aho-corasick.js Dynamically adjust limit parameters to avoid problems caused by hard-coded limits. --- plugins/tiddlywiki/freelinks/aho-corasick.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index 396b1520038..785d12253a4 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -15,6 +15,7 @@ function AhoCorasick() { this.trie = {}; this.failure = {}; this.maxFailureDepth = 100; + this.patternCount = 0; } AhoCorasick.prototype.addPattern = function(pattern, index) { @@ -40,6 +41,8 @@ AhoCorasick.prototype.addPattern = function(pattern, index) { index: index, length: pattern.length }); + + this.patternCount++; }; /* Build failure links with depth and node count limits */ @@ -56,7 +59,7 @@ AhoCorasick.prototype.buildFailureLinks = function() { } var processedNodes = 0; - var maxNodes = 100000; + var maxNodes = Math.max(100000, this.patternCount * 15); while(queue.length > 0 && processedNodes < maxNodes) { var node = queue.shift(); @@ -90,7 +93,7 @@ AhoCorasick.prototype.buildFailureLinks = function() { } if(processedNodes >= maxNodes) { - throw new Error("Aho-Corasick: buildFailureLinks exceeded maximum nodes"); + throw new Error("Aho-Corasick: buildFailureLinks exceeded maximum nodes (" + maxNodes + ")"); } }; @@ -135,6 +138,7 @@ AhoCorasick.prototype.search = function(text) { AhoCorasick.prototype.clear = function() { this.trie = {}; this.failure = {}; + this.patternCount = 0; }; AhoCorasick.prototype.getStats = function() { @@ -163,7 +167,7 @@ AhoCorasick.prototype.getStats = function() { return { nodeCount: nodeCount, - patternCount: patternCount, + patternCount: this.patternCount, failureLinks: failureCount }; }; From ac72f0b47df1e79375c2ce7b705bc89dced356a6 Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 12 Jun 2025 23:56:01 +0800 Subject: [PATCH 36/70] Update text.js Dynamically adjust limit parameters to avoid problems caused by hard-coded limits. --- plugins/tiddlywiki/freelinks/text.js | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index c238d2beed9..737e8e778b9 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -193,7 +193,7 @@ TextNodeWidget.prototype.processTextWithMatches = function(text, currentTiddlerT return newParseTree; }; -/* Compute tiddler title info with sorting and filtering optimizations */ +/* Compute tiddler title info with dynamic scaling */ function computeTiddlerTitleInfo(self, ignoreCase) { var targetFilterText = self.wiki.getTiddlerText(TITLE_TARGET_FILTER), titles = !!targetFilterText ? @@ -203,9 +203,7 @@ function computeTiddlerTitleInfo(self, ignoreCase) { if(!titles || titles.length === 0) { return { titles: [], - ac: new AhoCorasick(), - chunkSize: 100, - titleChunks: [] + ac: new AhoCorasick() }; } @@ -224,7 +222,6 @@ function computeTiddlerTitleInfo(self, ignoreCase) { var validTitles = []; var ac = new AhoCorasick(); - var chunkSize = 100; for(var i = 0; i < sortedTitles.length; i++) { var title = sortedTitles[i]; @@ -240,22 +237,13 @@ function computeTiddlerTitleInfo(self, ignoreCase) { } catch(e) { return { titles: [], - ac: new AhoCorasick(), - chunkSize: 100, - titleChunks: [] + ac: new AhoCorasick() }; } - var titleChunks = []; - for(var i = 0; i < validTitles.length; i += chunkSize) { - titleChunks.push(validTitles.slice(i, i + chunkSize)); - } - return { titles: validTitles, - ac: ac, - chunkSize: chunkSize, - titleChunks: titleChunks + ac: ac }; } From 43685abe5d9e26b696d019bd807d49b018c50291 Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 00:05:19 +0800 Subject: [PATCH 37/70] Update tiddlywiki.info remove other plugin for test plugin conflict --- editions/prerelease/tiddlywiki.info | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/editions/prerelease/tiddlywiki.info b/editions/prerelease/tiddlywiki.info index 577da2a7994..7a6d1559f8e 100644 --- a/editions/prerelease/tiddlywiki.info +++ b/editions/prerelease/tiddlywiki.info @@ -1,20 +1,10 @@ { "description": "Content for the current prerelease", "plugins": [ - "tiddlywiki/bibtex", - "tiddlywiki/browser-sniff", - "tiddlywiki/codemirror", - "tiddlywiki/confetti", - "tiddlywiki/dynannotate", - "tiddlywiki/dynaview", "tiddlywiki/freelinks", "tiddlywiki/help", - "tiddlywiki/highlight", "tiddlywiki/internals", - "tiddlywiki/jszip", - "tiddlywiki/menubar", - "tiddlywiki/powered-by-tiddlywiki", - "tiddlywiki/tour" + "tiddlywiki/powered-by-tiddlywiki" ], "themes": [ "tiddlywiki/vanilla", From d398f59d4535d3b321f5365d01e3faa48132c5ce Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 00:26:35 +0800 Subject: [PATCH 38/70] Update tiddlywiki.info --- editions/prerelease/tiddlywiki.info | 1 + 1 file changed, 1 insertion(+) diff --git a/editions/prerelease/tiddlywiki.info b/editions/prerelease/tiddlywiki.info index 7a6d1559f8e..59963f81e89 100644 --- a/editions/prerelease/tiddlywiki.info +++ b/editions/prerelease/tiddlywiki.info @@ -4,6 +4,7 @@ "tiddlywiki/freelinks", "tiddlywiki/help", "tiddlywiki/internals", + "tiddlywiki/menubar", "tiddlywiki/powered-by-tiddlywiki" ], "themes": [ From 4c8afd1006b1ee6244705297cec4ba31ac6e4678 Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 07:40:36 +0800 Subject: [PATCH 39/70] Update tiddlywiki.info --- editions/tw5.com/tiddlywiki.info | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/editions/tw5.com/tiddlywiki.info b/editions/tw5.com/tiddlywiki.info index cce3ff1fd5d..e0fef8b5d67 100644 --- a/editions/tw5.com/tiddlywiki.info +++ b/editions/tw5.com/tiddlywiki.info @@ -1,14 +1,7 @@ { "description": "Documentation from https://tiddlywiki.com", "plugins": [ - "tiddlywiki/browser-sniff", - "tiddlywiki/confetti", - "tiddlywiki/dynannotate", - "tiddlywiki/freelinks", - "tiddlywiki/internals", - "tiddlywiki/menubar", - "tiddlywiki/railroad", - "tiddlywiki/tour" + "tiddlywiki/freelinks" ], "themes": [ "tiddlywiki/vanilla", From 291d511664dc83b8ae0985d62b4cabcd4decef07 Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 16:24:02 +0800 Subject: [PATCH 40/70] Update aho-corasick.js Description of major changes Improve state transition logic - Ensure to go back to root node correctly in case of mismatch, and check root node for current character transition Fix failed link traversal - Add condition node ! == this.trie to avoid infinite loop at root node Enhance output collection - collect output not only from current node, but also from all nodes on failed link path, which is key to Aho-Corasick algorithm Add safety limit - collectCount < 10 to prevent failed link loops Translated with DeepL.com (free version) --- plugins/tiddlywiki/freelinks/aho-corasick.js | 49 ++++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index 785d12253a4..92969261650 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -10,7 +10,6 @@ and error handling for TiddlyWiki freelinking functionality. "use strict"; -/* Optimized Aho-Corasick implementation with performance enhancements */ function AhoCorasick() { this.trie = {}; this.failure = {}; @@ -45,12 +44,12 @@ AhoCorasick.prototype.addPattern = function(pattern, index) { this.patternCount++; }; -/* Build failure links with depth and node count limits */ AhoCorasick.prototype.buildFailureLinks = function() { var queue = []; var root = this.trie; this.failure[root] = root; + /* 初始化根節點的直接子節點 */ for(var char in root) { if(root[char] && char !== '$') { this.failure[root[char]] = root; @@ -71,6 +70,7 @@ AhoCorasick.prototype.buildFailureLinks = function() { var fail = this.failure[node]; var failureDepth = 0; + /* 尋找適當的失敗連結 */ while(fail && !fail[char] && failureDepth < this.maxFailureDepth) { fail = this.failure[fail]; failureDepth++; @@ -79,6 +79,7 @@ AhoCorasick.prototype.buildFailureLinks = function() { var failureLink = (fail && fail[char]) ? fail[char] : root; this.failure[child] = failureLink; + /* 複製失敗連結節點的輸出 */ var failureOutput = this.failure[child]; if(failureOutput && failureOutput.$) { if(!child.$) { @@ -111,24 +112,42 @@ AhoCorasick.prototype.search = function(text) { var char = text[i]; var transitionCount = 0; - while(node && !node[char] && transitionCount < this.maxFailureDepth) { - node = this.failure[node]; + /* 跟隨失敗連結直到找到匹配或回到根節點 */ + while(node && !node[char] && node !== this.trie && transitionCount < this.maxFailureDepth) { + node = this.failure[node] || this.trie; transitionCount++; } - node = (node && node[char]) ? node[char] : this.trie; + /* 確保狀態轉換正確 */ + if(node && node[char]) { + node = node[char]; + } else { + node = this.trie; + /* 檢查根節點是否有這個字符的轉換 */ + if(this.trie[char]) { + node = this.trie[char]; + } + } - if(node && node.$) { - var outputs = node.$; - for(var j = 0; j < outputs.length && matches.length < maxMatches; j++) { - var output = outputs[j]; - matches.push({ - pattern: output.pattern, - index: i - output.length + 1, - length: output.length, - titleIndex: output.index - }); + /* 收集當前節點及其失敗連結的所有匹配輸出 */ + var currentNode = node; + var collectCount = 0; + while(currentNode && collectCount < 10) { + if(currentNode.$) { + var outputs = currentNode.$; + for(var j = 0; j < outputs.length && matches.length < maxMatches; j++) { + var output = outputs[j]; + matches.push({ + pattern: output.pattern, + index: i - output.length + 1, + length: output.length, + titleIndex: output.index + }); + } } + currentNode = this.failure[currentNode]; + if(currentNode === this.trie) break; + collectCount++; } } From adef812043fd616924b95502ee06b766aab19e1d Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 16:53:25 +0800 Subject: [PATCH 41/70] Update aho-corasick.js Word Boundary Check - The isWordBoundaryMatch function checks if the match is on a word boundary: Alphanumeric characters [a-zA-Z0-9_] are regarded as unicode characters At least one non-unicode character must be present before and after the match for it to be considered valid. --- plugins/tiddlywiki/freelinks/aho-corasick.js | 29 ++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index 92969261650..ea8975b3b18 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -98,7 +98,7 @@ AhoCorasick.prototype.buildFailureLinks = function() { } }; -AhoCorasick.prototype.search = function(text) { +AhoCorasick.prototype.search = function(text, useWordBoundary) { if(!text || typeof text !== "string" || text.length === 0) { return []; } @@ -137,9 +137,17 @@ AhoCorasick.prototype.search = function(text) { var outputs = currentNode.$; for(var j = 0; j < outputs.length && matches.length < maxMatches; j++) { var output = outputs[j]; + var matchStart = i - output.length + 1; + var matchEnd = i + 1; + + /* 檢查單字邊界 */ + if(useWordBoundary && !this.isWordBoundaryMatch(text, matchStart, matchEnd)) { + continue; + } + matches.push({ pattern: output.pattern, - index: i - output.length + 1, + index: matchStart, length: output.length, titleIndex: output.index }); @@ -154,6 +162,23 @@ AhoCorasick.prototype.search = function(text) { return matches; }; +/* 檢查是否為有效的單字邊界匹配 */ +AhoCorasick.prototype.isWordBoundaryMatch = function(text, start, end) { + var beforeChar = start > 0 ? text[start - 1] : ''; + var afterChar = end < text.length ? text[end] : ''; + + /* 西文單字邊界:字母數字與非字母數字的交界 */ + var isWordChar = function(char) { + return /[a-zA-Z0-9_]/.test(char); + }; + + var beforeIsWord = beforeChar && isWordChar(beforeChar); + var afterIsWord = afterChar && isWordChar(afterChar); + + /* 匹配的文字前後至少要有一個非單字字符 */ + return !beforeIsWord || !afterIsWord; +}; + AhoCorasick.prototype.clear = function() { this.trie = {}; this.failure = {}; From 85826f2cfef467d40402e484c4316bfe8b16d199 Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 16:53:51 +0800 Subject: [PATCH 42/70] Update text.js Word Boundary Check - The isWordBoundaryMatch function checks if the match is on a word boundary: Alphanumeric characters [a-zA-Z0-9_] are regarded as unicode characters At least one non-unicode character must be present before and after the match for it to be considered valid. --- plugins/tiddlywiki/freelinks/text.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 737e8e778b9..94e5328213d 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -11,6 +11,7 @@ An optimized override of the core text widget that automatically linkifies the t var TITLE_TARGET_FILTER = "$:/config/Freelinks/TargetFilter"; var PERSIST_CACHE_TIDDLER = "$:/config/Freelinks/PersistAhoCorasickCache"; +var WORD_BOUNDARY_TIDDLER = "$:/config/Freelinks/WordBoundary"; var Widget = require("$:/core/modules/widgets/widget.js").widget, LinkWidget = require("$:/core/modules/widgets/link.js").link, @@ -28,7 +29,6 @@ function escapeRegExp(str) { } } -/* Fast Set-like structure using native Set for reliability */ function FastPositionSet() { this.set = new Set(); } @@ -75,6 +75,7 @@ TextNodeWidget.prototype.execute = function() { !this.isWithinButtonOrLink()) { var currentTiddlerTitle = this.getVariable("currentTiddler") || ""; + var useWordBoundary = self.wiki.getTiddlerText(WORD_BOUNDARY_TIDDLER, "no") === "yes"; var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); @@ -88,7 +89,7 @@ TextNodeWidget.prototype.execute = function() { }); if(this.tiddlerTitleInfo.titles.length > 0) { - var newParseTree = this.processTextWithMatches(text, currentTiddlerTitle, ignoreCase); + var newParseTree = this.processTextWithMatches(text, currentTiddlerTitle, ignoreCase, useWordBoundary); if(newParseTree.length > 1 || newParseTree[0].type !== "plain-text") { childParseTree = newParseTree; } @@ -98,12 +99,12 @@ TextNodeWidget.prototype.execute = function() { this.makeChildWidgets(childParseTree); }; -TextNodeWidget.prototype.processTextWithMatches = function(text, currentTiddlerTitle, ignoreCase) { +TextNodeWidget.prototype.processTextWithMatches = function(text, currentTiddlerTitle, ignoreCase, useWordBoundary) { var searchText = ignoreCase ? text.toLowerCase() : text; var matches; try { - matches = this.tiddlerTitleInfo.ac.search(searchText); + matches = this.tiddlerTitleInfo.ac.search(searchText, useWordBoundary); } catch(e) { return [{type: "plain-text", text: text}]; } @@ -120,7 +121,6 @@ TextNodeWidget.prototype.processTextWithMatches = function(text, currentTiddlerT var processedPositions = new FastPositionSet(); var validMatches = []; - /* Pre-filter matches to remove overlaps */ for(var i = 0; i < matches.length; i++) { var match = matches[i]; var matchStart = match.index; @@ -193,7 +193,6 @@ TextNodeWidget.prototype.processTextWithMatches = function(text, currentTiddlerT return newParseTree; }; -/* Compute tiddler title info with dynamic scaling */ function computeTiddlerTitleInfo(self, ignoreCase) { var targetFilterText = self.wiki.getTiddlerText(TITLE_TARGET_FILTER), titles = !!targetFilterText ? @@ -277,7 +276,8 @@ TextNodeWidget.prototype.refresh = function(changedTiddlers) { }); } - if(changedAttributes.text || titlesHaveChanged) { + if(changedAttributes.text || titlesHaveChanged || + (changedTiddlers && changedTiddlers[WORD_BOUNDARY_TIDDLER])) { if(titlesHaveChanged) { var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; var ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}) === "yes"; From 1e466e753828334e69c211f963804f9460c0c46c Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 16:55:23 +0800 Subject: [PATCH 43/70] Update settings.tid Word Boundary Check - The isWordBoundaryMatch function checks if the match is on a word boundary: Alphanumeric characters [a-zA-Z0-9_] are regarded as unicode characters At least one non-unicode character must be present before and after the match for it to be considered valid. --- plugins/tiddlywiki/freelinks/settings.tid | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/tiddlywiki/freelinks/settings.tid b/plugins/tiddlywiki/freelinks/settings.tid index 4fadda9bbb0..e049ec26f36 100644 --- a/plugins/tiddlywiki/freelinks/settings.tid +++ b/plugins/tiddlywiki/freelinks/settings.tid @@ -6,6 +6,8 @@ Filter defining tiddlers to which freelinks are made: <$edit-text tiddler="$:/co <$checkbox tiddler="$:/config/Freelinks/Enable" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/Enable">Enable freelinking within tiddler view templates +<$checkbox tiddler="$:/config/Freelinks/WordBoundary" field="text" checked="yes" unchecked="no" default="yes"> <$link to="$:/config/Freelinks/WordBoundary">Word Boundary Check + <$checkbox tiddler="$:/config/Freelinks/IgnoreCase" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/IgnoreCase">Ignore case <$checkbox tiddler="$:/config/Freelinks/PersistAhoCorasickCache" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/PersistAhoCorasickCache">Persist AhoCorasick cache for performance From a20fe8537c4bf7c3495d3e3e383071ffa764ce87 Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 17:36:00 +0800 Subject: [PATCH 44/70] fix Word Boundary logic --- plugins/tiddlywiki/freelinks/aho-corasick.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index ea8975b3b18..ea5e2b56924 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -175,8 +175,8 @@ AhoCorasick.prototype.isWordBoundaryMatch = function(text, start, end) { var beforeIsWord = beforeChar && isWordChar(beforeChar); var afterIsWord = afterChar && isWordChar(afterChar); - /* 匹配的文字前後至少要有一個非單字字符 */ - return !beforeIsWord || !afterIsWord; + /* 匹配的文字前後都不能是單字字符(必須是單字邊界) */ + return !beforeIsWord && !afterIsWord; }; AhoCorasick.prototype.clear = function() { From 506927d4adc32b2d66d9b4aa50d080365c90a7e8 Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 18:16:17 +0800 Subject: [PATCH 45/70] remove PersistentCache @ text.js --- plugins/tiddlywiki/freelinks/text.js | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 94e5328213d..9cacf13d722 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -3,14 +3,13 @@ title: $:/core/modules/widgets/text.js type: application/javascript module-type: widget -An optimized override of the core text widget that automatically linkifies the text, with support for non-Latin languages like Chinese, prioritizing longer titles, skipping processed matches, excluding the current tiddler title from linking, and handling large title sets with enhanced Aho-Corasick algorithm and memory optimization. +An optimized override of the core text widget that automatically linkifies the text, with support for non-Latin languages like Chinese, prioritizing longer titles, skipping processed matches, excluding the current tiddler title from linking, and handling large title sets with enhanced Aho-Corasick algorithm. \*/ "use strict"; var TITLE_TARGET_FILTER = "$:/config/Freelinks/TargetFilter"; -var PERSIST_CACHE_TIDDLER = "$:/config/Freelinks/PersistAhoCorasickCache"; var WORD_BOUNDARY_TIDDLER = "$:/config/Freelinks/WordBoundary"; var Widget = require("$:/core/modules/widgets/widget.js").widget, @@ -77,16 +76,11 @@ TextNodeWidget.prototype.execute = function() { var currentTiddlerTitle = this.getVariable("currentTiddler") || ""; var useWordBoundary = self.wiki.getTiddlerText(WORD_BOUNDARY_TIDDLER, "no") === "yes"; - var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); - this.tiddlerTitleInfo = persistCache ? - this.wiki.getPersistentCache(cacheKey, function() { - return computeTiddlerTitleInfo(self, ignoreCase); - }) : - this.wiki.getGlobalCache(cacheKey, function() { - return computeTiddlerTitleInfo(self, ignoreCase); - }); + this.tiddlerTitleInfo = this.wiki.getGlobalCache(cacheKey, function() { + return computeTiddlerTitleInfo(self, ignoreCase); + }); if(this.tiddlerTitleInfo.titles.length > 0) { var newParseTree = this.processTextWithMatches(text, currentTiddlerTitle, ignoreCase, useWordBoundary); @@ -279,13 +273,9 @@ TextNodeWidget.prototype.refresh = function(changedTiddlers) { if(changedAttributes.text || titlesHaveChanged || (changedTiddlers && changedTiddlers[WORD_BOUNDARY_TIDDLER])) { if(titlesHaveChanged) { - var persistCache = self.wiki.getTiddlerText(PERSIST_CACHE_TIDDLER, "no") === "yes"; var ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}) === "yes"; var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); - - if(persistCache) { - self.wiki.clearCache(cacheKey); - } + self.wiki.clearCache(cacheKey); } this.refreshSelf(); From 9c90b8744d9fa43f7d28abc261569cbad79f7821 Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 18:18:10 +0800 Subject: [PATCH 46/70] remove PersistentCache @settings.tid --- plugins/tiddlywiki/freelinks/settings.tid | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/settings.tid b/plugins/tiddlywiki/freelinks/settings.tid index e049ec26f36..6a7547b5ae5 100644 --- a/plugins/tiddlywiki/freelinks/settings.tid +++ b/plugins/tiddlywiki/freelinks/settings.tid @@ -4,10 +4,8 @@ tags: $:/tags/ControlPanel/SettingsTab Filter defining tiddlers to which freelinks are made: <$edit-text tiddler="$:/config/Freelinks/TargetFilter" tag="input" placeholder="Filter expression..." default=""/> -<$checkbox tiddler="$:/config/Freelinks/Enable" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/Enable">Enable freelinking within tiddler view templates +<$checkbox tiddler="$:/config/Freelinks/Enable" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/Enable">Enable freelinking within tiddler view templates : {{$:/config/Freelinks/Enable}} -<$checkbox tiddler="$:/config/Freelinks/WordBoundary" field="text" checked="yes" unchecked="no" default="yes"> <$link to="$:/config/Freelinks/WordBoundary">Word Boundary Check +<$checkbox tiddler="$:/config/Freelinks/WordBoundary" field="text" checked="yes" unchecked="no" default="yes"> <$link to="$:/config/Freelinks/WordBoundary">Word Boundary Check : {{$:/config/Freelinks/WordBoundary}} -<$checkbox tiddler="$:/config/Freelinks/IgnoreCase" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/IgnoreCase">Ignore case - -<$checkbox tiddler="$:/config/Freelinks/PersistAhoCorasickCache" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/PersistAhoCorasickCache">Persist AhoCorasick cache for performance +<$checkbox tiddler="$:/config/Freelinks/IgnoreCase" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/IgnoreCase">Ignore case :{{$:/config/Freelinks/IgnoreCase}} From 951edc3c7fff1fc6b8e6b28427149a8e77f6ab7f Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 23:00:19 +0800 Subject: [PATCH 47/70] Update readme.tid for Word Boundary Check --- plugins/tiddlywiki/freelinks/readme.tid | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/readme.tid b/plugins/tiddlywiki/freelinks/readme.tid index 15fa153a450..052cc78ee1c 100644 --- a/plugins/tiddlywiki/freelinks/readme.tid +++ b/plugins/tiddlywiki/freelinks/readme.tid @@ -2,14 +2,14 @@ title: $:/plugins/tiddlywiki/freelinks/readme This plugin enhances TiddlyWiki by automatically generating links to existing tiddler titles within text, improving navigation and connectivity. -''Note: Automatic link generation may experience performance slowdowns with a large number of tiddlers. For optimal performance, consider enabling the persistent cache option in the settings.'' +''Note: Automatic link generation may experience performance slowdowns with a large number of tiddlers. The plugin leverages an optimized Aho-Corasick algorithm to mitigate this, ensuring efficient handling of large datasets.'' Freelinking is activated for text runs where the following variables are configured: * `tv-wikilinks` is NOT set to `no` * `tv-freelinks` is set to `yes` -By default, freelinking is case-sensitive, but this can be toggled to ignore case via the settings tab. Additionally, a persistent Aho-Corasick cache can be enabled to boost performance, configurable under the Freelinks settings panel. +By default, freelinking is case-sensitive, but this can be toggled to ignore case via the settings tab. Additionally, a Word Boundary Check can be enabled to control linking precision, configurable under the Freelinks settings panel. Within view templates, the variable `tv-freelinks` is automatically set to the value of `$:/config/Freelinks/Enable`, which can be adjusted through the plugin's settings interface. @@ -41,10 +41,14 @@ You can also combine conditions, such as freelinking only for "HelloThere" with <$set name="tv-freelinks" value={{{ [match[HelloThere]] ~[tag[MyTag]] +[then[yes]else[no]] }}}/> ``` -!! Additional Features -- Use the settings panel to enable a persistent cache, leveraging an optimized Aho-Corasick algorithm to handle large tiddler sets efficiently. -- Adjust the target filter (`$:/config/Freelinks/TargetFilter`) to define which tiddlers are considered for linking. +!! Additional Features
+- Adjust the target filter (`$:/config/Freelinks/TargetFilter`) to define which tiddlers are considered for linking.
+- Enable "Word Boundary Check" in the settings to refine linking behavior:
+ - **Enabled** (defalut): Only standalone words are linked (e.g., [[God]] gugod guGod → only "God" is linked).
+ - **Disabled**: Partial matches within words are linked (e.g., [[God]] gugod gu[[God]] → "God" and gu"God" are linked). -- Powered by Grok & Claude, enhancing development with advanced AI assistance. +- Powered by [[Grok|https://grok.com/?referrer=website]] & [[Claude|https://claude.ai/new]], enhancing development with advanced AI assistance. + +With heartfelt sincerity, I want to express my gratitude to [[Grok|https://grok.com/?referrer=website]] (60%) and [[Claude|https://claude.ai/new]] (40%) for their incredible support in completing the entire revision of Freelinks. For further customization or troubleshooting, refer to the plugin's source code or consult the TiddlyWiki community. From a2b252db9c330f291455ef39cbe2ab0c2c9e8ec0 Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 23:07:01 +0800 Subject: [PATCH 48/70] Update aho-corasick.js Organize and delete comments --- plugins/tiddlywiki/freelinks/aho-corasick.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index ea5e2b56924..0e417ca8acd 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -49,7 +49,6 @@ AhoCorasick.prototype.buildFailureLinks = function() { var root = this.trie; this.failure[root] = root; - /* 初始化根節點的直接子節點 */ for(var char in root) { if(root[char] && char !== '$') { this.failure[root[char]] = root; @@ -70,7 +69,6 @@ AhoCorasick.prototype.buildFailureLinks = function() { var fail = this.failure[node]; var failureDepth = 0; - /* 尋找適當的失敗連結 */ while(fail && !fail[char] && failureDepth < this.maxFailureDepth) { fail = this.failure[fail]; failureDepth++; @@ -79,7 +77,6 @@ AhoCorasick.prototype.buildFailureLinks = function() { var failureLink = (fail && fail[char]) ? fail[char] : root; this.failure[child] = failureLink; - /* 複製失敗連結節點的輸出 */ var failureOutput = this.failure[child]; if(failureOutput && failureOutput.$) { if(!child.$) { @@ -112,24 +109,20 @@ AhoCorasick.prototype.search = function(text, useWordBoundary) { var char = text[i]; var transitionCount = 0; - /* 跟隨失敗連結直到找到匹配或回到根節點 */ while(node && !node[char] && node !== this.trie && transitionCount < this.maxFailureDepth) { node = this.failure[node] || this.trie; transitionCount++; } - /* 確保狀態轉換正確 */ if(node && node[char]) { node = node[char]; } else { node = this.trie; - /* 檢查根節點是否有這個字符的轉換 */ if(this.trie[char]) { node = this.trie[char]; } } - /* 收集當前節點及其失敗連結的所有匹配輸出 */ var currentNode = node; var collectCount = 0; while(currentNode && collectCount < 10) { @@ -140,7 +133,6 @@ AhoCorasick.prototype.search = function(text, useWordBoundary) { var matchStart = i - output.length + 1; var matchEnd = i + 1; - /* 檢查單字邊界 */ if(useWordBoundary && !this.isWordBoundaryMatch(text, matchStart, matchEnd)) { continue; } @@ -162,12 +154,10 @@ AhoCorasick.prototype.search = function(text, useWordBoundary) { return matches; }; -/* 檢查是否為有效的單字邊界匹配 */ AhoCorasick.prototype.isWordBoundaryMatch = function(text, start, end) { var beforeChar = start > 0 ? text[start - 1] : ''; var afterChar = end < text.length ? text[end] : ''; - /* 西文單字邊界:字母數字與非字母數字的交界 */ var isWordChar = function(char) { return /[a-zA-Z0-9_]/.test(char); }; @@ -175,7 +165,6 @@ AhoCorasick.prototype.isWordBoundaryMatch = function(text, start, end) { var beforeIsWord = beforeChar && isWordChar(beforeChar); var afterIsWord = afterChar && isWordChar(afterChar); - /* 匹配的文字前後都不能是單字字符(必須是單字邊界) */ return !beforeIsWord && !afterIsWord; }; From 1d316197d8ea7ec5f16ec0d3a7d25c933bbd1393 Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 23:10:44 +0800 Subject: [PATCH 49/70] Initial commit of freelinks plugin --- plugins/tiddlywiki/freelinks/config-Freelinks-WordBoundary.tid | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 plugins/tiddlywiki/freelinks/config-Freelinks-WordBoundary.tid diff --git a/plugins/tiddlywiki/freelinks/config-Freelinks-WordBoundary.tid b/plugins/tiddlywiki/freelinks/config-Freelinks-WordBoundary.tid new file mode 100644 index 00000000000..7d9de66a8b7 --- /dev/null +++ b/plugins/tiddlywiki/freelinks/config-Freelinks-WordBoundary.tid @@ -0,0 +1,2 @@ +title: $:/config/Freelinks/WordBoundary +text: yes From d09764fa9c8b28f077a2a988412d07ebe01ca328 Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 23:11:45 +0800 Subject: [PATCH 50/70] Update settings.tid Organize and delete comments --- plugins/tiddlywiki/freelinks/settings.tid | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/settings.tid b/plugins/tiddlywiki/freelinks/settings.tid index 6a7547b5ae5..90e8b660e11 100644 --- a/plugins/tiddlywiki/freelinks/settings.tid +++ b/plugins/tiddlywiki/freelinks/settings.tid @@ -4,8 +4,8 @@ tags: $:/tags/ControlPanel/SettingsTab Filter defining tiddlers to which freelinks are made: <$edit-text tiddler="$:/config/Freelinks/TargetFilter" tag="input" placeholder="Filter expression..." default=""/> -<$checkbox tiddler="$:/config/Freelinks/Enable" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/Enable">Enable freelinking within tiddler view templates : {{$:/config/Freelinks/Enable}} +<$checkbox tiddler="$:/config/Freelinks/Enable" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/Enable">Enable freelinking within tiddler view templates -<$checkbox tiddler="$:/config/Freelinks/WordBoundary" field="text" checked="yes" unchecked="no" default="yes"> <$link to="$:/config/Freelinks/WordBoundary">Word Boundary Check : {{$:/config/Freelinks/WordBoundary}} +<$checkbox tiddler="$:/config/Freelinks/WordBoundary" field="text" checked="yes" unchecked="no" default="yes"> <$link to="$:/config/Freelinks/WordBoundary">Word Boundary Check -<$checkbox tiddler="$:/config/Freelinks/IgnoreCase" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/IgnoreCase">Ignore case :{{$:/config/Freelinks/IgnoreCase}} +<$checkbox tiddler="$:/config/Freelinks/IgnoreCase" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/IgnoreCase">Ignore case From dcd6a610ea322a76ef60c141fb27963eb11d5073 Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 23:15:00 +0800 Subject: [PATCH 51/70] Update tiddlywiki.info add back other plugin --- editions/tw5.com/tiddlywiki.info | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/editions/tw5.com/tiddlywiki.info b/editions/tw5.com/tiddlywiki.info index e0fef8b5d67..ad1fff2b5c8 100644 --- a/editions/tw5.com/tiddlywiki.info +++ b/editions/tw5.com/tiddlywiki.info @@ -1,6 +1,13 @@ { "description": "Documentation from https://tiddlywiki.com", "plugins": [ + "tiddlywiki/browser-sniff", + "tiddlywiki/confetti", + "tiddlywiki/dynannotate", + "tiddlywiki/internals", + "tiddlywiki/menubar", + "tiddlywiki/railroad", + "tiddlywiki/tour", "tiddlywiki/freelinks" ], "themes": [ From 13a2fa9cbe9485e2273274674d3d84f906d69638 Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 13 Jun 2025 23:21:39 +0800 Subject: [PATCH 52/70] Update tiddlywiki.info alphabet sort --- editions/tw5.com/tiddlywiki.info | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/editions/tw5.com/tiddlywiki.info b/editions/tw5.com/tiddlywiki.info index ad1fff2b5c8..cce3ff1fd5d 100644 --- a/editions/tw5.com/tiddlywiki.info +++ b/editions/tw5.com/tiddlywiki.info @@ -4,11 +4,11 @@ "tiddlywiki/browser-sniff", "tiddlywiki/confetti", "tiddlywiki/dynannotate", + "tiddlywiki/freelinks", "tiddlywiki/internals", "tiddlywiki/menubar", "tiddlywiki/railroad", - "tiddlywiki/tour", - "tiddlywiki/freelinks" + "tiddlywiki/tour" ], "themes": [ "tiddlywiki/vanilla", From 6f1721ff22b9837432dddb19c634f796c7fa465d Mon Sep 17 00:00:00 2001 From: s793016 Date: Sat, 14 Jun 2025 15:48:32 +0800 Subject: [PATCH 53/70] Update readme.tid for new future The plugin supports non-Western language tiddler titles (e.g., Chinese) and prioritizes longer tiddler titles for matching, ensuring accurate linking in diverse contexts. Furthermore, the current tiddler title within its own content is excluded from generating links to avoid self-referencing. --- plugins/tiddlywiki/freelinks/readme.tid | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/readme.tid b/plugins/tiddlywiki/freelinks/readme.tid index 052cc78ee1c..280c97f6ce2 100644 --- a/plugins/tiddlywiki/freelinks/readme.tid +++ b/plugins/tiddlywiki/freelinks/readme.tid @@ -9,7 +9,7 @@ Freelinking is activated for text runs where the following variables are configu * `tv-wikilinks` is NOT set to `no` * `tv-freelinks` is set to `yes` -By default, freelinking is case-sensitive, but this can be toggled to ignore case via the settings tab. Additionally, a Word Boundary Check can be enabled to control linking precision, configurable under the Freelinks settings panel. +By default, freelinking is case-sensitive, but this can be toggled to ignore case via the settings tab. Additionally, a Word Boundary Check can be enabled to control linking precision, configurable under the Freelinks settings panel. The plugin supports non-Western language tiddler titles (e.g., Chinese) and prioritizes longer tiddler titles for matching, ensuring accurate linking in diverse contexts. Furthermore, the current tiddler title within its own content is excluded from generating links to avoid self-referencing. Within view templates, the variable `tv-freelinks` is automatically set to the value of `$:/config/Freelinks/Enable`, which can be adjusted through the plugin's settings interface. @@ -43,9 +43,10 @@ You can also combine conditions, such as freelinking only for "HelloThere" with !! Additional Features
- Adjust the target filter (`$:/config/Freelinks/TargetFilter`) to define which tiddlers are considered for linking.
-- Enable "Word Boundary Check" in the settings to refine linking behavior:
- - **Enabled** (defalut): Only standalone words are linked (e.g., [[God]] gugod guGod → only "God" is linked).
- - **Disabled**: Partial matches within words are linked (e.g., [[God]] gugod gu[[God]] → "God" and gu"God" are linked). +- Enable "Word Boundary Check" in the settings to refine linking behavior (applies only to Western languages):
+ - **Enabled** (defalut): Only standalone words are linked (e.g., @@__Yes__@@ eyes eYes → only "Yes" is linked).
+ - **Disabled**: Partial matches within words are linked (e.g., @@__yes__@@ eyes e@@__Yes__@@ → "Yes" and e"Yes" are linked). +- The plugin prioritizes longer tiddler titles for matching, ensuring that phrases like "Game Guide" are linked before "Game" alone.
- Powered by [[Grok|https://grok.com/?referrer=website]] & [[Claude|https://claude.ai/new]], enhancing development with advanced AI assistance. From 98a07137db063b8c783f528c8c7d15dd67e2f80c Mon Sep 17 00:00:00 2001 From: s793016 Date: Sat, 14 Jun 2025 17:19:22 +0800 Subject: [PATCH 54/70] Update readme.tid --- plugins/tiddlywiki/freelinks/readme.tid | 60 ++++++++++++++++++------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/readme.tid b/plugins/tiddlywiki/freelinks/readme.tid index 280c97f6ce2..b212a8da3d8 100644 --- a/plugins/tiddlywiki/freelinks/readme.tid +++ b/plugins/tiddlywiki/freelinks/readme.tid @@ -1,19 +1,24 @@ title: $:/plugins/tiddlywiki/freelinks/readme -This plugin enhances TiddlyWiki by automatically generating links to existing tiddler titles within text, improving navigation and connectivity. +This plugin transforms TiddlyWiki into a more connected experience by intelligently auto-linking text to existing tiddler titles, creating a web of knowledge that emerges naturally as you write. -''Note: Automatic link generation may experience performance slowdowns with a large number of tiddlers. The plugin leverages an optimized Aho-Corasick algorithm to mitigate this, ensuring efficient handling of large datasets.'' +''✨ Built with cutting-edge Aho-Corasick algorithm optimization - handles thousands of tiddlers without breaking a sweat!'' -Freelinking is activated for text runs where the following variables are configured: +!! ⚙️ Quick Setup -* `tv-wikilinks` is NOT set to `no` +The magic happens when these conditions are met: +* `tv-wikilinks` is NOT set to `no` * `tv-freelinks` is set to `yes` -By default, freelinking is case-sensitive, but this can be toggled to ignore case via the settings tab. Additionally, a Word Boundary Check can be enabled to control linking precision, configurable under the Freelinks settings panel. The plugin supports non-Western language tiddler titles (e.g., Chinese) and prioritizes longer tiddler titles for matching, ensuring accurate linking in diverse contexts. Furthermore, the current tiddler title within its own content is excluded from generating links to avoid self-referencing. +''Pro tip'': Toggle case sensitivity and word boundary checking in the settings panel to fine-tune your linking experience! + +🌍 ''Universal Language Support'': Works seamlessly with any language - English, 中文, 日本語, العربية, русский - you name it! The plugin intelligently handles Unicode characters and uses locale-aware sorting to prioritize longer, more specific matches. + +🎯 ''Smart Matching'': Longer titles take precedence (so "Machine Learning Guide" links before just "Machine"), and the plugin cleverly avoids self-referential links within the current tiddler. Within view templates, the variable `tv-freelinks` is automatically set to the value of `$:/config/Freelinks/Enable`, which can be adjusted through the plugin's settings interface. -!! Configuration Notes +!! 🔧 Advanced Configuration To customize which tiddlers trigger freelinking, modify the shadow tiddler [[$:/plugins/tiddlywiki/freelinks/macros/view]]. This tiddler is tagged `$:/tags/Macro/View`, ensuring it acts as a local macro in each view template. The default content is: @@ -41,15 +46,40 @@ You can also combine conditions, such as freelinking only for "HelloThere" with <$set name="tv-freelinks" value={{{ [match[HelloThere]] ~[tag[MyTag]] +[then[yes]else[no]] }}}/> ``` -!! Additional Features
-- Adjust the target filter (`$:/config/Freelinks/TargetFilter`) to define which tiddlers are considered for linking.
-- Enable "Word Boundary Check" in the settings to refine linking behavior (applies only to Western languages):
- - **Enabled** (defalut): Only standalone words are linked (e.g., @@__Yes__@@ eyes eYes → only "Yes" is linked).
- - **Disabled**: Partial matches within words are linked (e.g., @@__yes__@@ eyes e@@__Yes__@@ → "Yes" and e"Yes" are linked). -- The plugin prioritizes longer tiddler titles for matching, ensuring that phrases like "Game Guide" are linked before "Game" alone.
+!! 🎨 Feature Highlights + +''🎯 Target Filter'': Use `$:/config/Freelinks/TargetFilter` to define which tiddlers get the auto-link treatment + +''🔤 Word Boundary Intelligence'' (Western languages):
+ - ''Enabled'' (default): Links only complete words → @@__Yes__@@ eyes eYes (only "Yes" gets linked)
+ - ''Disabled'': Links partial matches too → @@__Yes__@@ eyes e@@__Yes__@@ (both "Yes" instances get linked) + +''🧠 Smart Prioritization'': "Machine Learning Tutorial" gets linked before just "Machine" - context matters! + +''🚫 Self-Link Prevention'': No awkward self-references - tiddlers don't link to themselves + +!! ⚡ Under the Hood + +''Lightning-Fast Algorithm'': Powered by optimized Aho-Corasick with smart safeguards:
+* ''Depth Protection'': Max 100 failure link transitions per character
+* ''Scale Handling'': Processes up to 100K nodes (or 15× your pattern count)
+* ''Result Limiting'': Cap of 10,000 matches per text block
+* ''Memory Smart'': Uses TiddlyWiki's global cache for blazing performance + +''Bulletproof Reliability'':
+* Invalid patterns? Skipped automatically
+* Algorithm hiccups? Falls back gracefully to normal text
+* Unicode chaos? Properly escaped and handled + +//Perfect for wikis with thousands of tiddlers - stress tested and ready for action!// + +!! 🙏 Credits & Thanks -- Powered by [[Grok|https://grok.com/?referrer=website]] & [[Claude|https://claude.ai/new]], enhancing development with advanced AI assistance. +Built with love and powered by AI collaboration:
+- 🤖 ''[[Grok|https://grok.com/?referrer=website]]'' (60%) - The creative spark
+- 🧠 ''[[Claude|https://claude.ai/new]]'' (40%) - The technical precision -With heartfelt sincerity, I want to express my gratitude to [[Grok|https://grok.com/?referrer=website]] (60%) and [[Claude|https://claude.ai/new]] (40%) for their incredible support in completing the entire revision of Freelinks. +*A heartfelt thank you to both AI assistants for making this ambitious rewrite possible!* -For further customization or troubleshooting, refer to the plugin's source code or consult the TiddlyWiki community. +--- +*Questions? Issues? The TiddlyWiki community is here to help! 🚀* From 8f86e6b212960b8bdf7f77a77418f0d955db08b1 Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 8 Jul 2025 16:42:03 +0800 Subject: [PATCH 55/70] Update plugins/tiddlywiki/freelinks/text.js Co-authored-by: Mario Pietsch --- plugins/tiddlywiki/freelinks/text.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 9cacf13d722..2f88e10c8e7 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -273,7 +273,7 @@ TextNodeWidget.prototype.refresh = function(changedTiddlers) { if(changedAttributes.text || titlesHaveChanged || (changedTiddlers && changedTiddlers[WORD_BOUNDARY_TIDDLER])) { if(titlesHaveChanged) { - var ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}) === "yes"; + var ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}).trim() === "yes"; var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); self.wiki.clearCache(cacheKey); } From 3a6f7d2b65007f392b152988470699638d4a7cdf Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 8 Jul 2025 16:59:04 +0800 Subject: [PATCH 56/70] Update plugins/tiddlywiki/freelinks/aho-corasick.js Co-authored-by: Mario Pietsch --- plugins/tiddlywiki/freelinks/aho-corasick.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index 0e417ca8acd..13226361ef4 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -64,7 +64,7 @@ AhoCorasick.prototype.buildFailureLinks = function() { processedNodes++; for(var char in node) { - if(node[char] && char !== '$') { + if(node[char] && char !== "$") { var child = node[char]; var fail = this.failure[node]; var failureDepth = 0; From 72db37099fcbecf181a2fa7a57cdca3a8c13df52 Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 8 Jul 2025 16:59:53 +0800 Subject: [PATCH 57/70] Update plugins/tiddlywiki/freelinks/aho-corasick.js Co-authored-by: Mario Pietsch --- plugins/tiddlywiki/freelinks/aho-corasick.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index 13226361ef4..0b283ad0e6e 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -50,7 +50,7 @@ AhoCorasick.prototype.buildFailureLinks = function() { this.failure[root] = root; for(var char in root) { - if(root[char] && char !== '$') { + if(root[char] && char !== "$") { this.failure[root[char]] = root; queue.push(root[char]); } From ae73b1be263e2b4131def662343d8df413cf57aa Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 8 Jul 2025 17:00:25 +0800 Subject: [PATCH 58/70] Update plugins/tiddlywiki/freelinks/text.js Co-authored-by: Mario Pietsch --- plugins/tiddlywiki/freelinks/text.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 2f88e10c8e7..9fcac8e1d41 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -55,7 +55,7 @@ TextNodeWidget.prototype.render = function(parent,nextSibling) { TextNodeWidget.prototype.execute = function() { var self = this, - ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}) === "yes"; + ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}).trim() === "yes"; var childParseTree = [{ type: "plain-text", From ff0507dbc9f2b722322602a18f9348ca56330b01 Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 8 Jul 2025 17:00:48 +0800 Subject: [PATCH 59/70] Update plugins/tiddlywiki/freelinks/aho-corasick.js Co-authored-by: Mario Pietsch --- plugins/tiddlywiki/freelinks/aho-corasick.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index 0b283ad0e6e..c78dcdf88ce 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -186,7 +186,7 @@ AhoCorasick.prototype.getStats = function() { patternCount += node.$.length; } for(var key in node) { - if(node[key] && typeof node[key] === 'object' && key !== '$') { + if(node[key] && typeof node[key] === "object" && key !== "$") { countNodes(node[key]); } } From 2c73f628e11d2b576add60de8c3991b4c2fef5d0 Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 8 Jul 2025 20:41:02 +0800 Subject: [PATCH 60/70] Update text.js Added locale configuration support - Added LOCALE_CONFIG_TIDDLER constant to make the sorting locale configurable instead of hardcoded "zh" Optimized title processing - Combined the filtering and escaping logic into a single pass to reduce duplication Added trim() for ignoreCase - Applied .trim() to the ignore case variable for consistency Enhanced refresh logic - Added locale configuration tiddler to the refresh check Improved comments - Added explanation for why sorting is necessary (prioritizing longer titles) --- plugins/tiddlywiki/freelinks/text.js | 34 ++++++++++++++++------------ 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 9fcac8e1d41..f3584b330a2 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -11,6 +11,7 @@ An optimized override of the core text widget that automatically linkifies the t var TITLE_TARGET_FILTER = "$:/config/Freelinks/TargetFilter"; var WORD_BOUNDARY_TIDDLER = "$:/config/Freelinks/WordBoundary"; +var LOCALE_CONFIG_TIDDLER = "$:/config/Freelinks/Locale"; var Widget = require("$:/core/modules/widgets/widget.js").widget, LinkWidget = require("$:/core/modules/widgets/link.js").link, @@ -200,29 +201,34 @@ function computeTiddlerTitleInfo(self, ignoreCase) { }; } - var filteredTitles = []; + // Get locale configuration for sorting + var locale = self.wiki.getTiddlerText(LOCALE_CONFIG_TIDDLER, "en"); + + var validTitles = []; + var ac = new AhoCorasick(); + + // Process titles in a single pass to avoid duplication for(var i = 0; i < titles.length; i++) { var title = titles[i]; if(title && title.length > 0 && title.substring(0,3) !== "$:/") { - filteredTitles.push(title); + var escapedTitle = escapeRegExp(title); + if(escapedTitle) { + validTitles.push(title); + } } } - var sortedTitles = filteredTitles.sort(function(a,b) { + // Sort by length (descending) then alphabetically + // Longer titles are prioritized to avoid partial matches + var sortedTitles = validTitles.sort(function(a,b) { var lenDiff = b.length - a.length; - return lenDiff !== 0 ? lenDiff : a.localeCompare(b, 'zh', {sensitivity: 'base'}); + return lenDiff !== 0 ? lenDiff : a.localeCompare(b, locale, {sensitivity: 'base'}); }); - var validTitles = []; - var ac = new AhoCorasick(); - + // Build Aho-Corasick automaton for(var i = 0; i < sortedTitles.length; i++) { var title = sortedTitles[i]; - var escapedTitle = escapeRegExp(title); - if(escapedTitle) { - validTitles.push(title); - ac.addPattern(ignoreCase ? title.toLowerCase() : title, validTitles.length - 1); - } + ac.addPattern(ignoreCase ? title.toLowerCase() : title, i); } try { @@ -235,7 +241,7 @@ function computeTiddlerTitleInfo(self, ignoreCase) { } return { - titles: validTitles, + titles: sortedTitles, ac: ac }; } @@ -271,7 +277,7 @@ TextNodeWidget.prototype.refresh = function(changedTiddlers) { } if(changedAttributes.text || titlesHaveChanged || - (changedTiddlers && changedTiddlers[WORD_BOUNDARY_TIDDLER])) { + (changedTiddlers && (changedTiddlers[WORD_BOUNDARY_TIDDLER] || changedTiddlers[LOCALE_CONFIG_TIDDLER]))) { if(titlesHaveChanged) { var ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}).trim() === "yes"; var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); From 62995b6d9dd58d638490d7127bb51ae4c9cf5774 Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 8 Jul 2025 20:53:45 +0800 Subject: [PATCH 61/70] Update text.js we don't need to specify 'zh' at all --- plugins/tiddlywiki/freelinks/text.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index f3584b330a2..3cb68c7cc54 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -11,7 +11,6 @@ An optimized override of the core text widget that automatically linkifies the t var TITLE_TARGET_FILTER = "$:/config/Freelinks/TargetFilter"; var WORD_BOUNDARY_TIDDLER = "$:/config/Freelinks/WordBoundary"; -var LOCALE_CONFIG_TIDDLER = "$:/config/Freelinks/Locale"; var Widget = require("$:/core/modules/widgets/widget.js").widget, LinkWidget = require("$:/core/modules/widgets/link.js").link, @@ -201,9 +200,6 @@ function computeTiddlerTitleInfo(self, ignoreCase) { }; } - // Get locale configuration for sorting - var locale = self.wiki.getTiddlerText(LOCALE_CONFIG_TIDDLER, "en"); - var validTitles = []; var ac = new AhoCorasick(); @@ -219,10 +215,10 @@ function computeTiddlerTitleInfo(self, ignoreCase) { } // Sort by length (descending) then alphabetically - // Longer titles are prioritized to avoid partial matches + // Longer titles are prioritized to avoid partial matches (e.g., "JavaScript" before "Java") var sortedTitles = validTitles.sort(function(a,b) { var lenDiff = b.length - a.length; - return lenDiff !== 0 ? lenDiff : a.localeCompare(b, locale, {sensitivity: 'base'}); + return lenDiff !== 0 ? lenDiff : (a < b ? -1 : a > b ? 1 : 0); }); // Build Aho-Corasick automaton @@ -277,7 +273,7 @@ TextNodeWidget.prototype.refresh = function(changedTiddlers) { } if(changedAttributes.text || titlesHaveChanged || - (changedTiddlers && (changedTiddlers[WORD_BOUNDARY_TIDDLER] || changedTiddlers[LOCALE_CONFIG_TIDDLER]))) { + (changedTiddlers && changedTiddlers[WORD_BOUNDARY_TIDDLER])) { if(titlesHaveChanged) { var ignoreCase = self.getVariable("tv-freelinks-ignore-case",{defaultValue:"no"}).trim() === "yes"; var cacheKey = "tiddler-title-info-" + (ignoreCase ? "insensitive" : "sensitive"); From b254d13973cd41b47cb7f936f19ad6c9f162e689 Mon Sep 17 00:00:00 2001 From: s793016 Date: Tue, 8 Jul 2025 21:01:46 +0800 Subject: [PATCH 62/70] Update aho-corasick.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This single line change would add support for: Accented letters: á, é, í, ó, ú, à, è, ì, ò, ù, ä, ë, ï, ö, ü, ñ, ç, etc. Most Western European languages (Spanish, French, German, Italian, Portuguese, etc.) --- plugins/tiddlywiki/freelinks/aho-corasick.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index c78dcdf88ce..49a442b9fce 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -159,7 +159,7 @@ AhoCorasick.prototype.isWordBoundaryMatch = function(text, start, end) { var afterChar = end < text.length ? text[end] : ''; var isWordChar = function(char) { - return /[a-zA-Z0-9_]/.test(char); + return /[a-zA-Z0-9_\u00C0-\u00FF]/.test(char); }; var beforeIsWord = beforeChar && isWordChar(beforeChar); From b03c6c9c30fd3ecca129b2321f9340d8639dc78b Mon Sep 17 00:00:00 2001 From: s793016 Date: Fri, 11 Jul 2025 16:04:06 +0800 Subject: [PATCH 63/70] Update aho-corasick.js useage --- plugins/tiddlywiki/freelinks/aho-corasick.js | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index 49a442b9fce..8835687ad4f 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -6,6 +6,40 @@ module-type: utils Optimized Aho-Corasick string matching algorithm implementation with enhanced performance and error handling for TiddlyWiki freelinking functionality. +Useage: + +Initialization: + Create an AhoCorasick instance: var ac = new AhoCorasick(); + After initialization, the trie and failure structures are automatically created to store patterns and failure links. + +Adding Patterns: + Call addPattern(pattern, index) to add a pattern, e.g., ac.addPattern("[[Link]]", 0);. + pattern is the string to match, and index is an identifier for tracking results. + Multiple patterns can be added, stored in the trie structure. + +Building Failure Links: + Call buildFailureLinks() to construct failure links for efficient multi-pattern matching. + Includes a maximum node limit (default 100,000 or 15 times the pattern count) to prevent excessive computation. + +Performing Search: + Use search(text, useWordBoundary) to find pattern matches in the text. + text is the input string, and useWordBoundary (boolean) controls whether to enforce word boundary checks. + Returns an array of match results, each containing pattern (matched pattern), index (start position), length (pattern length), and titleIndex (pattern identifier). + +Word Boundary Check: + If useWordBoundary is true, only matches surrounded by non-word characters (letters, digits, or underscores) are returned. + +Cleanup and Statistics: + Use clear() to reset the trie and failure links, freeing memory. + Use getStats() to retrieve statistics, including node count (nodeCount), pattern count (patternCount), and failure link count (failureLinks). + +Notes + Performance Considerations: The Aho-Corasick trie may consume significant memory with a large number of patterns. Limit the number of patterns (e.g., <10,000) for optimal performance. + Error Handling: The module includes maximum node and failure depth limits (maxFailureDepth) to prevent infinite loops or memory overflow. + Word Boundary: Enabling useWordBoundary ensures more precise matches, ideal for link detection scenarios. + Compatibility: Ensure compatibility with other TiddlyWiki modules (e.g., wikiparser.js) when processing WikiText. + Debugging: Use getStats() to inspect the trie structure's size and ensure it does not overload browser memory. + \*/ "use strict"; From 6536ed6028cd34383c1f2e7ffc24bebf01174cc8 Mon Sep 17 00:00:00 2001 From: s793016 Date: Mon, 14 Jul 2025 23:46:48 +0800 Subject: [PATCH 64/70] Update readme.tid for Writing Style --- plugins/tiddlywiki/freelinks/readme.tid | 76 +++++++++---------------- 1 file changed, 27 insertions(+), 49 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/readme.tid b/plugins/tiddlywiki/freelinks/readme.tid index b212a8da3d8..fc864314014 100644 --- a/plugins/tiddlywiki/freelinks/readme.tid +++ b/plugins/tiddlywiki/freelinks/readme.tid @@ -1,85 +1,63 @@ title: $:/plugins/tiddlywiki/freelinks/readme -This plugin transforms TiddlyWiki into a more connected experience by intelligently auto-linking text to existing tiddler titles, creating a web of knowledge that emerges naturally as you write. +This plugin adds automatic generation of links to tiddler titles. -''✨ Built with cutting-edge Aho-Corasick algorithm optimization - handles thousands of tiddlers without breaking a sweat!'' +The plugin uses the Aho-Corasick algorithm for efficient pattern matching with large numbers of tiddlers. -!! ⚙️ Quick Setup +!! Configuration -The magic happens when these conditions are met: -* `tv-wikilinks` is NOT set to `no` +Freelinking is activated for runs of text that have the following variables set: + +* `tv-wikilinks` is NOT equal to `no` * `tv-freelinks` is set to `yes` -''Pro tip'': Toggle case sensitivity and word boundary checking in the settings panel to fine-tune your linking experience! +Freelinks are case sensitive by default but can be configured to ignore case in the settings panel. + +Word boundary checking can be configured in the settings panel. When enabled (default for Western languages), links are created only for complete words. When disabled, partial matches within words are also linked. + +When multiple tiddler titles match the same text, longer titles take precedence over shorter ones. -🌍 ''Universal Language Support'': Works seamlessly with any language - English, 中文, 日本語, العربية, русский - you name it! The plugin intelligently handles Unicode characters and uses locale-aware sorting to prioritize longer, more specific matches. +Tiddlers do not create links to themselves. -🎯 ''Smart Matching'': Longer titles take precedence (so "Machine Learning Guide" links before just "Machine"), and the plugin cleverly avoids self-referential links within the current tiddler. +Use `$:/config/Freelinks/TargetFilter` to define which tiddlers are eligible for auto-linking. -Within view templates, the variable `tv-freelinks` is automatically set to the value of `$:/config/Freelinks/Enable`, which can be adjusted through the plugin's settings interface. +Within view templates, the variable `tv-freelinks` is automatically set to the content of `$:/config/Freelinks/Enable`, which can be set via the settings panel of this plugin. -!! 🔧 Advanced Configuration +!! Notes -To customize which tiddlers trigger freelinking, modify the shadow tiddler [[$:/plugins/tiddlywiki/freelinks/macros/view]]. This tiddler is tagged `$:/tags/Macro/View`, ensuring it acts as a local macro in each view template. The default content is: +To change within which tiddlers freelinking occurs requires customising the shadow tiddler [[$:/plugins/tiddlywiki/freelinks/macros/view]]. This tiddler is tagged `$:/tags/Macro/View` which means that it will be included as a local macro in each view template. By default, its content is: ``` <$set name="tv-freelinks" value={{$:/config/Freelinks/Enable}}/> ``` -This sets `tv-freelinks` to the value of `$:/config/Freelinks/Enable` (either "yes" or "no") for each tiddler, as defined in the control panel settings. +That means that for each tiddler the variable `tv-freelinks` will be set to the tiddler `$:/config/Freelinks/Enable`, which is set to "yes" or "no" by the settings in control panel. -For more specific control, you can use filter expressions. For example, to enable freelinking only within the tiddler titled "HelloThere": +Instead, we can use a filter expression to, say, only freelink within the tiddler with the title "HelloThere": ``` <$set name="tv-freelinks" value={{{ [match[HelloThere]then[yes]else[no]] }}}/> ``` -To limit freelinking to tiddlers tagged "MyTag": +Or, we can make a filter that will only freelink within tiddlers with the tag "MyTag": ``` <$set name="tv-freelinks" value={{{ [tag[MyTags]then[yes]else[no]] }}}/> ``` -You can also combine conditions, such as freelinking only for "HelloThere" with the "MyTag" tag: +Or we can combine both approaches: ``` <$set name="tv-freelinks" value={{{ [match[HelloThere]] ~[tag[MyTag]] +[then[yes]else[no]] }}}/> ``` -!! 🎨 Feature Highlights - -''🎯 Target Filter'': Use `$:/config/Freelinks/TargetFilter` to define which tiddlers get the auto-link treatment - -''🔤 Word Boundary Intelligence'' (Western languages):
- - ''Enabled'' (default): Links only complete words → @@__Yes__@@ eyes eYes (only "Yes" gets linked)
- - ''Disabled'': Links partial matches too → @@__Yes__@@ eyes e@@__Yes__@@ (both "Yes" instances get linked) - -''🧠 Smart Prioritization'': "Machine Learning Tutorial" gets linked before just "Machine" - context matters! - -''🚫 Self-Link Prevention'': No awkward self-references - tiddlers don't link to themselves - -!! ⚡ Under the Hood - -''Lightning-Fast Algorithm'': Powered by optimized Aho-Corasick with smart safeguards:
-* ''Depth Protection'': Max 100 failure link transitions per character
-* ''Scale Handling'': Processes up to 100K nodes (or 15× your pattern count)
-* ''Result Limiting'': Cap of 10,000 matches per text block
-* ''Memory Smart'': Uses TiddlyWiki's global cache for blazing performance - -''Bulletproof Reliability'':
-* Invalid patterns? Skipped automatically
-* Algorithm hiccups? Falls back gracefully to normal text
-* Unicode chaos? Properly escaped and handled - -//Perfect for wikis with thousands of tiddlers - stress tested and ready for action!// - -!! 🙏 Credits & Thanks +!! Implementation Details -Built with love and powered by AI collaboration:
-- 🤖 ''[[Grok|https://grok.com/?referrer=website]]'' (60%) - The creative spark
-- 🧠 ''[[Claude|https://claude.ai/new]]'' (40%) - The technical precision +The Aho-Corasick algorithm implementation includes: -*A heartfelt thank you to both AI assistants for making this ambitious rewrite possible!* +* Unicode character support for international text +* Prevention of self-referential links within the current tiddler +* Performance safeguards including depth protection and result limiting +* Graceful fallback handling for invalid patterns ---- -*Questions? Issues? The TiddlyWiki community is here to help! 🚀* +Longer tiddler titles take precedence over shorter ones when multiple matches are possible. From 41c493b2f8ddb32df6077324e893c0297574127d Mon Sep 17 00:00:00 2001 From: s793016 Date: Wed, 29 Oct 2025 21:52:45 +0800 Subject: [PATCH 65/70] Update tiddlywiki.info revert all the changes --- editions/tw5.com/tiddlywiki.info | 1 - 1 file changed, 1 deletion(-) diff --git a/editions/tw5.com/tiddlywiki.info b/editions/tw5.com/tiddlywiki.info index cce3ff1fd5d..47de3c45597 100644 --- a/editions/tw5.com/tiddlywiki.info +++ b/editions/tw5.com/tiddlywiki.info @@ -4,7 +4,6 @@ "tiddlywiki/browser-sniff", "tiddlywiki/confetti", "tiddlywiki/dynannotate", - "tiddlywiki/freelinks", "tiddlywiki/internals", "tiddlywiki/menubar", "tiddlywiki/railroad", From f2aa7eb515fe029e7184b53c57cc7671ec15335d Mon Sep 17 00:00:00 2001 From: s793016 Date: Wed, 29 Oct 2025 21:53:13 +0800 Subject: [PATCH 66/70] Update tiddlywiki.info revert all the changes --- editions/tw5.com-server/tiddlywiki.info | 1 - 1 file changed, 1 deletion(-) diff --git a/editions/tw5.com-server/tiddlywiki.info b/editions/tw5.com-server/tiddlywiki.info index 8d2b44ea748..cc460be7e34 100644 --- a/editions/tw5.com-server/tiddlywiki.info +++ b/editions/tw5.com-server/tiddlywiki.info @@ -3,7 +3,6 @@ "plugins": [ "tiddlywiki/tiddlyweb", "tiddlywiki/filesystem", - "tiddlywiki/freelinks", "tiddlywiki/highlight", "tiddlywiki/internals" ], From fcd66818c3dcdae678918ca001cde62c37b529a0 Mon Sep 17 00:00:00 2001 From: s793016 Date: Wed, 29 Oct 2025 21:54:03 +0800 Subject: [PATCH 67/70] Update tiddlywiki.info revert all the changes --- editions/prerelease/tiddlywiki.info | 1 - 1 file changed, 1 deletion(-) diff --git a/editions/prerelease/tiddlywiki.info b/editions/prerelease/tiddlywiki.info index 59963f81e89..db652c9af3c 100644 --- a/editions/prerelease/tiddlywiki.info +++ b/editions/prerelease/tiddlywiki.info @@ -1,7 +1,6 @@ { "description": "Content for the current prerelease", "plugins": [ - "tiddlywiki/freelinks", "tiddlywiki/help", "tiddlywiki/internals", "tiddlywiki/menubar", From 0141a9f1dbb5c23c47db6c43cc5560f865195820 Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 30 Oct 2025 00:24:04 +0800 Subject: [PATCH 68/70] Update tiddlywiki.info revert --- editions/prerelease/tiddlywiki.info | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/editions/prerelease/tiddlywiki.info b/editions/prerelease/tiddlywiki.info index db652c9af3c..9b37efef3f8 100644 --- a/editions/prerelease/tiddlywiki.info +++ b/editions/prerelease/tiddlywiki.info @@ -1,10 +1,19 @@ { "description": "Content for the current prerelease", "plugins": [ + "tiddlywiki/browser-sniff", "tiddlywiki/help", + "tiddlywiki/powered-by-tiddlywiki", "tiddlywiki/internals", + "tiddlywiki/highlight", + "tiddlywiki/bibtex", + "tiddlywiki/dynaview", + "tiddlywiki/dynannotate", + "tiddlywiki/codemirror", "tiddlywiki/menubar", - "tiddlywiki/powered-by-tiddlywiki" + "tiddlywiki/jszip", + "tiddlywiki/confetti", + "tiddlywiki/tour" ], "themes": [ "tiddlywiki/vanilla", From 792a00f40513ae72457064a13518c999ccf319ba Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 30 Oct 2025 00:42:43 +0800 Subject: [PATCH 69/70] Update text.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plugins/tiddlywiki/freelinks/text.js#L25 [ESLint PR code] reported by reviewdog 🐶 Strings must use doublequote. --- plugins/tiddlywiki/freelinks/text.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/tiddlywiki/freelinks/text.js b/plugins/tiddlywiki/freelinks/text.js index 3cb68c7cc54..5af9fffc0ee 100755 --- a/plugins/tiddlywiki/freelinks/text.js +++ b/plugins/tiddlywiki/freelinks/text.js @@ -22,7 +22,7 @@ var ESCAPE_REGEX = /[\\^$*+?.()|[\]{}]/g; function escapeRegExp(str) { try { - return str.replace(ESCAPE_REGEX, '\\$&'); + return str.replace(ESCAPE_REGEX, "\\$&"); } catch(e) { return null; } From bfc9f07a32456356c2ce783bc302783b7be9bf57 Mon Sep 17 00:00:00 2001 From: s793016 Date: Thu, 30 Oct 2025 00:44:55 +0800 Subject: [PATCH 70/70] Update aho-corasick.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plugins/tiddlywiki/freelinks/aho-corasick.js#L193 [ESLint PR code] reported by reviewdog 🐶 Strings must use doublequote. Raw Output: {"ruleId":"@stylistic/quotes","severity":2,"message":"Strings must use doublequote.","line":193,"column":50,"nodeType":"Literal","messageId":"wrongQuotes","endLine":193,"endColumn":52,"fix":{"range":[5743,5745],"text":"\"\""}} --- plugins/tiddlywiki/freelinks/aho-corasick.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/tiddlywiki/freelinks/aho-corasick.js b/plugins/tiddlywiki/freelinks/aho-corasick.js index 8835687ad4f..bcbcf9b4429 100644 --- a/plugins/tiddlywiki/freelinks/aho-corasick.js +++ b/plugins/tiddlywiki/freelinks/aho-corasick.js @@ -189,8 +189,8 @@ AhoCorasick.prototype.search = function(text, useWordBoundary) { }; AhoCorasick.prototype.isWordBoundaryMatch = function(text, start, end) { - var beforeChar = start > 0 ? text[start - 1] : ''; - var afterChar = end < text.length ? text[end] : ''; + var beforeChar = start > 0 ? text[start - 1] : ""; + var afterChar = end < text.length ? text[end] : ""; var isWordChar = function(char) { return /[a-zA-Z0-9_\u00C0-\u00FF]/.test(char);