initial utils
This commit is contained in:
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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');
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user