import export working

This commit is contained in:
Fabian Freund
2026-01-03 19:53:35 +01:00
parent 3cfd510125
commit a1838e5b01
9 changed files with 343 additions and 138 deletions
@@ -21,12 +21,16 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart';
part 'bookmarks.g.dart';
@Riverpod(keepAlive: true)
class BookmarksRepository extends _$BookmarksRepository {
final _service = GeckoBookmarksService();
late final _jsonUtils = BookmarkJSONUtils(_service);
late final _htmlUtils = BookmarkHTMLUtils(_service);
Future<void> addBookmark({
required String parentGuid,
@@ -73,12 +77,31 @@ class BookmarksRepository extends _$BookmarksRepository {
ref.invalidateSelf();
}
Future<int> importFromJSON(String jsonString, {bool replace = false}) async {
final count = await _jsonUtils.importFromJSON(jsonString, replace: replace);
ref.invalidateSelf();
return count;
}
Future<int> importFromHTML(String htmlString, {bool replace = false}) async {
final count = await _htmlUtils.importFromHTML(htmlString, replace: replace);
ref.invalidateSelf();
return count;
}
Future<Map<String, dynamic>?> exportToJson({
required BookmarkRoot root,
}) async {
return await _jsonUtils.exportToJson(root: root);
}
Future<String> exportToHTML({required BookmarkRoot root}) async {
return await _htmlUtils.exportToHTML(root: root);
}
@override
Future<BookmarkItem?> build() async {
final node = await _service.getTree(
BookmarkRoot.mobile.id,
recursive: true,
);
final node = await _service.getTree(BookmarkRoot.root.id, recursive: true);
return node.mapNotNull(BookmarkItem.parseRecursive);
}
}
@@ -34,7 +34,7 @@ final class BookmarksRepositoryProvider
}
String _$bookmarksRepositoryHash() =>
r'94d9f0f6c589455b16b0831de4ed1f87dcee704e';
r'932441e926afcb1d60dc34b8a8756b7ad8fe0a27';
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
FutureOr<BookmarkItem?> build();
@@ -48,7 +48,7 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
final treeKey = useMemoized(() => GlobalKey<TreeViewState>());
final folderList = ref.watch(
bookmarksProvider<BookmarkFolder>(BookmarkRoot.mobile.id),
bookmarksProvider<BookmarkFolder>(BookmarkRoot.root.id),
);
final nameTextController = useTextEditingController(
@@ -242,7 +242,7 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
// ignore: unused_result
ref.refresh(
bookmarksProvider<BookmarkFolder>(
BookmarkRoot.mobile.id,
BookmarkRoot.root.id,
),
);
},
@@ -18,8 +18,11 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'dart:io';
import 'package:animated_tree_view/animated_tree_view.dart';
import 'package:convert/convert.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
@@ -29,11 +32,13 @@ import 'package:nullability/nullability.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
import 'package:weblibre/presentation/hooks/listenable_callback.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/ui_helper.dart';
class BookmarkListScreen extends HookConsumerWidget {
final String entryGuid;
@@ -101,6 +106,38 @@ class BookmarkListScreen extends HookConsumerWidget {
});
},
),
SubmenuButton(
leadingIcon: const Icon(MdiIcons.import),
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(MdiIcons.codeJson),
child: const Text('JSON'),
onPressed: () => _handleImport(context, ref, 'json'),
),
MenuItemButton(
leadingIcon: const Icon(MdiIcons.xml),
child: const Text('HTML'),
onPressed: () => _handleImport(context, ref, 'html'),
),
],
child: const Text('Import'),
),
SubmenuButton(
leadingIcon: const Icon(MdiIcons.export),
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(MdiIcons.codeJson),
child: const Text('JSON'),
onPressed: () => _handleExport(context, ref, 'json'),
),
MenuItemButton(
leadingIcon: const Icon(MdiIcons.xml),
child: const Text('HTML'),
onPressed: () => _handleExport(context, ref, 'html'),
),
],
child: const Text('Export'),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () {
@@ -148,6 +185,7 @@ class BookmarkListScreen extends HookConsumerWidget {
return TreeView.simple(
key: treeKey,
tree: root,
showRootNode: entryGuid != BookmarkRoot.root.id,
onTreeReady: (controller) {
if (textFilterEnabled.value) {
controller.expandAllChildren(root, recursive: true);
@@ -299,4 +337,91 @@ class BookmarkListScreen extends HookConsumerWidget {
),
);
}
Future<void> _handleImport(
BuildContext context,
WidgetRef ref,
String format,
) async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: format == 'json' ? ['json'] : ['html', 'htm'],
);
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
if (file.path == null) {
if (context.mounted) {
showErrorMessage(context, 'Failed to read file');
}
return;
}
final content = await File(file.path!).readAsString();
final repository = ref.read(bookmarksRepositoryProvider.notifier);
final count = format == 'json'
? await repository.importFromJSON(content)
: await repository.importFromHTML(content);
if (context.mounted) {
showInfoMessage(context, 'Imported $count bookmarks successfully');
}
} catch (e) {
if (context.mounted) {
showErrorMessage(context, 'Import failed: $e');
}
}
}
Future<void> _handleExport(
BuildContext context,
WidgetRef ref,
String format,
) async {
try {
// Create the backup content first
final repository = ref.read(bookmarksRepositoryProvider.notifier);
String content;
if (format == 'json') {
final data = await repository.exportToJson(root: BookmarkRoot.root);
if (data == null) {
throw Exception('Failed to export bookmarks');
}
content = const JsonEncoder.withIndent(' ').convert(data);
} else {
content = await repository.exportToHTML(root: BookmarkRoot.root);
}
// Convert to bytes for the file picker
final bytes = utf8.encode(content);
// Now show the save dialog with the content ready
final dateFormatter = FixedDateTimeFormatter('YYYY-MM-DD_hhmmss');
final timestamp = dateFormatter.encode(DateTime.now());
final defaultFileName =
'bookmarks_$timestamp.${format == 'json' ? 'json' : 'html'}';
final outputPath = await FilePicker.platform.saveFile(
dialogTitle: 'Export Bookmarks',
fileName: defaultFileName,
type: FileType.custom,
allowedExtensions: format == 'json' ? ['json'] : ['html', 'htm'],
bytes: bytes,
);
if (outputPath == null) return;
if (context.mounted) {
showInfoMessage(context, 'Bookmarks exported successfully');
}
} catch (e) {
if (context.mounted) {
showErrorMessage(context, 'Export failed: $e');
}
}
}
}
@@ -1,6 +1,7 @@
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
import 'package:weblibre/core/logger.dart';
const _containerNormal = 0;
const _containerToolbar = 1;
@@ -426,7 +427,7 @@ class _BookmarkImporter {
count++;
}
} catch (e) {
print('Failed to import bookmark "$title": $e');
logger.e('Failed to import bookmark "$title": $e');
}
}
} else if (type == BookmarkNodeType.folder.index) {
@@ -436,7 +437,7 @@ class _BookmarkImporter {
child['guid'] = newGuid;
count += await _insertTree(child);
} catch (e) {
print('Failed to import folder "$title": $e');
logger.e('Failed to import folder "$title": $e');
}
}
}
@@ -2,6 +2,7 @@
import 'dart:convert';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:weblibre/core/logger.dart';
class BookmarkJSONUtils {
final GeckoBookmarksService _service;
@@ -24,7 +25,7 @@ class BookmarkJSONUtils {
return await _import(data, replace: replace);
} catch (ex) {
print('Failed to import bookmarks: $ex');
logger.e('Failed to import bookmarks: $ex');
rethrow;
}
}
@@ -130,10 +131,10 @@ class BookmarkJSONUtils {
await _service.addItem(parentGuid, uri, title, i);
count++;
} else {
print('Skipping invalid URL: $url');
logger.w('Skipping invalid URL: $url');
}
} catch (e) {
print('Failed to import bookmark "$title": $e');
logger.e('Failed to import bookmark "$title": $e');
}
}
} else if (type == BookmarkNodeType.folder) {
@@ -145,7 +146,7 @@ class BookmarkJSONUtils {
// Recursively insert children
count += await _insertTree(child, folderIdToGuidMap);
} catch (e) {
print('Failed to import folder "$title": $e');
logger.e('Failed to import folder "$title": $e');
}
}
// Note: Separators are not supported by the Android API
@@ -237,13 +238,13 @@ class BookmarkJSONUtils {
// Skip invalid bookmarks
if (node.type == BookmarkNodeType.item) {
if (node.url == null || node.url!.isEmpty) {
print('Skipping bookmark with invalid URL: ${node.guid}');
logger.w('Skipping bookmark with invalid URL: ${node.guid}');
return null;
}
try {
Uri.parse(node.url!);
} catch (e) {
print('Skipping bookmark with malformed URL: ${node.url}');
logger.w('Skipping bookmark with malformed URL: ${node.url}');
return null;
}
}
@@ -417,7 +417,7 @@ class ContextualToolbar extends HookConsumerWidget {
IconButton(
onPressed: () async {
await BookmarkListRoute(
entryGuid: BookmarkRoot.mobile.id,
entryGuid: BookmarkRoot.root.id,
).push(context);
},
icon: const Icon(MdiIcons.bookmarkMultiple),
@@ -806,7 +806,7 @@ class NavigationMenuButton extends HookConsumerWidget {
MenuItemButton(
onPressed: () async {
await BookmarkListRoute(
entryGuid: BookmarkRoot.mobile.id,
entryGuid: BookmarkRoot.root.id,
).push(context);
},
leadingIcon: const Icon(MdiIcons.bookmarkMultiple),