Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
242 changes: 242 additions & 0 deletions plugins/tiddlywiki/freelinks/aho-corasick.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/*\
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.

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";

function AhoCorasick() {
this.trie = {};
this.failure = {};
this.maxFailureDepth = 100;
this.patternCount = 0;
}

AhoCorasick.prototype.addPattern = function(pattern, index) {
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]) {
node[char] = {};
}
node = node[char];
}

if(!node.$) {
node.$ = [];
}
node.$.push({
pattern: pattern,
index: index,
length: pattern.length
});

this.patternCount++;
};

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 processedNodes = 0;
var maxNodes = Math.max(100000, this.patternCount * 15);

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 failureDepth = 0;

while(fail && !fail[char] && failureDepth < this.maxFailureDepth) {
fail = this.failure[fail];
failureDepth++;
}

var failureLink = (fail && fail[char]) ? fail[char] : root;
this.failure[child] = failureLink;

var failureOutput = this.failure[child];
if(failureOutput && failureOutput.$) {
if(!child.$) {
child.$ = [];
}
child.$.push.apply(child.$, failureOutput.$);
}

queue.push(child);
}
}
}

if(processedNodes >= maxNodes) {
throw new Error("Aho-Corasick: buildFailureLinks exceeded maximum nodes (" + maxNodes + ")");

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.

There is the question if we want to "throw" an RSOD error here, or if we can fail more silently.
IMO the wiki is not broken -- the index has a problem. -- I'm not sure

We will need a translatable string here.

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.

This is a valid concern. Instead of throwing a hard error that could crash the application, you might want to log a warning and gracefully degrade functionality. The error message should indeed be translatable for internationalization.

For the discussion points: These need more consideration and discussion before implementing changes, as they involve functionality and internationalization concerns.

}
};

AhoCorasick.prototype.search = function(text, useWordBoundary) {
if(!text || typeof text !== "string" || text.length === 0) {
return [];
}

var matches = [];
var node = this.trie;
var textLength = text.length;
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] && 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) {
if(currentNode.$) {
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: matchStart,
length: output.length,
titleIndex: output.index
});
}
}
currentNode = this.failure[currentNode];
if(currentNode === this.trie) break;
collectCount++;
}
}

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_\u00C0-\u00FF]/.test(char);
};

var beforeIsWord = beforeChar && isWordChar(beforeChar);
var afterIsWord = afterChar && isWordChar(afterChar);

return !beforeIsWord && !afterIsWord;
};

AhoCorasick.prototype.clear = function() {
this.trie = {};
this.failure = {};
this.patternCount = 0;
};

AhoCorasick.prototype.getStats = function() {

@saqimtiaz saqimtiaz Feb 19, 2026

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.

@s793016 @linonetwo are we sure that this method has the correct logic?

In countNodes() we increment the local variable patternCount, but we never use it and return this.patternCount 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.

Simply put: it counts one variable but returns another, effectively rendering the counting useless.

This eslint-disable-next-line no-unused-vars annotation itself hints at the problem—ESLint has already warned that the local variable patternCount is unused.

The getStats() function's return value is actually correct—it's just that the intermediate calculation is redundant.

Additionally, thanks to Claude Sonnet 4.6, we've identified other issues that will be addressed collectively.

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.

@s793016 Now you see AI are lazy, they tend to hide the error using eslint-disable, or modify the test case so its work could easily done. Make sure to use Claude Opus 4.6 to final review the code.

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.

I think it's not that AI is lazy, but that the AI at the time wasn't strong enough. The first version before June 2025 was clumsily put together by Grok, then to extract the algorithm independently, Grok couldn't handle it and passed the baton to Claude, and later I showed the code to ChatGPT, who also didn't spot the issues. Until a couple of days ago when Claude 4.6 came online, it immediately identified several logic problems—the progress of AI in this past half year or more has really been tremendous.

Back in mid-2025, the models (including earlier Groks) were still hitting limits on complex refactors like extracting algorithms without introducing subtle logic bugs. Handing it off to Claude made sense at the time, and even ChatGPT missing those issues highlights how context-dependent error detection can be. Fast-forward to now (early 2026), and yeah, the jumps in reasoning depth are huge. Claude 4.6 spotting those logic flaws right away? That's a testament to better multi-step thinking and pattern recognition in newer architectures. We've seen similar strides across the board.

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.

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]);
}
}
}

countNodes(this.trie);

for(var key in this.failure) {
failureCount++;
}

return {
nodeCount: nodeCount,
patternCount: this.patternCount,
failureLinks: failureCount
};
};

exports.AhoCorasick = AhoCorasick;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: $:/config/Freelinks/WordBoundary
text: yes
31 changes: 26 additions & 5 deletions plugins/tiddlywiki/freelinks/readme.tid
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,36 @@ title: $:/plugins/tiddlywiki/freelinks/readme

This plugin adds automatic generation of links to tiddler titles.

''Note that automatic link generation can be very slow when there are a large number of tiddlers''.
The plugin uses the Aho-Corasick algorithm for efficient pattern matching with large numbers of tiddlers.

!! Configuration

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`

Freelinks are case sensitive by default but can be configured to ignore case in the settings tab.
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.

Tiddlers do not create links to themselves.

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 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 content of `$:/config/Freelinks/Enable`, which can be set via the settings panel of this plugin.

!! 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 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}}/>
```

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

Instead, we can use a filter expression to, say, only freelink within the tiddler with the title "HelloThere":

Expand All @@ -40,3 +50,14 @@ Or we can combine both approaches:
```
<$set name="tv-freelinks" value={{{ [<currentTiddler>match[HelloThere]] ~[<currentTiddler>tag[MyTag]] +[then[yes]else[no]] }}}/>
```

!! Implementation Details

The Aho-Corasick algorithm implementation includes:

* 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

Longer tiddler titles take precedence over shorter ones when multiple matches are possible.
2 changes: 2 additions & 0 deletions plugins/tiddlywiki/freelinks/settings.tid
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ 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</$link> </$checkbox>

<$checkbox tiddler="$:/config/Freelinks/WordBoundary" field="text" checked="yes" unchecked="no" default="yes"> <$link to="$:/config/Freelinks/WordBoundary">Word Boundary Check</$link> </$checkbox>

<$checkbox tiddler="$:/config/Freelinks/IgnoreCase" field="text" checked="yes" unchecked="no" default="no"> <$link to="$:/config/Freelinks/IgnoreCase">Ignore case</$link> </$checkbox>
Loading