diff --git a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart index f1a68b78..4d1561ed 100644 --- a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart +++ b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart @@ -68,6 +68,11 @@ class BookmarksRepository extends _$BookmarksRepository { ref.invalidateSelf(); } + Future eraseEverything(BookmarkRoot root) async { + await _service.eraseEverything(root); + ref.invalidateSelf(); + } + @override Future build() async { final node = await _service.getTree( diff --git a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart index 242016f1..e927b639 100644 --- a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart +++ b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart @@ -34,7 +34,7 @@ final class BookmarksRepositoryProvider } String _$bookmarksRepositoryHash() => - r'b8fa5b5699c053b91fcabd46dc97b7192e32d068'; + r'94d9f0f6c589455b16b0831de4ed1f87dcee704e'; abstract class _$BookmarksRepository extends $AsyncNotifier { FutureOr build(); diff --git a/app/lib/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart b/app/lib/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart new file mode 100644 index 00000000..f213605a --- /dev/null +++ b/app/lib/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart @@ -0,0 +1,569 @@ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; + +const _containerNormal = 0; +const _containerToolbar = 1; +const _containerMenu = 2; +const _containerUnfiled = 3; +const _containerPlaces = 4; + +const _exportIndent = ' '; + +class _Frame { + final Map folder; + int containerNesting = 0; + int lastContainerType = _containerNormal; + String previousText = ''; + bool inDescription = false; + String? previousLink; + Map? previousItem; + DateTime? previousDateAdded; + DateTime? previousLastModifiedDate; + + _Frame(this.folder); +} + +class BookmarkHTMLUtils { + final GeckoBookmarksService _service; + + BookmarkHTMLUtils(this._service); + + /// Import bookmarks from HTML string + Future importFromHTML(String htmlString, {bool replace = false}) async { + final importer = _BookmarkImporter(_service, replace); + return await importer.importFromHTML(htmlString); + } + + /// Export bookmarks to HTML string + Future exportToHTML({required BookmarkRoot root}) async { + final tree = await _service.getTree(root.id, recursive: true); + if (tree == null) { + throw Exception('Failed to get bookmarks tree'); + } + + final exporter = _BookmarkExporter(tree); + return exporter.exportToHTML(); + } +} + +class _BookmarkImporter { + final GeckoBookmarksService _service; + final bool _isImportDefaults; + final Map _bookmarkTree; + final List<_Frame> _frames = []; + + _BookmarkImporter(this._service, this._isImportDefaults) + : _bookmarkTree = { + 'type': BookmarkNodeType.folder.index, + 'guid': BookmarkRoot.menu.id, + 'children': >[], + } { + _frames.add(_Frame(_bookmarkTree)); + } + + _Frame get _curFrame => _frames.last; + + Future importFromHTML(String htmlString) async { + final document = html_parser.parse(htmlString); + _walkTreeForImport(document.body); + return await _importBookmarks(); + } + + dom.Node? _nextSibling(dom.Node node) { + final parent = node.parent; + if (parent == null) return null; + + final siblings = parent.nodes; + final index = siblings.indexOf(node); + + if (index != -1 && index + 1 < siblings.length) { + return siblings[index + 1]; + } + + return null; + } + + void _walkTreeForImport(dom.Node? node) { + if (node == null) return; + + dom.Node? current = node; + dom.Node? next; + + for (;;) { + if (current?.nodeType == dom.Node.ELEMENT_NODE) { + _openContainer(current! as dom.Element); + } else if (current?.nodeType == dom.Node.TEXT_NODE) { + _appendText(current!.text ?? ''); + } + + if ((next = current?.firstChild) != null) { + current = next; + continue; + } + + for (;;) { + if (current?.nodeType == dom.Node.ELEMENT_NODE) { + _closeContainer(current! as dom.Element); + } + if (current == node) return; + if ((next = _nextSibling(current!)) != null) { + current = next; + break; + } + current = current.parentNode; + } + } + } + + void _openContainer(dom.Element element) { + switch (element.localName) { + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + _handleHeadBegin(element); + case 'a': + _handleLinkBegin(element); + case 'dl': + case 'ul': + case 'menu': + _handleContainerBegin(); + case 'dd': + _curFrame.inDescription = true; + case 'hr': + _handleSeparator(); + } + } + + void _closeContainer(dom.Element element) { + final frame = _curFrame; + + if (frame.inDescription) { + frame.previousText = ''; + frame.inDescription = false; + } + + switch (element.localName) { + case 'dl': + case 'ul': + case 'menu': + _handleContainerEnd(); + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + _handleHeadEnd(); + case 'a': + _handleLinkEnd(); + } + } + + void _appendText(String str) { + _curFrame.previousText += str; + } + + void _handleHeadBegin(dom.Element element) { + final frame = _curFrame; + + frame.previousLink = null; + frame.lastContainerType = _containerNormal; + + if (frame.containerNesting == 0 && _frames.length > 1) { + _frames.removeLast(); + } + + if (element.attributes.containsKey('personal_toolbar_folder')) { + if (_isImportDefaults) { + frame.lastContainerType = _containerToolbar; + } + } else if (element.attributes.containsKey('bookmarks_menu')) { + if (_isImportDefaults) { + frame.lastContainerType = _containerMenu; + } + } else if (element.attributes.containsKey('unfiled_bookmarks_folder')) { + if (_isImportDefaults) { + frame.lastContainerType = _containerUnfiled; + } + } else if (element.attributes.containsKey('places_root')) { + if (_isImportDefaults) { + frame.lastContainerType = _containerPlaces; + } + } else { + final addDate = element.attributes['add_date']; + if (addDate != null) { + frame.previousDateAdded = _convertImportedDateToInternalDate(addDate); + } + final modDate = element.attributes['last_modified']; + if (modDate != null) { + frame.previousLastModifiedDate = _convertImportedDateToInternalDate( + modDate, + ); + } + } + _curFrame.previousText = ''; + } + + void _handleLinkBegin(dom.Element element) { + final frame = _curFrame; + + frame.previousItem = null; + frame.previousText = ''; + + final href = element.attributes['href']?.trim(); + final dateAdded = element.attributes['add_date']?.trim(); + final lastModified = element.attributes['last_modified']?.trim(); + final tags = element.attributes['tags']?.trim(); + final keyword = element.attributes['shortcuturl']?.trim(); + final postData = element.attributes['post_data']?.trim(); + final lastCharset = element.attributes['last_charset']?.trim(); + + if (href == null || href.isEmpty) { + frame.previousLink = null; + return; + } + + try { + final uri = Uri.parse(href); + if (!uri.hasScheme) { + frame.previousLink = null; + return; + } + frame.previousLink = uri.toString(); + } catch (e) { + frame.previousLink = null; + return; + } + + final bookmark = {'url': frame.previousLink}; + + if (dateAdded != null) { + bookmark['dateAdded'] = _convertImportedDateToInternalDate( + dateAdded, + ).millisecondsSinceEpoch; + } + if (lastModified != null) { + bookmark['lastModified'] = _convertImportedDateToInternalDate( + lastModified, + ).millisecondsSinceEpoch; + } + if (dateAdded == null && lastModified != null) { + bookmark['dateAdded'] = bookmark['lastModified']; + } + + if (tags != null && tags.isNotEmpty) { + bookmark['tags'] = tags; + } + if (keyword != null && keyword.isNotEmpty) { + bookmark['keyword'] = keyword; + } + if (postData != null && postData.isNotEmpty) { + bookmark['postData'] = postData; + } + if (lastCharset != null && lastCharset.isNotEmpty) { + bookmark['charset'] = lastCharset; + } + + (frame.folder['children'] as List).add(bookmark); + frame.previousItem = bookmark; + } + + void _handleContainerBegin() { + _curFrame.containerNesting++; + } + + void _handleContainerEnd() { + final frame = _curFrame; + if (frame.containerNesting > 0) { + frame.containerNesting--; + } + if (_frames.length > 1 && frame.containerNesting == 0) { + _frames.removeLast(); + } + } + + void _handleHeadEnd() { + _newFrame(); + } + + void _handleLinkEnd() { + final frame = _curFrame; + frame.previousText = frame.previousText.trim(); + + if (frame.previousItem != null) { + frame.previousItem!['title'] = frame.previousText; + } + + frame.previousText = ''; + } + + void _handleSeparator() { + final frame = _curFrame; + final separator = { + 'type': BookmarkNodeType.separator.index, + }; + (frame.folder['children'] as List).add(separator); + frame.previousItem = separator; + } + + void _newFrame() { + final frame = _curFrame; + final containerTitle = frame.previousText; + frame.previousText = ''; + final containerType = frame.lastContainerType; + + final folder = { + 'children': >[], + 'type': BookmarkNodeType.folder.index, + }; + + switch (containerType) { + case _containerNormal: + folder['title'] = containerTitle; + case _containerPlaces: + folder['guid'] = BookmarkRoot.root.id; + case _containerMenu: + folder['guid'] = BookmarkRoot.menu.id; + case _containerUnfiled: + folder['guid'] = BookmarkRoot.unfiled.id; + case _containerToolbar: + folder['guid'] = BookmarkRoot.toolbar.id; + } + + (frame.folder['children'] as List).add(folder); + + if (frame.previousDateAdded != null) { + folder['dateAdded'] = frame.previousDateAdded!.millisecondsSinceEpoch; + frame.previousDateAdded = null; + } + if (frame.previousLastModifiedDate != null) { + folder['lastModified'] = + frame.previousLastModifiedDate!.millisecondsSinceEpoch; + frame.previousLastModifiedDate = null; + } + if (!folder.containsKey('dateAdded') && + folder.containsKey('lastModified')) { + folder['dateAdded'] = folder['lastModified']; + } + + frame.previousItem = folder; + _frames.add(_Frame(folder)); + } + + DateTime _convertImportedDateToInternalDate(String seconds) { + try { + final parsed = int.tryParse(seconds); + if (parsed != null) { + return DateTime.fromMillisecondsSinceEpoch(parsed * 1000); + } + } catch (e) { + // Fall through + } + return DateTime.now(); + } + + List> _getBookmarkTrees() { + if (!_isImportDefaults) { + return [_bookmarkTree]; + } + + final bookmarkTrees = >[_bookmarkTree]; + final children = _bookmarkTree['children'] as List>; + + _bookmarkTree['children'] = children.where((child) { + final guid = child['guid'] as String?; + if (guid != null && bookmarkRootIds.contains(guid)) { + bookmarkTrees.add(child); + return false; + } + return true; + }).toList(); + + return bookmarkTrees; + } + + Future _importBookmarks() async { + if (_isImportDefaults) { + await _service.eraseEverything(BookmarkRoot.root); + } + + final bookmarkTrees = _getBookmarkTrees(); + int bookmarkCount = 0; + + for (final tree in bookmarkTrees) { + final children = tree['children'] as List?; + if (children == null || children.isEmpty) continue; + + bookmarkCount += await _insertTree(tree); + } + + return bookmarkCount; + } + + Future _insertTree(Map node) async { + int count = 0; + final children = node['children'] as List?; + + if (children == null || children.isEmpty) return 0; + + final parentGuid = node['guid'] as String; + + for (int i = 0; i < children.length; i++) { + final child = children[i] as Map; + final type = child['type'] as int? ?? BookmarkNodeType.item.index; + + if (type == BookmarkNodeType.item.index) { + final url = child['url'] as String?; + final title = child['title'] as String? ?? ''; + + if (url != null && url.isNotEmpty) { + try { + final uri = Uri.parse(url); + if (uri.hasScheme) { + await _service.addItem(parentGuid, uri, title, i); + count++; + } + } catch (e) { + print('Failed to import bookmark "$title": $e'); + } + } + } else if (type == BookmarkNodeType.folder.index) { + final title = child['title'] as String? ?? ''; + try { + final newGuid = await _service.addFolder(parentGuid, title, i); + child['guid'] = newGuid; + count += await _insertTree(child); + } catch (e) { + print('Failed to import folder "$title": $e'); + } + } + } + + return count; + } +} + +class _BookmarkExporter { + final BookmarkNode _root; + final StringBuffer _buffer = StringBuffer(); + + _BookmarkExporter(this._root); + + String exportToHTML() { + _writeHeader(); + _writeContainer(_root); + return _buffer.toString(); + } + + void _write(String text) { + _buffer.write(text); + } + + void _writeLine(String text) { + _buffer.writeln(text); + } + + void _writeHeader() { + _writeLine(''); + _writeLine(''); + _writeLine( + '', + ); + _writeLine('', + ); + _writeLine('Bookmarks'); + } + + void _writeContainer(BookmarkNode item, [String indent = '']) { + if (item.guid == _root.guid) { + _writeLine('

${_escapeHtml(item.title ?? 'Bookmarks')}

'); + _writeLine(''); + } else { + _write('$indent
${_escapeHtml(item.title ?? '')}'); + } + + _writeLine('$indent

'); + if (item.children != null) { + _writeContainerContents(item, indent); + } + if (item.guid == _root.guid) { + _writeLine('$indent

'); + } else { + _writeLine('$indent

'); + } + } + + void _writeContainerContents(BookmarkNode item, String indent) { + final localIndent = indent + _exportIndent; + + for (final child in item.children!) { + if (child.type == BookmarkNodeType.folder) { + _writeContainer(child, localIndent); + } else if (child.type == BookmarkNodeType.separator) { + _writeSeparator(child, localIndent); + } else { + _writeItem(child, localIndent); + } + } + } + + void _writeSeparator(BookmarkNode item, String indent) { + _write('$indent'); + } + + void _writeItem(BookmarkNode item, String indent) { + if (item.url == null || item.url!.isEmpty) return; + + try { + Uri.parse(item.url!); + } catch (e) { + return; + } + + _write('$indent

${_escapeHtml(item.title ?? '')}'); + } + + void _writeDateAttributes(BookmarkNode item) { + // Convert from microseconds to seconds (UNIX timestamp) + if (item.dateAdded > 0) { + _write(' ADD_DATE="${item.dateAdded ~/ 1000000}"'); + } + if (item.lastModified > 0) { + _write(' LAST_MODIFIED="${item.lastModified ~/ 1000000}"'); + } + } + + String _escapeHtml(String text) { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + String _escapeUrl(String text) { + return text.replaceAll('"', '%22'); + } +} diff --git a/app/lib/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart b/app/lib/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart new file mode 100644 index 00000000..5f71dd74 --- /dev/null +++ b/app/lib/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart @@ -0,0 +1,345 @@ +// ignore_for_file: unnecessary_raw_strings + +import 'dart:convert'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; + +class BookmarkJSONUtils { + final GeckoBookmarksService _service; + + BookmarkJSONUtils(this._service); + + /// Import bookmarks from JSON string + Future importFromJSON(String jsonString, {bool replace = false}) async { + try { + final data = jsonDecode(jsonString); + + if (data is! Map) { + throw Exception('Invalid JSON format'); + } + + final children = data['children'] as List?; + if (children == null || children.isEmpty) { + return 0; + } + + return await _import(data, replace: replace); + } catch (ex) { + print('Failed to import bookmarks: $ex'); + rethrow; + } + } + + /// Export bookmarks to JSON + Future?> exportToJson({ + required BookmarkRoot root, + }) async { + final tree = await _service.getTree(root.id, recursive: true); + if (tree == null) { + throw Exception('Failed to get bookmarks tree'); + } + + return _nodeToJson(tree, isRoot: true); + } + + /// Import implementation + Future _import( + Map rootNode, { + required bool replace, + }) async { + final nodes = + (rootNode['children'] as List?) + ?.whereType>() + .where( + (node) => + node['root'] != 'tagsFolder' && + node['guid'] != 'tags________', + ) + .toList() ?? + []; + + if (nodes.isEmpty) { + return 0; + } + + // If replacing, erase existing bookmarks first + if (replace) { + await _service.eraseEverything(BookmarkRoot.root); + } + + final folderIdToGuidMap = {}; + + // Translate tree types and build folder map + for (final node in nodes) { + if (node['children'] == null || (node['children'] as List).isEmpty) { + continue; + } + + final folders = _translateTreeTypes(node); + folderIdToGuidMap.addAll(folders); + } + + int bookmarkCount = 0; + + // Insert nodes + for (final node in nodes) { + if (node['children'] == null || (node['children'] as List).isEmpty) { + continue; + } + + final guid = node['guid'] as String?; + if (guid == null || !bookmarkRootIds.contains(guid)) { + continue; + } + + _fixupSearchQueries(node, folderIdToGuidMap); + + // Insert the tree recursively + bookmarkCount += await _insertTree(node, folderIdToGuidMap); + } + + return bookmarkCount; + } + + /// Recursively insert bookmark tree + Future _insertTree( + Map node, + Map folderIdToGuidMap, + ) async { + int count = 0; + final children = node['children'] as List?; + + if (children == null || children.isEmpty) { + return 0; + } + + final parentGuid = node['guid'] as String; + + for (int i = 0; i < children.length; i++) { + final child = children[i] as Map; + final type = _getNodeType(child); + + if (type == BookmarkNodeType.item) { + final url = _getNodeUrl(child); + final title = child['title'] as String? ?? ''; + + if (url != null && url.isNotEmpty) { + try { + // Validate URL before inserting + final uri = Uri.tryParse(url); + if (uri != null && uri.hasScheme) { + await _service.addItem(parentGuid, uri, title, i); + count++; + } else { + print('Skipping invalid URL: $url'); + } + } catch (e) { + print('Failed to import bookmark "$title": $e'); + } + } + } else if (type == BookmarkNodeType.folder) { + final title = child['title'] as String? ?? ''; + try { + final newGuid = await _service.addFolder(parentGuid, title, i); + child['guid'] = newGuid; + + // Recursively insert children + count += await _insertTree(child, folderIdToGuidMap); + } catch (e) { + print('Failed to import folder "$title": $e'); + } + } + // Note: Separators are not supported by the Android API + } + + return count; + } + + /// Translate tree types from JSON format to internal format + Map _translateTreeTypes(Map node) { + final folderIdToGuidMap = {}; + + _normalizeNodeUrl(node); + + final type = node['type']; + if (type == 'text/x-moz-place-container') { + node['type'] = BookmarkNodeType.folder.index; + + final id = node['id']?.toString(); + final guid = node['guid'] as String?; + if (id != null && guid != null) { + folderIdToGuidMap[id] = guid; + } + } else if (type == 'text/x-moz-place') { + node['type'] = BookmarkNodeType.item.index; + } else if (type == 'text/x-moz-place-separator') { + node['type'] = BookmarkNodeType.separator.index; + node.remove('title'); + } + + final children = node['children'] as List?; + if (children != null) { + for (final child in children) { + if (child is Map) { + folderIdToGuidMap.addAll(_translateTreeTypes(child)); + } + } + } + + return folderIdToGuidMap; + } + + /// Fix up search queries with folder mappings + void _fixupSearchQueries( + Map node, + Map folderIdToGuidMap, + ) { + final url = _getNodeUrl(node); + if (url != null && url.startsWith('place:')) { + node['url'] = _fixupQuery(url, folderIdToGuidMap); + } + + final children = node['children'] as List?; + if (children != null) { + for (final child in children) { + if (child is Map) { + _fixupSearchQueries(child, folderIdToGuidMap); + } + } + } + } + + /// Replace folder IDs with GUIDs in place: URIs + String _fixupQuery(String queryURL, Map folderIdToGuidMap) { + final regex = RegExp(r'folder=([A-Za-z0-9_]+)'); + bool invalid = false; + + final result = queryURL.replaceAllMapped(regex, (match) { + final folderId = match.group(1)!; + final guid = folderIdToGuidMap[folderId]; + + if (guid == null) { + invalid = true; + return 'invalidOldParentId=$folderId'; + } + + return 'parent=$guid'; + }); + + if (invalid) { + return '$result&excludeItems=1'; + } + + return result; + } + + /// Convert BookmarkNode to JSON (for export) + Map? _nodeToJson(BookmarkNode node, {bool isRoot = false}) { + // Skip invalid bookmarks + if (node.type == BookmarkNodeType.item) { + if (node.url == null || node.url!.isEmpty) { + print('Skipping bookmark with invalid URL: ${node.guid}'); + return null; + } + try { + Uri.parse(node.url!); + } catch (e) { + print('Skipping bookmark with malformed URL: ${node.url}'); + return null; + } + } + + final json = { + 'guid': node.guid, + 'title': node.type == BookmarkNodeType.separator + ? '' + : (node.title ?? ''), + 'index': 0, // Will be set by parent + 'dateAdded': node.dateAdded, + 'lastModified': node.lastModified, + 'typeCode': node.type.index + 1, + 'type': _getTypeString(node.type), + }; + + if (isRoot && + node.parentGuid != null && + node.guid != BookmarkRoot.root.id) { + json['parentGuid'] = node.parentGuid; + } + + final rootName = _getRootName(node.guid); + if (rootName != null) { + json['root'] = rootName; + } + + if (node.type == BookmarkNodeType.item) { + json['url'] = node.url; + } + + if (node.type == BookmarkNodeType.folder && node.children != null) { + final validChildren = >[]; + for (var i = 0; i < node.children!.length; i++) { + final childJson = _nodeToJson(node.children![i]); + if (childJson != null) { + childJson['index'] = validChildren.length; + validChildren.add(childJson); + } + } + if (validChildren.isNotEmpty) { + json['children'] = validChildren; + } + } + + return json; + } + + /// Convert BookmarkNodeType to Firefox type string + String _getTypeString(BookmarkNodeType type) { + switch (type) { + case BookmarkNodeType.item: + return 'text/x-moz-place'; + case BookmarkNodeType.folder: + return 'text/x-moz-place-container'; + case BookmarkNodeType.separator: + return 'text/x-moz-place-separator'; + } + } + + /// Get root folder name for JSON + String? _getRootName(String guid) { + if (guid == BookmarkRoot.root.id) return 'placesRoot'; + if (guid == BookmarkRoot.menu.id) return 'bookmarksMenuFolder'; + if (guid == BookmarkRoot.toolbar.id) return 'toolbarFolder'; + if (guid == BookmarkRoot.unfiled.id) return 'unfiledBookmarksFolder'; + if (guid == BookmarkRoot.mobile.id) return 'mobileFolder'; + return null; + } + + /// Get URL from node (accepts both 'url' and 'uri') + String? _getNodeUrl(Map node) { + return node['url'] as String? ?? node['uri'] as String?; + } + + /// Normalize 'uri' to 'url' during import + void _normalizeNodeUrl(Map node) { + if (node.containsKey('uri')) { + node['url'] = node['uri']; + node.remove('uri'); + } + } + + /// Get node type from JSON + BookmarkNodeType _getNodeType(Map node) { + final type = node['type']; + if (type is int) { + return BookmarkNodeType.values[type]; + } + + if (type == 'text/x-moz-place-container') { + return BookmarkNodeType.folder; + } else if (type == 'text/x-moz-place') { + return BookmarkNodeType.item; + } else { + return BookmarkNodeType.separator; + } + } +} diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_bookmarks.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_bookmarks.dart index 35466646..6350a328 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_bookmarks.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_bookmarks.dart @@ -128,4 +128,23 @@ class GeckoBookmarksService { Future deleteNode(String guid) { return _api.deleteNode(guid); } + + /// Removes ALL bookmarks from the specified root folder. + /// The root folder itself is preserved, only its children are removed. + Future eraseEverything(BookmarkRoot root) async { + // Get all direct children of the root + final tree = await getTree(root.id); + + // Delete each direct child (deleteNode cascades to all descendants) + if (tree?.children != null) { + for (final child in tree!.children!) { + // try { + await deleteNode(child.guid); + // } catch (e) { + // // Log but continue with other children + // logger.e('Failed to delete bookmark ${child.guid}: $e'); + // } + } + } + } }