initial utils

This commit is contained in:
Fabian Freund
2026-01-03 19:53:35 +01:00
parent dfda1fa786
commit 2c41177852
5 changed files with 939 additions and 1 deletions
@@ -68,6 +68,11 @@ class BookmarksRepository extends _$BookmarksRepository {
ref.invalidateSelf();
}
Future<void> eraseEverything(BookmarkRoot root) async {
await _service.eraseEverything(root);
ref.invalidateSelf();
}
@override
Future<BookmarkItem?> build() async {
final node = await _service.getTree(
@@ -34,7 +34,7 @@ final class BookmarksRepositoryProvider
}
String _$bookmarksRepositoryHash() =>
r'b8fa5b5699c053b91fcabd46dc97b7192e32d068';
r'94d9f0f6c589455b16b0831de4ed1f87dcee704e';
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
FutureOr<BookmarkItem?> build();
@@ -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<String, dynamic> folder;
int containerNesting = 0;
int lastContainerType = _containerNormal;
String previousText = '';
bool inDescription = false;
String? previousLink;
Map<String, dynamic>? previousItem;
DateTime? previousDateAdded;
DateTime? previousLastModifiedDate;
_Frame(this.folder);
}
class BookmarkHTMLUtils {
final GeckoBookmarksService _service;
BookmarkHTMLUtils(this._service);
/// Import bookmarks from HTML string
Future<int> importFromHTML(String htmlString, {bool replace = false}) async {
final importer = _BookmarkImporter(_service, replace);
return await importer.importFromHTML(htmlString);
}
/// Export bookmarks to HTML string
Future<String> 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<String, dynamic> _bookmarkTree;
final List<_Frame> _frames = [];
_BookmarkImporter(this._service, this._isImportDefaults)
: _bookmarkTree = {
'type': BookmarkNodeType.folder.index,
'guid': BookmarkRoot.menu.id,
'children': <Map<String, dynamic>>[],
} {
_frames.add(_Frame(_bookmarkTree));
}
_Frame get _curFrame => _frames.last;
Future<int> 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 = <String, dynamic>{'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 = <String, dynamic>{
'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 = <String, dynamic>{
'children': <Map<String, dynamic>>[],
'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<Map<String, dynamic>> _getBookmarkTrees() {
if (!_isImportDefaults) {
return [_bookmarkTree];
}
final bookmarkTrees = <Map<String, dynamic>>[_bookmarkTree];
final children = _bookmarkTree['children'] as List<Map<String, dynamic>>;
_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<int> _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<int> _insertTree(Map<String, dynamic> 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<String, dynamic>;
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('<!DOCTYPE NETSCAPE-Bookmark-file-1>');
_writeLine('<!-- This is an automatically generated file.');
_writeLine(' It will be read and overwritten.');
_writeLine(' DO NOT EDIT! -->');
_writeLine(
'<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">',
);
_writeLine('<meta http-equiv="Content-Security-Policy"');
_writeLine(
' content="default-src \'self\'; script-src \'none\'; img-src data: *; object-src \'none\'"></meta>',
);
_writeLine('<TITLE>Bookmarks</TITLE>');
}
void _writeContainer(BookmarkNode item, [String indent = '']) {
if (item.guid == _root.guid) {
_writeLine('<H1>${_escapeHtml(item.title ?? 'Bookmarks')}</H1>');
_writeLine('');
} else {
_write('$indent<DT><H3');
_writeDateAttributes(item);
if (item.guid == BookmarkRoot.toolbar.id) {
_write(' PERSONAL_TOOLBAR_FOLDER="true"');
} else if (item.guid == BookmarkRoot.unfiled.id) {
_write(' UNFILED_BOOKMARKS_FOLDER="true"');
}
_writeLine('>${_escapeHtml(item.title ?? '')}</H3>');
}
_writeLine('$indent<DL><p>');
if (item.children != null) {
_writeContainerContents(item, indent);
}
if (item.guid == _root.guid) {
_writeLine('$indent</DL>');
} else {
_writeLine('$indent</DL><p>');
}
}
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<HR');
if (item.title != null && item.title!.isNotEmpty) {
_write(' NAME="${_escapeHtml(item.title!)}"');
}
_writeLine('>');
}
void _writeItem(BookmarkNode item, String indent) {
if (item.url == null || item.url!.isEmpty) return;
try {
Uri.parse(item.url!);
} catch (e) {
return;
}
_write('$indent<DT><A HREF="${_escapeUrl(item.url!)}"');
_writeDateAttributes(item);
_writeLine('>${_escapeHtml(item.title ?? '')}</A>');
}
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
String _escapeUrl(String text) {
return text.replaceAll('"', '%22');
}
}
@@ -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<int> importFromJSON(String jsonString, {bool replace = false}) async {
try {
final data = jsonDecode(jsonString);
if (data is! Map<String, dynamic>) {
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<Map<String, dynamic>?> 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<int> _import(
Map<String, dynamic> rootNode, {
required bool replace,
}) async {
final nodes =
(rootNode['children'] as List?)
?.whereType<Map<String, dynamic>>()
.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 = <String, String>{};
// 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<int> _insertTree(
Map<String, dynamic> node,
Map<String, String> 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<String, dynamic>;
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<String, String> _translateTreeTypes(Map<String, dynamic> node) {
final folderIdToGuidMap = <String, String>{};
_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<String, dynamic>) {
folderIdToGuidMap.addAll(_translateTreeTypes(child));
}
}
}
return folderIdToGuidMap;
}
/// Fix up search queries with folder mappings
void _fixupSearchQueries(
Map<String, dynamic> node,
Map<String, String> 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<String, dynamic>) {
_fixupSearchQueries(child, folderIdToGuidMap);
}
}
}
}
/// Replace folder IDs with GUIDs in place: URIs
String _fixupQuery(String queryURL, Map<String, String> 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<String, dynamic>? _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 = <String, dynamic>{
'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 = <Map<String, dynamic>>[];
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<String, dynamic> node) {
return node['url'] as String? ?? node['uri'] as String?;
}
/// Normalize 'uri' to 'url' during import
void _normalizeNodeUrl(Map<String, dynamic> node) {
if (node.containsKey('uri')) {
node['url'] = node['uri'];
node.remove('uri');
}
}
/// Get node type from JSON
BookmarkNodeType _getNodeType(Map<String, dynamic> 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;
}
}
}
@@ -128,4 +128,23 @@ class GeckoBookmarksService {
Future<bool> 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<void> 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');
// }
}
}
}
}