Skip to content
Merged
Changes from 1 commit
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
06cbbe8
Enhance Freelinks with Aho-Corasick for long titles and large wikis
s793016 Jun 8, 2025
bbef43c
delete comment
s793016 Jun 8, 2025
6c05f06
Create AhoCorasick.js
s793016 Jun 8, 2025
9138953
Update text.js
s793016 Jun 8, 2025
9316a53
Update text.js
s793016 Jun 8, 2025
ece4907
Update AhoCorasick.js
s793016 Jun 8, 2025
bd4ae9f
Update text.js
s793016 Jun 8, 2025
f3b673a
Update text.js
s793016 Jun 8, 2025
01c2ef7
move AhoCorasick to AhoCorasick.js
s793016 Jun 9, 2025
5002e36
update AhoCorasick.js
s793016 Jun 9, 2025
da803cf
Delete core/modules/utils/AhoCorasick.js
s793016 Jun 9, 2025
a0cf994
Update text.js
s793016 Jun 9, 2025
6b702ca
indentation modify
s793016 Jun 9, 2025
002ab04
remove function {}
s793016 Jun 9, 2025
3402cc5
remove function {}
s793016 Jun 9, 2025
0b13f17
Rename AhoCorasick.js to aho-corasick.js
s793016 Jun 10, 2025
9bcb309
Update tiddlywiki.info add freelink
s793016 Jun 10, 2025
2093f26
missing a comma here
s793016 Jun 10, 2025
1944783
clean up comments & use old style
s793016 Jun 10, 2025
f786038
try add it to editions/tw5.com-server for testing
s793016 Jun 10, 2025
7c3b62b
try add it to editions/prerelease for testing
s793016 Jun 11, 2025
8976b53
Merge branch 'master' into freelinks-aho-corasick
s793016 Jun 11, 2025
89e9fff
optimized
s793016 Jun 11, 2025
5f0b98d
optimized
s793016 Jun 11, 2025
b022fcf
add setting for "Persist AhoCorasick cache"
s793016 Jun 11, 2025
c0de611
add dynamic limits
s793016 Jun 11, 2025
43f4521
remove comment
s793016 Jun 11, 2025
a27c867
revert to 5f0b98d1fd50c71db4f71be25bbfaf0e0d6a6765
s793016 Jun 11, 2025
1de0e70
try sort alphabet
s793016 Jun 12, 2025
21dde76
try sort alphabet
s793016 Jun 12, 2025
873e238
try sort alphabet
s793016 Jun 12, 2025
5cff761
typo freelink -> freelinks
s793016 Jun 12, 2025
e832a5b
typo freelink -> freelinks
s793016 Jun 12, 2025
5330d09
typo freelink -> freelinks
s793016 Jun 12, 2025
167f64c
Update readme.tid
s793016 Jun 12, 2025
7270600
Update aho-corasick.js
s793016 Jun 12, 2025
ac72f0b
Update text.js
s793016 Jun 12, 2025
43685ab
Update tiddlywiki.info
s793016 Jun 12, 2025
d398f59
Update tiddlywiki.info
s793016 Jun 12, 2025
4c8afd1
Update tiddlywiki.info
s793016 Jun 12, 2025
291d511
Update aho-corasick.js
s793016 Jun 13, 2025
adef812
Update aho-corasick.js
s793016 Jun 13, 2025
85826f2
Update text.js
s793016 Jun 13, 2025
1e466e7
Update settings.tid
s793016 Jun 13, 2025
a20fe85
fix Word Boundary logic
s793016 Jun 13, 2025
506927d
remove PersistentCache @ text.js
s793016 Jun 13, 2025
9c90b87
remove PersistentCache @settings.tid
s793016 Jun 13, 2025
951edc3
Update readme.tid for Word Boundary Check
s793016 Jun 13, 2025
a2b252d
Update aho-corasick.js Organize and delete comments
s793016 Jun 13, 2025
1d31619
Initial commit of freelinks plugin
s793016 Jun 13, 2025
d09764f
Update settings.tid Organize and delete comments
s793016 Jun 13, 2025
dcd6a61
Update tiddlywiki.info add back other plugin
s793016 Jun 13, 2025
13a2fa9
Update tiddlywiki.info alphabet sort
s793016 Jun 13, 2025
6f1721f
Update readme.tid for new future
s793016 Jun 14, 2025
98a0713
Update readme.tid
s793016 Jun 14, 2025
8f86e6b
Update plugins/tiddlywiki/freelinks/text.js
s793016 Jul 8, 2025
3a6f7d2
Update plugins/tiddlywiki/freelinks/aho-corasick.js
s793016 Jul 8, 2025
72db370
Update plugins/tiddlywiki/freelinks/aho-corasick.js
s793016 Jul 8, 2025
ae73b1b
Update plugins/tiddlywiki/freelinks/text.js
s793016 Jul 8, 2025
ff0507d
Update plugins/tiddlywiki/freelinks/aho-corasick.js
s793016 Jul 8, 2025
2c73f62
Update text.js
s793016 Jul 8, 2025
62995b6
Update text.js
s793016 Jul 8, 2025
b254d13
Update aho-corasick.js
s793016 Jul 8, 2025
b03c6c9
Update aho-corasick.js useage
s793016 Jul 11, 2025
6536ed6
Update readme.tid for Writing Style
s793016 Jul 14, 2025
41c493b
Update tiddlywiki.info
s793016 Oct 29, 2025
f2aa7eb
Update tiddlywiki.info
s793016 Oct 29, 2025
fcd6681
Update tiddlywiki.info
s793016 Oct 29, 2025
0141a9f
Update tiddlywiki.info
s793016 Oct 29, 2025
792a00f
Update text.js
s793016 Oct 29, 2025
bfc9f07
Update aho-corasick.js
s793016 Oct 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
343 changes: 274 additions & 69 deletions plugins/tiddlywiki/freelinks/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(){

Copy link
Copy Markdown
Contributor

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.

(function(){
/*jslint node: true, browser: true */
/*global $tw: false */

Copy link
Copy Markdown
Contributor Author

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

Copy link
Copy Markdown
Contributor Author

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


/*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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refactor this class to another js, and require it in this file instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move AhoCorasick to AhoCorasick.js ok

Copy link
Copy Markdown
Contributor Author

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

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);
};
Expand Down Expand Up @@ -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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to trim, like other config tiddlers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete .trim ok

Copy link
Copy Markdown
Contributor Author

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

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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why use both getPersistentCache and getGlobalCache?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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”
This is actually a meaningful design:

getPersistentCache: cross-session persistence, suitable for large wikis
getGlobalCache: single-session cache, suitable for general use.

Selected dynamically based on $:/config/Freelinks/PersistAhoCorasickCache configuration.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I search the repo, there is no getPersistentCache, so this is Grok's illusion. So there is only getGlobalCache. While I hope we cound have a getPersistentCache in indexed-db, but that is for another PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

@pmario pmario Jun 10, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

this.clearGlobalCache();

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

@s793016 s793016 Jun 8, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove logs before merging.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove log ok

Copy link
Copy Markdown
Contributor Author

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

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;
Expand All @@ -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;

})();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

modify ok