Add export/import functionality for favorites (#655)

This commit is contained in:
wanghongenpin
2025-12-27 01:34:27 +08:00
parent 63942b94f5
commit 9c7a9da67b
7 changed files with 228 additions and 15 deletions

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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()),

View File

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

View File

@@ -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) {

View File

@@ -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,