mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-04-11 11:23:47 +08:00
feat: add XML and HTML formatting support (#721)
This commit is contained in:
@@ -25,6 +25,7 @@ enum ContentType {
|
||||
formData,
|
||||
js,
|
||||
html,
|
||||
xml,
|
||||
text,
|
||||
css,
|
||||
font,
|
||||
|
||||
@@ -38,6 +38,10 @@ abstract class HttpMessage {
|
||||
"text/css": ContentType.css,
|
||||
"font-woff": ContentType.font,
|
||||
"text/html": ContentType.html,
|
||||
"application/xhtml+xml": ContentType.html,
|
||||
"+xml": ContentType.xml,
|
||||
"application/xml": ContentType.xml,
|
||||
"text/xml": ContentType.xml,
|
||||
"text/plain": ContentType.text,
|
||||
"application/x-www-form-urlencoded": ContentType.formUrl,
|
||||
"form-data": ContentType.formData,
|
||||
@@ -128,11 +132,9 @@ abstract class HttpMessage {
|
||||
|
||||
if (headers.isGzip) {
|
||||
rawBody = gzipDecode(body!);
|
||||
}else
|
||||
|
||||
if (headers.contentEncoding == 'br') {
|
||||
} else if (headers.contentEncoding == 'br') {
|
||||
rawBody = brDecode(body!);
|
||||
} else if (headers.contentEncoding == 'deflate') {
|
||||
} else if (headers.contentEncoding == 'deflate') {
|
||||
rawBody = zlibDecode(body!);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,11 +47,12 @@ class SearchConditionsState extends State<SearchConditions> {
|
||||
|
||||
final Map<String, ContentType?> responseContentMap = {
|
||||
'JSON': ContentType.json,
|
||||
'IMAGE': ContentType.image,
|
||||
'HTML': ContentType.html,
|
||||
'JS': ContentType.js,
|
||||
'CSS': ContentType.css,
|
||||
'TEXT': ContentType.text,
|
||||
'IMAGE': ContentType.image
|
||||
'XML': ContentType.xml,
|
||||
};
|
||||
|
||||
late SearchModel searchModel;
|
||||
|
||||
@@ -30,6 +30,7 @@ import '../../utils/platform.dart';
|
||||
const contentMap = {
|
||||
ContentType.json: Icons.data_object,
|
||||
ContentType.html: Icons.html,
|
||||
ContentType.xml: Icons.code,
|
||||
ContentType.js: Icons.javascript,
|
||||
ContentType.image: Icons.image,
|
||||
ContentType.video: Icons.video_call,
|
||||
|
||||
@@ -36,9 +36,11 @@ import 'package:proxypin/ui/component/utils.dart';
|
||||
import 'package:proxypin/ui/desktop/setting/request_rewrite.dart';
|
||||
import 'package:proxypin/ui/mobile/setting/request_rewrite.dart';
|
||||
import 'package:proxypin/utils/crypto_body_decoder.dart';
|
||||
import 'package:proxypin/utils/html_formatter.dart';
|
||||
import 'package:proxypin/utils/lang.dart';
|
||||
import 'package:proxypin/utils/num.dart';
|
||||
import 'package:proxypin/utils/platform.dart';
|
||||
import 'package:proxypin/utils/xml_formatter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../component/json/json_text.dart';
|
||||
@@ -509,43 +511,61 @@ class _BodyState extends State<_Body> {
|
||||
return _getBody(viewType);
|
||||
}
|
||||
|
||||
HttpMessage? _effectiveMessage(HttpBodyState? parent) {
|
||||
if (parent?.showDecoded == true && parent?.decoded != null && message != null) {
|
||||
return _DecodedHttpMessage(message!, parent!.decoded!);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
String _formatTextBody(ViewType type, String body) {
|
||||
try {
|
||||
if (type == ViewType.formUrl) {
|
||||
return Uri.decodeFull(body);
|
||||
}
|
||||
|
||||
if (type == ViewType.html) {
|
||||
return HTML.pretty(body);
|
||||
}
|
||||
|
||||
if (type == ViewType.xml) {
|
||||
return XML.pretty(body);
|
||||
}
|
||||
|
||||
if (type == ViewType.jsonText || type == ViewType.json) {
|
||||
var jsonObject = json.decode(body);
|
||||
return const JsonEncoder.withIndent(" ").convert(jsonObject);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
Future<String?> getBody() async {
|
||||
final parent = context.findAncestorStateOfType<HttpBodyState>();
|
||||
if (parent?.showDecoded == true && parent?.decoded?.text != null) {
|
||||
return parent!.decoded!.text;
|
||||
final currentMessage = _effectiveMessage(parent);
|
||||
|
||||
if (currentMessage?.isWebSocket == true) {
|
||||
return currentMessage?.messages.map((e) => e.payloadDataAsString).join("\n");
|
||||
}
|
||||
|
||||
if (message?.isWebSocket == true) {
|
||||
return message?.messages.map((e) => e.payloadDataAsString).join("\n");
|
||||
}
|
||||
|
||||
if (message == null || message?.body == null) {
|
||||
if (currentMessage == null || currentMessage.body == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (viewType == ViewType.hex) {
|
||||
return message!.body!.map(intToHex).join(" ");
|
||||
return currentMessage.body!.map(intToHex).join(" ");
|
||||
}
|
||||
|
||||
try {
|
||||
if (viewType == ViewType.formUrl) {
|
||||
return Uri.decodeFull(message!.bodyAsString);
|
||||
}
|
||||
|
||||
if (viewType == ViewType.jsonText || viewType == ViewType.json) {
|
||||
//json格式化
|
||||
var jsonObject = json.decode(await message!.decodeBodyString());
|
||||
return const JsonEncoder.withIndent(" ").convert(jsonObject);
|
||||
}
|
||||
} catch (_) {}
|
||||
return message!.decodeBodyString();
|
||||
final body = parent?.showDecoded == true && parent?.decoded?.text != null
|
||||
? parent!.decoded!.text!
|
||||
: await currentMessage.decodeBodyString();
|
||||
return _formatTextBody(viewType, body);
|
||||
}
|
||||
|
||||
Widget _getBody(ViewType type) {
|
||||
final parent = context.findAncestorStateOfType<HttpBodyState>();
|
||||
final message = parent?.showDecoded == true && parent?.decoded != null
|
||||
? _DecodedHttpMessage(widget.message!, parent!.decoded!)
|
||||
: widget.message;
|
||||
final message = _effectiveMessage(parent);
|
||||
|
||||
if (message?.isWebSocket == true ||
|
||||
(message?.contentType == ContentType.sse && message?.messages.isNotEmpty == true)) {
|
||||
@@ -592,12 +612,16 @@ class _BodyState extends State<_Body> {
|
||||
|
||||
if (type == ViewType.formUrl) {
|
||||
return HighlightTextWidget(
|
||||
text: Uri.decodeFull(message.getBodyString()),
|
||||
text: _formatTextBody(type, message.getBodyString()),
|
||||
searchController: widget.searchController,
|
||||
contextMenuBuilder: contextMenu);
|
||||
}
|
||||
|
||||
return futureWidget(message.decodeBodyString(), initialData: message.getBodyString(), (body) {
|
||||
final initialData = (type == ViewType.html || type == ViewType.xml)
|
||||
? _formatTextBody(type, message.getBodyString())
|
||||
: message.getBodyString();
|
||||
|
||||
return futureWidget(message.decodeBodyString(), initialData: initialData, (body) {
|
||||
try {
|
||||
if (type == ViewType.jsonText) {
|
||||
var jsonObject = json.decode(body);
|
||||
@@ -615,7 +639,9 @@ class _BodyState extends State<_Body> {
|
||||
}
|
||||
|
||||
return HighlightTextWidget(
|
||||
text: body, searchController: widget.searchController, contextMenuBuilder: contextMenu);
|
||||
text: _formatTextBody(type, body),
|
||||
searchController: widget.searchController,
|
||||
contextMenuBuilder: contextMenu);
|
||||
} catch (e) {
|
||||
logger.e(e, stackTrace: StackTrace.current);
|
||||
}
|
||||
@@ -653,7 +679,10 @@ class Tabs {
|
||||
tabs.list.add(ViewType.json);
|
||||
}
|
||||
|
||||
if (contentType == ContentType.formUrl || contentType == ContentType.json) {
|
||||
if (contentType == ContentType.formUrl ||
|
||||
contentType == ContentType.json ||
|
||||
contentType == ContentType.html ||
|
||||
contentType == ContentType.xml) {
|
||||
tabs.list.add(ViewType.text);
|
||||
}
|
||||
|
||||
@@ -672,6 +701,7 @@ enum ViewType {
|
||||
json("JSON"),
|
||||
jsonText("JSON Text"),
|
||||
html("HTML"),
|
||||
xml("XML"),
|
||||
image("Image"),
|
||||
video("Video"),
|
||||
css("CSS"),
|
||||
|
||||
@@ -155,7 +155,7 @@ class ContentTypeState extends State<ContentTypeSelect> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
value ??= localizations.all;
|
||||
types ??= ["JSON", "HTML", "JS", "CSS", "TEXT", "IMAGE", localizations.all];
|
||||
types ??= ["JSON", "IMAGE", "HTML", "JS", "CSS", "TEXT", "XML", localizations.all];
|
||||
|
||||
return PopupMenuButton(
|
||||
initialValue: value,
|
||||
|
||||
166
lib/utils/html_formatter.dart
Normal file
166
lib/utils/html_formatter.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
|
||||
class HTML {
|
||||
static final RegExp _documentTagPattern = RegExp(r'^\s*<(?:!doctype|html|head|body)\b', caseSensitive: false);
|
||||
|
||||
/// 格式化 HTML
|
||||
static String pretty(String htmlString) {
|
||||
if (htmlString.trim().isEmpty || !htmlString.contains('<')) {
|
||||
return htmlString;
|
||||
}
|
||||
|
||||
try {
|
||||
final root = _documentTagPattern.hasMatch(htmlString)
|
||||
? html_parser.parse(htmlString)
|
||||
: html_parser.parseFragment(htmlString);
|
||||
final buffer = StringBuffer();
|
||||
|
||||
for (final node in root.nodes) {
|
||||
_HtmlPrettyPrinter.writeNode(node, buffer, 0);
|
||||
}
|
||||
|
||||
final formatted = buffer.toString().trimRight();
|
||||
return formatted.isEmpty ? htmlString : formatted;
|
||||
} catch (_) {
|
||||
return htmlString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _HtmlPrettyPrinter {
|
||||
static const String _indent = ' ';
|
||||
static const Set<String> _voidElements = {
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr',
|
||||
};
|
||||
static const Set<String> _preserveContentElements = {'pre', 'script', 'style', 'textarea'};
|
||||
static const HtmlEscape _attributeEscaper = HtmlEscape(HtmlEscapeMode.attribute);
|
||||
|
||||
static void writeNode(dom.Node node, StringBuffer buffer, int depth) {
|
||||
if (node is dom.Text) {
|
||||
final text = _normalizeText(node.text);
|
||||
if (text.isNotEmpty) {
|
||||
_writeLine(buffer, depth, text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (node is dom.Comment) {
|
||||
_writeLine(buffer, depth, node.toString().trim());
|
||||
return;
|
||||
}
|
||||
|
||||
if (node is dom.DocumentType) {
|
||||
_writeLine(buffer, depth, node.toString().trim());
|
||||
return;
|
||||
}
|
||||
|
||||
if (node is dom.Element) {
|
||||
_writeElement(node, buffer, depth);
|
||||
return;
|
||||
}
|
||||
|
||||
for (final child in node.nodes) {
|
||||
writeNode(child, buffer, depth);
|
||||
}
|
||||
}
|
||||
|
||||
static void _writeElement(dom.Element element, StringBuffer buffer, int depth) {
|
||||
final tag = (element.localName ?? '').toLowerCase();
|
||||
if (tag.isEmpty) {
|
||||
for (final child in element.nodes) {
|
||||
writeNode(child, buffer, depth);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final openTag = _openTag(element, tag);
|
||||
|
||||
if (_voidElements.contains(tag)) {
|
||||
_writeLine(buffer, depth, openTag);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_preserveContentElements.contains(tag)) {
|
||||
_writeLine(buffer, depth, openTag);
|
||||
final content = element.innerHtml.trimRight();
|
||||
if (content.isNotEmpty) {
|
||||
for (final line in content.split('\n')) {
|
||||
_writeLine(buffer, depth + 1, line.trimRight());
|
||||
}
|
||||
}
|
||||
_writeLine(buffer, depth, '</$tag>');
|
||||
return;
|
||||
}
|
||||
|
||||
final children = element.nodes.where(_hasVisibleContent).toList();
|
||||
if (children.isEmpty) {
|
||||
_writeLine(buffer, depth, '$openTag</$tag>');
|
||||
return;
|
||||
}
|
||||
|
||||
final inlineText = _inlineText(children);
|
||||
if (inlineText != null) {
|
||||
_writeLine(buffer, depth, '$openTag$inlineText</$tag>');
|
||||
return;
|
||||
}
|
||||
|
||||
_writeLine(buffer, depth, openTag);
|
||||
for (final child in children) {
|
||||
writeNode(child, buffer, depth + 1);
|
||||
}
|
||||
_writeLine(buffer, depth, '</$tag>');
|
||||
}
|
||||
|
||||
static bool _hasVisibleContent(dom.Node node) {
|
||||
if (node is dom.Text) {
|
||||
return _normalizeText(node.text).isNotEmpty;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static String? _inlineText(List<dom.Node> children) {
|
||||
if (children.length != 1 || children.first is! dom.Text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final text = _normalizeText((children.first as dom.Text).text);
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
static String _openTag(dom.Element element, String tag) {
|
||||
if (element.attributes.isEmpty) {
|
||||
return '<$tag>';
|
||||
}
|
||||
|
||||
final attributes =
|
||||
element.attributes.entries.map((entry) => '${entry.key}="${_attributeEscaper.convert(entry.value)}"').join(' ');
|
||||
return '<$tag $attributes>';
|
||||
}
|
||||
|
||||
static String _normalizeText(String text) {
|
||||
return text.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
}
|
||||
|
||||
static void _writeLine(StringBuffer buffer, int depth, String line) {
|
||||
buffer
|
||||
..write(_indent * depth)
|
||||
..writeln(line);
|
||||
}
|
||||
}
|
||||
|
||||
18
lib/utils/xml_formatter.dart
Normal file
18
lib/utils/xml_formatter.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
class XML {
|
||||
/// 格式化 XML
|
||||
static String pretty(String xmlString) {
|
||||
if (xmlString.trim().isEmpty || !xmlString.contains('<')) {
|
||||
return xmlString;
|
||||
}
|
||||
|
||||
try {
|
||||
final document = XmlDocument.parse(xmlString);
|
||||
return document.toXmlString(pretty: true, indent: ' ');
|
||||
} catch (_) {
|
||||
return xmlString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ dependencies:
|
||||
qr_flutter: ^4.1.0
|
||||
flutter_qr_reader_plus: ^1.0.6
|
||||
brotli: ^0.6.0
|
||||
html: ^0.15.6
|
||||
xml: ^6.6.1
|
||||
# macos_window_utils: 1.6.1
|
||||
win32audio: ^1.3.1
|
||||
vclibs: ^0.1.3
|
||||
|
||||
43
test/html_format_test.dart
Normal file
43
test/html_format_test.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:proxypin/network/http/content_type.dart';
|
||||
import 'package:proxypin/network/http/http.dart';
|
||||
import 'package:proxypin/utils/html_formatter.dart';
|
||||
|
||||
void main() {
|
||||
test('HTML.pretty formats nested markup', () {
|
||||
const input = '<div><h1>Hello</h1><p>World <strong>!</strong></p></div>';
|
||||
|
||||
expect(
|
||||
HTML.pretty(input),
|
||||
'<div>\n'
|
||||
' <h1>Hello</h1>\n'
|
||||
' <p>\n'
|
||||
' World\n'
|
||||
' <strong>!</strong>\n'
|
||||
' </p>\n'
|
||||
'</div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('HTML.pretty tolerates malformed HTML', () {
|
||||
const input = '<div><span>hello</div>';
|
||||
|
||||
expect(
|
||||
HTML.pretty(input),
|
||||
'<div>\n'
|
||||
' <span>hello</span>\n'
|
||||
'</div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('HTML.pretty leaves plain text unchanged', () {
|
||||
expect(HTML.pretty('plain text body'), 'plain text body');
|
||||
});
|
||||
|
||||
test('xhtml content type maps to html', () {
|
||||
final response = HttpResponse(HttpStatus.ok);
|
||||
response.headers.set('content-type', 'application/xhtml+xml; charset=utf-8');
|
||||
|
||||
expect(response.contentType, ContentType.html);
|
||||
});
|
||||
}
|
||||
46
test/xml_format_test.dart
Normal file
46
test/xml_format_test.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:proxypin/network/http/content_type.dart';
|
||||
import 'package:proxypin/network/http/http.dart';
|
||||
import 'package:proxypin/utils/xml_formatter.dart';
|
||||
|
||||
void main() {
|
||||
test('XML.pretty formats nested document', () {
|
||||
const input = '<root><a>1</a><b><c>2</c></b></root>';
|
||||
|
||||
expect(
|
||||
XML.pretty(input),
|
||||
'<root>\n'
|
||||
' <a>1</a>\n'
|
||||
' <b>\n'
|
||||
' <c>2</c>\n'
|
||||
' </b>\n'
|
||||
'</root>',
|
||||
);
|
||||
});
|
||||
|
||||
test('XML.pretty falls back for malformed XML', () {
|
||||
const input = '<root><a></root>';
|
||||
expect(XML.pretty(input), input);
|
||||
});
|
||||
|
||||
test('xml content types map to ContentType.xml', () {
|
||||
final response = HttpResponse(HttpStatus.ok);
|
||||
|
||||
response.headers.set('content-type', 'application/xml; charset=utf-8');
|
||||
expect(response.contentType, ContentType.xml);
|
||||
|
||||
response.headers.set('content-type', 'text/xml; charset=utf-8');
|
||||
expect(response.contentType, ContentType.xml);
|
||||
|
||||
response.headers.set('content-type', 'application/soap+xml; charset=utf-8');
|
||||
expect(response.contentType, ContentType.xml);
|
||||
});
|
||||
|
||||
test('xhtml keeps html content type', () {
|
||||
final response = HttpResponse(HttpStatus.ok);
|
||||
response.headers.set('content-type', 'application/xhtml+xml; charset=utf-8');
|
||||
|
||||
expect(response.contentType, ContentType.html);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user