-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Aho-Corasick Freelinks Enhancement for Large Wikis and Non-Latin Titles #9084
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
06cbbe8
bbef43c
6c05f06
9138953
9316a53
ece4907
bd4ae9f
f3b673a
01c2ef7
5002e36
da803cf
a0cf994
6b702ca
002ab04
3402cc5
0b13f17
9bcb309
2093f26
1944783
f786038
7c3b62b
8976b53
89e9fff
5f0b98d
b022fcf
c0de611
43f4521
a27c867
1de0e70
21dde76
873e238
5cff761
e832a5b
5330d09
167f64c
7270600
ac72f0b
43685ab
d398f59
4c8afd1
291d511
adef812
85826f2
1e466e7
a20fe85
506927d
9c90b87
951edc3
a2b252d
1d31619
d09764f
dcd6a61
13a2fa9
6f1721f
98a0713
8f86e6b
3a6f7d2
72db370
ae73b1b
ff0507d
2c73f62
62995b6
b254d13
b03c6c9
6536ed6
41c493b
f2aa7eb
fcd6681
0141a9f
792a00f
bfc9f07
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -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() { | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. refactor this class to another js, and
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. move AhoCorasick to AhoCorasick.js ok
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Modified it again and it's now functioning properly |
||||
| 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"; | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no need to trim, like other config tiddlers.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. delete .trim ok
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Modified it again and it's now functioning properly |
||||
| 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() { | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why use both getPersistentCache and getGlobalCache?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cache logic clarification Keeps the two caching policies, but adds a note saying “Unified Caching Policy” getPersistentCache: cross-session persistence, suitable for large wikis Selected dynamically based on $:/config/Freelinks/PersistAhoCorasickCache configuration.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, I search the repo, there is no
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, “getPersistentCache“ is not an illusion, it's an additional static caching of large data (default: off). Persistent Cache Toggle: Optional persistent caching of the Aho-Corasick automaton via $:/config/Freelinks/PersistAhoCorasickCache (default: no). Enabled (yes) reduces rebuild time by ~100-200ms per refresh. Cache invalidates on title changes, ensuring consistency.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The global cache is very short lived. Everytime the wiki.addTiddler() function is called the global cache will be cleared. So basically whenever there is any UI interaction. Since UI interactions most to the time use state- or temp-tiddlers Line 1232 in a3b8c7c
Also see: freelinks.js uses globalCache object, which is destroyed most of the time #4572 |
||||
| 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 | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let AI delete these comments, I use the prompt: Do not use repetitive comments that only describe the code content, only comments code that are difficult to discern the abstract design intent
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. clean ok |
||||
| 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", | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we have this kind of parse tree node? I haven't encounter it before.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @linonetwo Thanks for the feedback! I tried changing plain-text to text, but it caused TiddlyWiki to fail starting, likely due to parser conflicts. I've reverted to plain-text for now to ensure stability.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see, now I know it! |
||||
| 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()); | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove logs before merging.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove log ok
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Modified it again and it's now functioning properly |
||||
| 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; | ||||
|
|
||||
| })(); | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same here -- This belongs to the function() wrapper. Should not be needed any more
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. modify ok |
||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On master branch we already remove, you need to let AI take latest code as reference. You can drag several js file to AI context. And don't remember to let it follow ES5 and other tw code style.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry I'm a noob, I only use forums when I use github.
/*jslint node: true, browser: true */
/*global $tw: false */
deleted
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Modified it again and it's now functioning properly