initial utils
This commit is contained in:
@@ -68,6 +68,11 @@ class BookmarksRepository extends _$BookmarksRepository {
|
|||||||
ref.invalidateSelf();
|
ref.invalidateSelf();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> eraseEverything(BookmarkRoot root) async {
|
||||||
|
await _service.eraseEverything(root);
|
||||||
|
ref.invalidateSelf();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<BookmarkItem?> build() async {
|
Future<BookmarkItem?> build() async {
|
||||||
final node = await _service.getTree(
|
final node = await _service.getTree(
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ final class BookmarksRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$bookmarksRepositoryHash() =>
|
String _$bookmarksRepositoryHash() =>
|
||||||
r'b8fa5b5699c053b91fcabd46dc97b7192e32d068';
|
r'94d9f0f6c589455b16b0831de4ed1f87dcee704e';
|
||||||
|
|
||||||
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
|
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
|
||||||
FutureOr<BookmarkItem?> build();
|
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) {
|
Future<bool> deleteNode(String guid) {
|
||||||
return _api.deleteNode(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