bookmark chunks with progress
This commit is contained in:
+34
@@ -30,6 +30,40 @@
|
||||
/// dropped during parsing rather than modelled here.
|
||||
library;
|
||||
|
||||
/// What an import is doing right now.
|
||||
enum BookmarkImportPhase {
|
||||
/// Reading and parsing the file, off the UI isolate.
|
||||
parsing,
|
||||
|
||||
/// Emptying the existing roots, for a replacing import.
|
||||
erasing,
|
||||
|
||||
/// Writing the parsed tree into storage.
|
||||
inserting,
|
||||
}
|
||||
|
||||
/// How far along an import is, for display while it runs.
|
||||
class BookmarkImportProgress {
|
||||
final BookmarkImportPhase phase;
|
||||
|
||||
/// Bookmarks written so far. Zero outside [BookmarkImportPhase.inserting].
|
||||
final int inserted;
|
||||
|
||||
/// Bookmarks the file is expected to yield, or 0 while still unknown —
|
||||
/// during parsing there is nothing to count against yet.
|
||||
final int total;
|
||||
|
||||
const BookmarkImportProgress({
|
||||
required this.phase,
|
||||
this.inserted = 0,
|
||||
this.total = 0,
|
||||
});
|
||||
|
||||
/// Fraction complete, or null when it cannot be known and the UI should show
|
||||
/// an indeterminate indicator instead.
|
||||
double? get fraction => total > 0 ? (inserted / total).clamp(0.0, 1.0) : null;
|
||||
}
|
||||
|
||||
/// A single node of a parsed bookmark tree.
|
||||
sealed class ImportBookmarkNode {
|
||||
/// Creation time recorded in the imported file, or null if it had none.
|
||||
|
||||
+7
-1
@@ -21,6 +21,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_import_isolate.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_importer.dart';
|
||||
@@ -226,7 +227,12 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
required String path,
|
||||
required BookmarkImportFormat format,
|
||||
bool replace = false,
|
||||
void Function(BookmarkImportProgress)? onProgress,
|
||||
}) async {
|
||||
onProgress?.call(
|
||||
const BookmarkImportProgress(phase: BookmarkImportPhase.parsing),
|
||||
);
|
||||
|
||||
final tree = await parseBookmarkFile(
|
||||
path: path,
|
||||
format: format,
|
||||
@@ -237,7 +243,7 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
|
||||
final count = await BookmarkTreeImporter(
|
||||
_service,
|
||||
).import(tree, replace: replace);
|
||||
).import(tree, replace: replace, onProgress: onProgress);
|
||||
|
||||
_notifyChanged();
|
||||
return count;
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||
|
||||
/// Shows how far a running bookmark import has got.
|
||||
///
|
||||
/// A large file takes long enough that the app would otherwise look frozen.
|
||||
/// The dialog cannot be dismissed: storage work is already under way and there
|
||||
/// is no way to call it back, so offering a cancel button would be a lie.
|
||||
///
|
||||
/// [progress] is driven by the import itself; the dialog closes when the caller
|
||||
/// pops it.
|
||||
class ImportProgressDialog extends StatelessWidget {
|
||||
final ValueListenable<BookmarkImportProgress> progress;
|
||||
|
||||
const ImportProgressDialog({required this.progress, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: AlertDialog(
|
||||
title: const Text('Importing bookmarks'),
|
||||
content: ValueListenableBuilder<BookmarkImportProgress>(
|
||||
valueListenable: progress,
|
||||
builder: (context, value, child) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(switch (value.phase) {
|
||||
BookmarkImportPhase.parsing => 'Reading the file…',
|
||||
BookmarkImportPhase.erasing => 'Removing existing bookmarks…',
|
||||
BookmarkImportPhase.inserting when value.total > 0 =>
|
||||
'${value.inserted} of ${value.total} bookmarks',
|
||||
BookmarkImportPhase.inserting => 'Saving bookmarks…',
|
||||
}),
|
||||
const SizedBox(height: 16.0),
|
||||
LinearProgressIndicator(value: value.fraction),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+35
-9
@@ -35,6 +35,7 @@ import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
|
||||
@@ -42,6 +43,7 @@ import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/book
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_bookmark_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_folder_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/import_bookmarks_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/import_progress_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_import_isolate.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
@@ -1086,15 +1088,39 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
final shouldReplace = await showImportBookmarksDialog(context);
|
||||
if (shouldReplace == null) return; // User cancelled dialog
|
||||
|
||||
// Reading and parsing happen in a background isolate, so a large file
|
||||
// does not freeze the UI while it is being processed.
|
||||
final count = await ref
|
||||
.read(bookmarksRepositoryProvider.notifier)
|
||||
.importFromFile(
|
||||
path: file.path!,
|
||||
format: format,
|
||||
replace: shouldReplace,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Writing tens of thousands of bookmarks takes long enough that the app
|
||||
// would look frozen without something to watch.
|
||||
final progress = ValueNotifier<BookmarkImportProgress>(
|
||||
const BookmarkImportProgress(phase: BookmarkImportPhase.parsing),
|
||||
);
|
||||
|
||||
final progressDialog = showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => ImportProgressDialog(progress: progress),
|
||||
);
|
||||
|
||||
final int count;
|
||||
try {
|
||||
// Reading and parsing happen in a background isolate, so a large file
|
||||
// does not freeze the UI while it is being processed.
|
||||
count = await ref
|
||||
.read(bookmarksRepositoryProvider.notifier)
|
||||
.importFromFile(
|
||||
path: file.path!,
|
||||
format: format,
|
||||
replace: shouldReplace,
|
||||
onProgress: (value) => progress.value = value,
|
||||
);
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
await progressDialog;
|
||||
}
|
||||
progress.dispose();
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'Imported $count bookmarks successfully');
|
||||
|
||||
+43
-36
@@ -105,6 +105,15 @@ class _PendingItem {
|
||||
_PendingItem({required this.url, this.dateAdded, this.lastModified});
|
||||
}
|
||||
|
||||
/// One level of the document walk: a node and how far through its children the
|
||||
/// walk has got.
|
||||
class _WalkFrame {
|
||||
final dom.Node node;
|
||||
int index = 0;
|
||||
|
||||
_WalkFrame(this.node);
|
||||
}
|
||||
|
||||
class _Frame {
|
||||
final _ParsedFolder folder;
|
||||
int containerNesting = 0;
|
||||
@@ -148,49 +157,47 @@ class _BookmarkHtmlParser {
|
||||
return _buildTree();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// Walks the document depth-first, opening each node on the way down and
|
||||
/// closing it on the way back up.
|
||||
///
|
||||
/// Keeps its own cursor into every level rather than asking a node for its
|
||||
/// next sibling. Locating a sibling means searching the parent's child list,
|
||||
/// which is linear, so doing it per step made the walk quadratic in the
|
||||
/// number of siblings at a level. A flat bookmark file puts every entry under
|
||||
/// a single list, which turned a 25k-bookmark import into hundreds of
|
||||
/// millions of comparisons and minutes of parsing.
|
||||
void _walkTreeForImport(dom.Node? node) {
|
||||
if (node == null) return;
|
||||
|
||||
dom.Node? current = node;
|
||||
dom.Node? next;
|
||||
final stack = <_WalkFrame>[_WalkFrame(node)];
|
||||
_enterNode(node);
|
||||
|
||||
for (;;) {
|
||||
if (current?.nodeType == dom.Node.ELEMENT_NODE) {
|
||||
_openContainer(current! as dom.Element);
|
||||
} else if (current?.nodeType == dom.Node.TEXT_NODE) {
|
||||
_appendText(current!.text ?? '');
|
||||
}
|
||||
while (stack.isNotEmpty) {
|
||||
final frame = stack.last;
|
||||
final children = frame.node.nodes;
|
||||
|
||||
if ((next = current?.firstChild) != null) {
|
||||
current = next;
|
||||
continue;
|
||||
if (frame.index < children.length) {
|
||||
final child = children[frame.index++];
|
||||
_enterNode(child);
|
||||
stack.add(_WalkFrame(child));
|
||||
} else {
|
||||
_leaveNode(frame.node);
|
||||
stack.removeLast();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 _enterNode(dom.Node node) {
|
||||
if (node.nodeType == dom.Node.ELEMENT_NODE) {
|
||||
_openContainer(node as dom.Element);
|
||||
} else if (node.nodeType == dom.Node.TEXT_NODE) {
|
||||
_appendText(node.text ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
void _leaveNode(dom.Node node) {
|
||||
if (node.nodeType == dom.Node.ELEMENT_NODE) {
|
||||
_closeContainer(node as dom.Element);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+56
-11
@@ -38,10 +38,20 @@ class BookmarkTreeImporter {
|
||||
/// first; the root itself is never deleted. A tree that parsed to nothing
|
||||
/// erases nothing either, so a malformed or empty file cannot wipe the
|
||||
/// user's bookmarks and leave them with an empty library.
|
||||
Future<int> import(ImportBookmarkTree tree, {required bool replace}) async {
|
||||
Future<int> import(
|
||||
ImportBookmarkTree tree, {
|
||||
required bool replace,
|
||||
void Function(BookmarkImportProgress)? onProgress,
|
||||
}) async {
|
||||
if (tree.isEmpty) return 0;
|
||||
|
||||
final total = tree.stats.bookmarkCount;
|
||||
|
||||
if (replace) {
|
||||
onProgress?.call(
|
||||
const BookmarkImportProgress(phase: BookmarkImportPhase.erasing),
|
||||
);
|
||||
|
||||
for (final root in BookmarkRoot.values) {
|
||||
if (root != BookmarkRoot.root) {
|
||||
await _service.eraseEverything(root);
|
||||
@@ -51,20 +61,45 @@ class BookmarkTreeImporter {
|
||||
|
||||
var importedCount = 0;
|
||||
|
||||
for (final section in tree.sections.entries) {
|
||||
if (section.value.isEmpty) continue;
|
||||
|
||||
final result = await _service.insertTree(
|
||||
section.key,
|
||||
section.value.map(toPigeonImportNode).toList(),
|
||||
// Native reports progress per insertion, counted from the start of that
|
||||
// call, so completed sections have to be added back on.
|
||||
void report(int insertedInSection) {
|
||||
onProgress?.call(
|
||||
BookmarkImportProgress(
|
||||
phase: BookmarkImportPhase.inserting,
|
||||
inserted: importedCount + insertedInSection,
|
||||
total: total,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
importedCount += result.insertedItemCount;
|
||||
if (onProgress != null) {
|
||||
GeckoBookmarksEvents.setUp(_ImportProgressReceiver(report));
|
||||
}
|
||||
|
||||
if (result.failedNodeCount > 0) {
|
||||
logger.e(
|
||||
'Failed to import ${result.failedNodeCount} top-level nodes into ${section.key}',
|
||||
try {
|
||||
report(0);
|
||||
|
||||
for (final section in tree.sections.entries) {
|
||||
if (section.value.isEmpty) continue;
|
||||
|
||||
final result = await _service.insertTree(
|
||||
section.key,
|
||||
section.value.map(toPigeonImportNode).toList(),
|
||||
);
|
||||
|
||||
importedCount += result.insertedItemCount;
|
||||
report(0);
|
||||
|
||||
if (result.failedNodeCount > 0) {
|
||||
logger.e(
|
||||
'Failed to import ${result.failedNodeCount} top-level nodes into ${section.key}',
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (onProgress != null) {
|
||||
GeckoBookmarksEvents.setUp(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +107,16 @@ class BookmarkTreeImporter {
|
||||
}
|
||||
}
|
||||
|
||||
class _ImportProgressReceiver extends GeckoBookmarksEvents {
|
||||
_ImportProgressReceiver(this._onProgress);
|
||||
|
||||
final void Function(int insertedItemCount) _onProgress;
|
||||
|
||||
@override
|
||||
void onImportProgress(int insertedItemCount) =>
|
||||
_onProgress(insertedItemCount);
|
||||
}
|
||||
|
||||
/// Converts a parsed node into the Pigeon transport type.
|
||||
///
|
||||
/// Timestamps become milliseconds since epoch, with 0 standing in for "the file
|
||||
|
||||
Reference in New Issue
Block a user