TF-4054 Fix link detection

This commit is contained in:
dab246
2025-10-01 00:17:24 +07:00
committed by Dat H. Pham
parent 05b11fb779
commit aa710113b0
3 changed files with 453 additions and 0 deletions
+73
View File
@@ -2,6 +2,8 @@ import 'dart:convert';
import 'dart:math';
import 'package:core/utils/html/html_template.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as parser;
import 'package:html_unescape/html_unescape.dart';
import 'js_interop_stub.dart' if (dart.library.html) 'dart:js_interop';
@@ -713,4 +715,75 @@ class HtmlUtils {
return cleaned;
}
static String wrapPlainTextLinks(String htmlString) {
try {
final document = parser.parse(htmlString);
final container = document.body;
if (container == null) return htmlString;
final urlRegex = RegExp(r'''(?:(?:https?:\/\/)|(?:www\.))[^\s<]+[^<.,:;\"')\]\s!?]''');
_processNode(container, urlRegex);
return container.innerHtml;
} catch (e) {
logError('HtmlUtils::wrapPlainTextLinks:Exception = $e');
return htmlString;
}
}
static final _skipTags = {
'a', 'img', 'video', 'audio', 'source', 'link', 'script', 'iframe'
};
static void _processNode(dom.Node node, RegExp urlRegex) {
for (var child in node.nodes.toList()) {
// Skip if node or parent node is in tag to skip
final parentTag = child.parent?.localName;
if (parentTag != null && _skipTags.contains(parentTag.toLowerCase())) {
continue;
}
if (child.nodeType == dom.Node.TEXT_NODE) {
final text = child.text ?? '';
final matches = urlRegex.allMatches(text);
if (matches.isEmpty) continue;
final nodes = <dom.Node>[];
int lastIndex = 0;
for (final match in matches) {
final url = match.group(0)!;
final start = match.start;
if (start > lastIndex) {
nodes.add(dom.Text(text.substring(lastIndex, start)));
}
final href = url.startsWith('http') ? url : 'https://$url';
final link = dom.Element.tag('a')
..attributes['href'] = href
..text = url;
nodes.add(link);
lastIndex = match.end;
}
if (lastIndex < text.length) {
nodes.add(dom.Text(text.substring(lastIndex)));
}
final parent = child.parent!;
final index = parent.nodes.indexOf(child);
parent.nodes.removeAt(index);
parent.nodes.insertAll(index, nodes);
} else {
_processNode(child, urlRegex);
}
}
}
}
+377
View File
@@ -322,4 +322,381 @@ void main() {
expect(HtmlUtils.extractPlainText(html), '<heloloasdadadadadsad>dab');
});
});
group('HtmlUtils.wrapPlainTextLinks', () {
test('wraps plain text link inside <p>', () {
const input = '<div><p>https://google.com</p></div>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<div><p><a href="https://google.com">https://google.com</a></p></div>',
);
});
test('keeps existing <a> tags unchanged', () {
const input = "<div><a href='https://google.com'>Hello</a></div>";
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
"<div><a href=\"https://google.com\">Hello</a></div>",
);
});
test('wraps multiple links in text', () {
const input = '<p>Go to https://flutter.dev and https://dart.dev now</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>Go to <a href="https://flutter.dev">https://flutter.dev</a> and <a href="https://dart.dev">https://dart.dev</a> now</p>',
);
});
test('keeps non-link text unchanged', () {
const input = '<p>Hello world!</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(result.trim(), '<p>Hello world!</p>');
});
test('handles mixed content with existing and new links', () {
const input = '''
<div>
<p>Visit https://google.com or <a href="https://flutter.dev">Flutter</a></p>
</div>
''';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<div>\n<p>Visit <a href="https://google.com">https://google.com</a> or <a href="https://flutter.dev">Flutter</a></p>\n</div>',
);
});
test('returns input if body is null', () {
const input = '';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(result, input);
});
test('handles link with trailing punctuation', () {
const input = '<p>Check https://google.com, it is great!</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>Check <a href="https://google.com">https://google.com</a>, it is great!</p>',
);
});
test('handles link with query parameters', () {
const input = '<p>Go to https://example.com/search?q=test</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>Go to <a href="https://example.com/search?q=test">https://example.com/search?q=test</a></p>',
);
});
test('wraps link inside span', () {
const input = '<span>See https://flutter.dev/docs for details</span>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<span>See <a href="https://flutter.dev/docs">https://flutter.dev/docs</a> for details</span>',
);
});
test('handles multiple paragraphs with links', () {
const input = '''
<div>
<p>Visit https://google.com</p>
<p>Then go to https://flutter.dev</p>
</div>
''';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<div>\n <p>Visit <a href="https://google.com">https://google.com</a></p>\n <p>Then go to <a href="https://flutter.dev">https://flutter.dev</a></p>\n</div>',
);
});
test('ignores links already wrapped in <a>', () {
const input =
'<p><a href="https://google.com">https://google.com</a> is wrapped</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p><a href="https://google.com">https://google.com</a> is wrapped</p>',
);
});
test('handles link with trailing parenthesis correctly', () {
const input = '<p>(https://example.com)</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>(<a href="https://example.com">https://example.com</a>)</p>',
);
});
test('handles link followed by exclamation mark', () {
const input = '<p>Wow https://amazing.dev!</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>Wow <a href="https://amazing.dev">https://amazing.dev</a>!</p>',
);
});
test('handles link followed by question mark', () {
const input = '<p>What is https://example.com?</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>What is <a href="https://example.com">https://example.com</a>?</p>',
);
});
test('handles http (non-https) link', () {
const input = '<p>Visit http://example.org for info</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>Visit <a href="http://example.org">http://example.org</a> for info</p>',
);
});
test('handles multiple text segments and links mixed', () {
const input =
'<p>Before https://link1.com middle https://link2.com end</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>Before <a href="https://link1.com">https://link1.com</a> middle <a href="https://link2.com">https://link2.com</a> end</p>',
);
});
test('handles sentence with link between words', () {
const input = '<p>Check this https://docs.flutter.dev today</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>Check this <a href="https://docs.flutter.dev">https://docs.flutter.dev</a> today</p>',
);
});
test('handles link at beginning of text', () {
const input = '<p>https://start.com is the first link</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p><a href="https://start.com">https://start.com</a> is the first link</p>',
);
});
test('handles link at end of text', () {
const input = '<p>Go to end https://end.com</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>Go to end <a href="https://end.com">https://end.com</a></p>',
);
});
test('handles text containing only the link', () {
const input = '<p>https://onlylink.com</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p><a href="https://onlylink.com">https://onlylink.com</a></p>',
);
});
test('wraps link starting with www', () {
const input = '<p>Visit www.google.com for info</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>Visit <a href="https://www.google.com">www.google.com</a> for info</p>',
);
});
test('handles link followed by colon', () {
const input = '<p>URL: https://flutter.dev:</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>URL: <a href="https://flutter.dev">https://flutter.dev</a>:</p>',
);
});
test('handles parentheses around link', () {
const input = '<p>(https://parenthesis.com)</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>(<a href="https://parenthesis.com">https://parenthesis.com</a>)</p>',
);
});
test('handles link inside nested span', () {
const input = '<p><span>Check https://nested.com inside span</span></p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p><span>Check <a href="https://nested.com">https://nested.com</a> inside span</span></p>',
);
});
test('handles multiple links across multiple tags', () {
const input = '<p>First https://one.com</p><p>Second https://two.com</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>First <a href="https://one.com">https://one.com</a></p><p>Second <a href="https://two.com">https://two.com</a></p>',
);
});
test('handles link with port number', () {
const input = '<p>Visit http://localhost:8080/home</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>Visit <a href="http://localhost:8080/home">http://localhost:8080/home</a></p>',
);
});
test('handles unicode domain', () {
const input = '<p>Try https://例子.测试</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>Try <a href="https://例子.测试">https://例子.测试</a></p>',
);
});
test('handles link with query params & escapes correctly', () {
const input = '<p>Search https://google.com?q=flutter&lang=en</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<p>Search <a href="https://google.com?q=flutter&amp;lang=en">https://google.com?q=flutter&amp;lang=en</a></p>',
);
});
test('does not wrap text inside existing <a>', () {
const input = '<a href="https://flutter.dev">https://flutter.dev</a>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.trim(),
'<a href="https://flutter.dev">https://flutter.dev</a>',
);
});
test('handles empty html safely', () {
const input = '';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(result, '');
});
test('handles text without link', () {
const input = '<p>No link here</p>';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(result.trim(), '<p>No link here</p>');
});
test('handles invalid HTML gracefully', () {
const input = '<p>Some broken <div> www.test.com';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(result.contains('<a href="https://www.test.com">'), true);
});
test('ignores URLs inside img/video/link/script attributes', () {
const input = '''
<div>
<p>Visit www.google.com</p>
<img src="https://example.com/image.jpg" alt="example">
<video src="https://example.com/video.mp4"></video>
<a href="https://flutter.dev">Flutter</a>
</div>
''';
final result = HtmlUtils.wrapPlainTextLinks(input);
expect(
result.contains('<a href="https://www.google.com">www.google.com</a>'),
true,
reason: 'Text link www.google.com should be wrapped',
);
expect(result.contains('<img src="https://example.com/image.jpg"'), true);
expect(result.contains('<video src="https://example.com/video.mp4"'), true);
expect(result.contains('<a href="https://flutter.dev">Flutter</a>'), true);
});
test('does not alter src or href attributes', () {
const input = '''
<div>
<p>Visit www.google.com</p>
<img src="https://example.com/image.jpg" alt="example">
<video src="https://example.com/video.mp4"></video>
<a href="https://flutter.dev">Flutter</a>
</div>
''';
final result = HtmlUtils.wrapPlainTextLinks(input).trim();
expect(
result,
'<div>\n <p>Visit <a href="https://www.google.com">www.google.com</a></p>\n '
'<img src="https://example.com/image.jpg" alt="example">\n '
'<video src="https://example.com/video.mp4"></video>\n '
'<a href="https://flutter.dev">Flutter</a>\n</div>',
);
});
test('does NOT wrap URLs inside special tags (img, video, script, link, iframe)', () {
const input = '''
<div>
<p>Check www.google.com</p>
<img src="https://cdn.example.com/image.jpg" alt="Sample image with link google.com">
<video src="https://cdn.example.com/video.mp4">
www.google.com
</video>
<script>console.log("Visit www.google.com");</script>
<iframe src="https://player.example.com"></iframe>
<link rel="stylesheet" href="https://cdn.example.com/style.css">
</div>
''';
final result = HtmlUtils.wrapPlainTextLinks(input);
// The URL in <p> should be wrapped
expect(
result.contains('<a href="https://www.google.com">www.google.com</a>'),
true,
reason:
'The text www.google.com inside <p> should be wrapped into a link',
);
// There must NOT be any <a> tag inside these special tags
final forbiddenTags = ['img', 'video', 'script', 'iframe', 'link'];
for (final tag in forbiddenTags) {
final pattern = RegExp('<$tag[^>]*>.*?<\\/\\s*$tag>', dotAll: true);
final matches = pattern.allMatches(result);
for (final match in matches) {
final segment = match.group(0)!;
expect(
segment.contains('<a '),
false,
reason: 'There must be no <a> tag inside <$tag>',
);
}
}
// There must NOT be any <a> tag inside href/src attributes
final attributePattern = RegExp(r'(src|href)="[^"]*<a[^"]*"');
expect(
attributePattern.hasMatch(result),
false,
reason: 'There must be no <a> tag inside href or src attributes',
);
});
});
}
@@ -1,6 +1,7 @@
import 'package:core/data/constants/constant.dart';
import 'package:core/utils/app_logger.dart';
import 'package:core/utils/application_manager.dart';
import 'package:core/utils/html/html_utils.dart';
import 'package:dartz/dartz.dart';
import 'package:dio/dio.dart';
import 'package:jmap_dart_client/jmap/mail/email/email.dart';
@@ -71,6 +72,8 @@ class ComposerRepositoryImpl extends ComposerRepository {
emailContent = await removeCollapsedExpandedSignatureEffect(emailContent: emailContent);
emailContent = HtmlUtils.wrapPlainTextLinks(emailContent);
final userAgent = await _applicationManager.generateApplicationUserAgent();
final emailBodyPartId = PartId(_uuid.v1());