TF-4054 Fix link detection
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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&lang=en">https://google.com?q=flutter&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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user