mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-03-15 04:23:17 +08:00
Add export/import functionality for favorites (#655)
This commit is contained in:
@@ -260,6 +260,7 @@ class HttpRequest extends HttpMessage {
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'_class': 'HttpRequest',
|
||||
'_id': requestId,
|
||||
'uri': requestUrl,
|
||||
'method': method.name,
|
||||
'protocolVersion': protocolVersion,
|
||||
@@ -273,7 +274,8 @@ class HttpRequest extends HttpMessage {
|
||||
factory HttpRequest.fromJson(Map<String, dynamic> json) {
|
||||
var request = HttpRequest(HttpMethod.valueOf(json['method']), json['uri'],
|
||||
protocolVersion: json['protocolVersion'] ?? "HTTP/1.1");
|
||||
|
||||
|
||||
request.requestId = json['_id'] ?? request.requestId;
|
||||
request.headers.addAll(HttpHeaders.fromJson(json['headers']));
|
||||
request.body = json['body']?.toString().codeUnits;
|
||||
if (json['requestTime'] != null) {
|
||||
|
||||
@@ -15,10 +15,12 @@
|
||||
*/
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:proxypin/network/http/http.dart';
|
||||
import 'package:proxypin/network/util/logger.dart';
|
||||
import 'package:proxypin/storage/path.dart';
|
||||
import 'package:proxypin/utils/har.dart';
|
||||
|
||||
/// 收藏存储
|
||||
/// @author WangHongEn
|
||||
@@ -70,14 +72,68 @@ class FavoriteStorage {
|
||||
}
|
||||
|
||||
//刷新配置
|
||||
static void flushConfig() async {
|
||||
static Future<void> flushConfig() async {
|
||||
var list = await favorites;
|
||||
Paths.getPath("favorites.json").then((file) => file.writeAsString(toJson(list)));
|
||||
await Paths.getPath("favorites.json").then((file) => file.writeAsString(toJson(list)));
|
||||
}
|
||||
|
||||
static String toJson(Queue<Favorite> list) {
|
||||
return jsonEncode(list.map((e) => e.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Export all favorites to a given file path
|
||||
static Future<void> exportToFile(String path) async {
|
||||
var current = await favorites;
|
||||
var content = toJson(current);
|
||||
await File(path).writeAsString(content, flush: true);
|
||||
}
|
||||
|
||||
/// Export all favorites as HAR to a given file path
|
||||
static Future<void> exportToHarFile(String path, {String title = 'Favorites'}) async {
|
||||
var current = await favorites;
|
||||
final requests = current.map((f) => f.request).toList(growable: false);
|
||||
await Har.writeFile(requests, File(path), title: title);
|
||||
}
|
||||
|
||||
/// Import favorites from a JSON or HAR file (merges with current list, de-duping by requestId)
|
||||
static Future<void> importFromFile(String path) async {
|
||||
final file = File(path);
|
||||
if (!await file.exists()) {
|
||||
throw Exception('File not found');
|
||||
}
|
||||
|
||||
final lower = path.toLowerCase();
|
||||
List<Favorite> imported;
|
||||
if (lower.endsWith('.har')) {
|
||||
// HAR import
|
||||
final requests = await Har.readFile(file);
|
||||
imported = requests.map((r) => Favorite(r)).toList(growable: false);
|
||||
} else {
|
||||
// JSON import (old format)
|
||||
final content = await file.readAsString();
|
||||
if (content.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
final decoded = jsonDecode(content) as List<dynamic>;
|
||||
imported = decoded.map((e) => Favorite.fromJson(e as Map<String, dynamic>)).toList(growable: false);
|
||||
}
|
||||
|
||||
final current = await favorites;
|
||||
final existingIds = current.map((e) => e.request.requestId).toSet();
|
||||
|
||||
// Merge without replacing current entries; skip duplicates by requestId
|
||||
for (var fav in imported.reversed) {
|
||||
final rid = fav.request.requestId;
|
||||
if (existingIds.contains(rid)) {
|
||||
continue;
|
||||
}
|
||||
existingIds.add(rid);
|
||||
current.addFirst(fav);
|
||||
}
|
||||
|
||||
await flushConfig();
|
||||
addNotifier?.call();
|
||||
}
|
||||
}
|
||||
|
||||
class Favorite {
|
||||
|
||||
@@ -24,6 +24,7 @@ class _HistoryCacheTimeState extends State<HistoryCacheTime> {
|
||||
offset: const Offset(0, 35),
|
||||
icon: const Icon(Icons.av_timer, size: 19),
|
||||
initialValue: widget.configuration.historyCacheTime,
|
||||
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
onSelected: (val) {
|
||||
widget.configuration.historyCacheTime = val;
|
||||
widget.configuration.flushConfig();
|
||||
|
||||
@@ -156,6 +156,7 @@ class NetworkTabState extends State<NetworkTabController> with SingleTickerProvi
|
||||
: AppBar(
|
||||
title: widget.title,
|
||||
bottom: tabBar,
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
ShareWidget(
|
||||
proxyServer: widget.proxyServer, request: widget.request.get(), response: widget.response.get()),
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:date_format/date_format.dart';
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -28,6 +29,7 @@ import 'package:flutter_toastr/flutter_toastr.dart';
|
||||
import 'package:proxypin/network/channel/host_port.dart';
|
||||
import 'package:proxypin/network/http/http.dart';
|
||||
import 'package:proxypin/network/http/http_client.dart';
|
||||
import 'package:proxypin/network/util/logger.dart';
|
||||
import 'package:proxypin/storage/favorites.dart';
|
||||
import 'package:proxypin/ui/component/app_dialog.dart';
|
||||
import 'package:proxypin/ui/component/utils.dart';
|
||||
@@ -78,12 +80,15 @@ class _FavoritesState extends State<Favorites> {
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: favorites.length,
|
||||
itemCount: favorites.length + 1,
|
||||
itemBuilder: (_, index) {
|
||||
var request = favorites.elementAt(index);
|
||||
if (index == 0) {
|
||||
return _FavoritesActions(onChanged: () => setState(() {}));
|
||||
}
|
||||
var request = favorites.elementAt(index - 1);
|
||||
return _FavoriteItem(
|
||||
request,
|
||||
index: index,
|
||||
index: index - 1,
|
||||
panel: widget.panel,
|
||||
onRemove: (Favorite favorite) {
|
||||
FavoriteStorage.removeFavorite(favorite);
|
||||
@@ -92,7 +97,8 @@ class _FavoritesState extends State<Favorites> {
|
||||
},
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Divider(height: 1, thickness: 0.3),
|
||||
separatorBuilder: (_, idx) =>
|
||||
idx == 0 ? const SizedBox(height: 4) : const Divider(height: 1, thickness: 0.3),
|
||||
);
|
||||
} else {
|
||||
return const SizedBox();
|
||||
@@ -287,3 +293,85 @@ class _FavoriteItemState extends State<_FavoriteItem> {
|
||||
widget.panel.change(request, request.response);
|
||||
}
|
||||
}
|
||||
|
||||
class _FavoritesActions extends StatelessWidget {
|
||||
final VoidCallback onChanged;
|
||||
|
||||
const _FavoritesActions({required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localizations = AppLocalizations.of(context)!;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
localizations.favorites,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.82),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// IconButton(
|
||||
// tooltip: '${localizations.export} HAR',
|
||||
// padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
// constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
// icon: const Icon(Icons.upload, size: 18),
|
||||
// onPressed: () async {
|
||||
// final path = await FilePicker.platform.saveFile(fileName: 'favorites.har');
|
||||
// if (path == null) return;
|
||||
// await FavoriteStorage.exportToHarFile(path, title: localizations.favorites);
|
||||
// FlutterToastr.show(localizations.exportSuccess, context);
|
||||
// },
|
||||
// ),
|
||||
IconButton(
|
||||
tooltip: localizations.export,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
icon: const Icon(Icons.upload_file, size: 18),
|
||||
onPressed: () async {
|
||||
final path = await FilePicker.platform.saveFile(fileName: 'favorites.json');
|
||||
if (path == null) return;
|
||||
await FavoriteStorage.exportToFile(path);
|
||||
if (context.mounted) CustomToast.success(localizations.exportSuccess).show(context);
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
IconButton(
|
||||
tooltip: localizations.import,
|
||||
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
icon: const Icon(Icons.download_for_offline_outlined, size: 18),
|
||||
onPressed: () async {
|
||||
final result =
|
||||
await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['json', 'har']);
|
||||
final file = result?.files.isNotEmpty == true ? result!.files.first : null;
|
||||
if (file?.path == null) return;
|
||||
|
||||
try {
|
||||
await FavoriteStorage.importFromFile(file!.path!);
|
||||
if (context.mounted) CustomToast.success(localizations.importSuccess).show(context);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
logger.e('Import favorites failed: $e');
|
||||
if (context.mounted) CustomToast.error('${localizations.importFailed}: $e').show(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, thickness: 0.4),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,11 +153,25 @@ class _HistoryListState extends State<_HistoryListWidget> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(38),
|
||||
preferredSize: const Size.fromHeight(36),
|
||||
child: AppBar(
|
||||
title: Text(localizations.historyRecord, style: const TextStyle(fontSize: 14)),
|
||||
toolbarHeight: 36,
|
||||
titleSpacing: 8,
|
||||
centerTitle: false,
|
||||
title: Text(
|
||||
localizations.historyRecord,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.82),
|
||||
),
|
||||
),
|
||||
bottom: const PreferredSize(preferredSize: Size.fromHeight(1), child: Divider(height: 1, thickness: 0.4)),
|
||||
actions: [
|
||||
IconButton(onPressed: import, icon: const Icon(Icons.input, size: 18), tooltip: localizations.import),
|
||||
IconButton(
|
||||
onPressed: import,
|
||||
icon: const Icon(Icons.input, size: 18),
|
||||
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
||||
tooltip: localizations.import),
|
||||
const SizedBox(width: 3),
|
||||
HistoryCacheTime(proxyServer.configuration, onSelected: (val) {
|
||||
if (val == 0) {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:date_format/date_format.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -40,6 +41,7 @@ import 'package:proxypin/ui/mobile/setting/script.dart';
|
||||
import 'package:proxypin/utils/curl.dart';
|
||||
import 'package:proxypin/utils/lang.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
/// 收藏列表页面
|
||||
/// @author WangHongEn
|
||||
@@ -57,10 +59,59 @@ class MobileFavorites extends StatefulWidget {
|
||||
class _FavoritesState extends State<MobileFavorites> {
|
||||
AppLocalizations get localizations => AppLocalizations.of(context)!;
|
||||
|
||||
Future<void> _exportJson() async {
|
||||
final favorites = await FavoriteStorage.favorites;
|
||||
final json = FavoriteStorage.toJson(favorites);
|
||||
final bytes = utf8.encode(json);
|
||||
final path = await FilePicker.platform.saveFile(fileName: 'favorites.json', bytes: bytes);
|
||||
if (path == null) return;
|
||||
if (mounted) FlutterToastr.show(localizations.exportSuccess, context);
|
||||
}
|
||||
|
||||
Future<String?> _materializePickedFile(PlatformFile file) async {
|
||||
if (file.path != null) return file.path!;
|
||||
if (file.bytes == null) return null;
|
||||
final tmp = await File('${Directory.systemTemp.path}/${file.name}').create();
|
||||
await tmp.writeAsBytes(file.bytes!, flush: true);
|
||||
return tmp.path;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(localizations.favorites, style: const TextStyle(fontSize: 16)), centerTitle: true),
|
||||
appBar: AppBar(
|
||||
title: Text(localizations.favorites, style: const TextStyle(fontSize: 16)),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: localizations.export,
|
||||
icon: const Icon(Icons.upload_file, size: 20),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await _exportJson();
|
||||
} catch (e) {
|
||||
if (context.mounted) FlutterToastr.show('${localizations.importFailed}: $e', context);
|
||||
}
|
||||
}),
|
||||
IconButton(
|
||||
tooltip: localizations.import,
|
||||
icon: const Icon(Icons.download_for_offline_outlined, size: 20),
|
||||
onPressed: () async {
|
||||
final result = await FilePicker.platform
|
||||
.pickFiles(type: FileType.custom, allowedExtensions: ['json', 'har'], withData: true);
|
||||
final file = result?.files.isNotEmpty == true ? result!.files.first : null;
|
||||
if (file == null) return;
|
||||
final path = await _materializePickedFile(file);
|
||||
if (path == null) return;
|
||||
try {
|
||||
await FavoriteStorage.importFromFile(path);
|
||||
if (context.mounted) FlutterToastr.show(localizations.importSuccess, context);
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
if (context.mounted) FlutterToastr.show('${localizations.importFailed}: $e', context);
|
||||
}
|
||||
}),
|
||||
]),
|
||||
body: FutureBuilder(
|
||||
future: FavoriteStorage.favorites,
|
||||
builder: (BuildContext context, AsyncSnapshot<Queue<Favorite>> snapshot) {
|
||||
@@ -168,7 +219,7 @@ class _FavoriteItemState extends State<_FavoriteItem> {
|
||||
}
|
||||
|
||||
///右键菜单
|
||||
menu(details) {
|
||||
void menu(details) {
|
||||
// setState(() {
|
||||
// selected = true;
|
||||
// });
|
||||
@@ -309,14 +360,14 @@ class _FavoriteItemState extends State<_FavoriteItem> {
|
||||
}
|
||||
|
||||
//显示高级重发
|
||||
showCustomRepeat(HttpRequest request) {
|
||||
void showCustomRepeat(HttpRequest request) {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => futureWidget(SharedPreferences.getInstance(),
|
||||
(prefs) => MobileCustomRepeat(onRepeat: () => onRepeat(request), prefs: prefs))));
|
||||
}
|
||||
|
||||
onRepeat(HttpRequest request) {
|
||||
void onRepeat(HttpRequest request) {
|
||||
var httpRequest = request.copy(uri: request.requestUrl);
|
||||
var proxyInfo = widget.proxyServer.isRunning ? ProxyInfo.of("127.0.0.1", widget.proxyServer.port) : null;
|
||||
HttpClients.proxyRequest(httpRequest, proxyInfo: proxyInfo);
|
||||
@@ -327,7 +378,7 @@ class _FavoriteItemState extends State<_FavoriteItem> {
|
||||
}
|
||||
|
||||
//重命名
|
||||
rename(Favorite item) {
|
||||
void rename(Favorite item) {
|
||||
String? name = item.name;
|
||||
showDialog(
|
||||
context: context,
|
||||
|
||||
Reference in New Issue
Block a user