bookmark feature rewrite
This commit is contained in:
+3
-1
@@ -82,7 +82,9 @@ AppLinkPolicySnapshot? appLinkPolicySnapshot(Ref ref) {
|
||||
final isolationLoaded = ref
|
||||
.watch(watchIsolatedContextContainerMapProvider)
|
||||
.hasValue;
|
||||
final strictLoaded = ref.watch(watchStrictContextAssignmentsProvider).hasValue;
|
||||
final strictLoaded = ref
|
||||
.watch(watchStrictContextAssignmentsProvider)
|
||||
.hasValue;
|
||||
final sitesLoaded = ref.watch(watchAllAssignedSitesProvider).hasValue;
|
||||
// The real proxy-routing settings drive `protectGeneralContext`; the
|
||||
// `...WithDefaults` view silently substitutes defaults while the row loads,
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ final class AppLinkPolicySnapshotProvider
|
||||
}
|
||||
|
||||
String _$appLinkPolicySnapshotHash() =>
|
||||
r'7f700b67d3b7b0b435fe82a98de455c6e374a1a2';
|
||||
r'6fe2dca118d7162561fc7f6280d1a0411d50972a';
|
||||
|
||||
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
|
||||
/// native profile-scoped store (§2.8), the sole policy source consulted by the
|
||||
|
||||
@@ -311,7 +311,10 @@ AppLinkProtection computeAppLinkProtection({
|
||||
|
||||
final protectedContextIds = <String>{};
|
||||
for (final MapEntry(:key, :value) in assignmentByContextId.entries) {
|
||||
if (isAssignmentProtected(value, protectGeneralContext: protectGeneralContext)) {
|
||||
if (isAssignmentProtected(
|
||||
value,
|
||||
protectGeneralContext: protectGeneralContext,
|
||||
)) {
|
||||
protectedContextIds.add(key);
|
||||
}
|
||||
}
|
||||
@@ -323,7 +326,10 @@ AppLinkProtection computeAppLinkProtection({
|
||||
.toList();
|
||||
if (assignments.isEmpty) continue;
|
||||
final chosen = resolveIsolationContextRouting(assignments).chosen;
|
||||
if (isAssignmentProtected(chosen, protectGeneralContext: protectGeneralContext)) {
|
||||
if (isAssignmentProtected(
|
||||
chosen,
|
||||
protectGeneralContext: protectGeneralContext,
|
||||
)) {
|
||||
protectedContextIds.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-12
@@ -53,17 +53,17 @@ class ContainerAppLinkSettingsDialog extends ConsumerWidget {
|
||||
WidgetRef ref,
|
||||
ContextAppLinkPolicy Function(ContextAppLinkPolicy current) update,
|
||||
) async {
|
||||
await ref
|
||||
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||
.save((current) {
|
||||
final existing =
|
||||
current.appLinkContextOverrides[contextId] ??
|
||||
ContextAppLinkPolicy.blank();
|
||||
return current.copyWith.appLinkContextOverrides({
|
||||
...current.appLinkContextOverrides,
|
||||
contextId: update(existing),
|
||||
});
|
||||
});
|
||||
await ref.read(saveGeneralSettingsControllerProvider.notifier).save((
|
||||
current,
|
||||
) {
|
||||
final existing =
|
||||
current.appLinkContextOverrides[contextId] ??
|
||||
ContextAppLinkPolicy.blank();
|
||||
return current.copyWith.appLinkContextOverrides({
|
||||
...current.appLinkContextOverrides,
|
||||
contextId: update(existing),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -120,7 +120,9 @@ class ContainerAppLinkSettingsDialog extends ConsumerWidget {
|
||||
RadioListTile.adaptive(
|
||||
value: AppLinksMode.ask,
|
||||
title: Text('Ask before opening'),
|
||||
subtitle: Text('Show a prompt before opening links in apps'),
|
||||
subtitle: Text(
|
||||
'Show a prompt before opening links in apps',
|
||||
),
|
||||
),
|
||||
RadioListTile.adaptive(
|
||||
value: AppLinksMode.never,
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/// Parser output for bookmark imports.
|
||||
///
|
||||
/// This is the hand-off between parsing (which is pure, and runs in a
|
||||
/// background isolate) and insertion (which talks to Places). Nodes carry no
|
||||
/// guids and no explicit positions: the tree is described purely by nesting and
|
||||
/// child order, and storage assigns both while inserting.
|
||||
///
|
||||
/// Netscape/Firefox metadata that Places cannot represent — `TAGS`,
|
||||
/// `SHORTCUTURL`/keyword, `POST_DATA` and `LAST_CHARSET` — is intentionally
|
||||
/// dropped during parsing rather than modelled here.
|
||||
library;
|
||||
|
||||
/// A single node of a parsed bookmark tree.
|
||||
sealed class ImportBookmarkNode {
|
||||
/// Creation time recorded in the imported file, or null if it had none.
|
||||
final DateTime? dateAdded;
|
||||
|
||||
/// Modification time recorded in the imported file, or null if it had none.
|
||||
final DateTime? lastModified;
|
||||
|
||||
const ImportBookmarkNode({this.dateAdded, this.lastModified});
|
||||
}
|
||||
|
||||
/// A folder and everything nested underneath it.
|
||||
final class ImportBookmarkFolder extends ImportBookmarkNode {
|
||||
final String title;
|
||||
|
||||
/// Children in the order they should appear under this folder.
|
||||
final List<ImportBookmarkNode> children;
|
||||
|
||||
const ImportBookmarkFolder({
|
||||
required this.title,
|
||||
required this.children,
|
||||
super.dateAdded,
|
||||
super.lastModified,
|
||||
});
|
||||
}
|
||||
|
||||
/// A bookmark pointing at [url].
|
||||
final class ImportBookmarkItem extends ImportBookmarkNode {
|
||||
final Uri url;
|
||||
final String title;
|
||||
|
||||
const ImportBookmarkItem({
|
||||
required this.url,
|
||||
required this.title,
|
||||
super.dateAdded,
|
||||
super.lastModified,
|
||||
});
|
||||
}
|
||||
|
||||
/// A visual divider between sibling nodes.
|
||||
final class ImportBookmarkSeparator extends ImportBookmarkNode {
|
||||
const ImportBookmarkSeparator({super.dateAdded, super.lastModified});
|
||||
}
|
||||
|
||||
/// What a parser found in a bookmark file, and where it belongs.
|
||||
///
|
||||
/// [sections] maps the guid of a Places root onto the nodes that should be
|
||||
/// appended underneath it. Files without root markers produce a single section;
|
||||
/// Firefox exports that identify their menu/toolbar/unfiled roots produce one
|
||||
/// section per recognised root.
|
||||
class ImportBookmarkTree {
|
||||
final Map<String, List<ImportBookmarkNode>> sections;
|
||||
|
||||
/// Counters describing what the parser saw, including what it discarded.
|
||||
final ImportBookmarkStats stats;
|
||||
|
||||
const ImportBookmarkTree({required this.sections, required this.stats});
|
||||
|
||||
static const empty = ImportBookmarkTree(
|
||||
sections: {},
|
||||
stats: ImportBookmarkStats(),
|
||||
);
|
||||
|
||||
bool get isEmpty => sections.values.every((nodes) => nodes.isEmpty);
|
||||
}
|
||||
|
||||
/// Summary of a parse, useful for reporting and for regression tests that care
|
||||
/// about what was skipped rather than only about what survived.
|
||||
class ImportBookmarkStats {
|
||||
/// Bookmark items that parsed successfully.
|
||||
final int bookmarkCount;
|
||||
|
||||
/// Folders that parsed successfully.
|
||||
final int folderCount;
|
||||
|
||||
/// Separators that parsed successfully.
|
||||
final int separatorCount;
|
||||
|
||||
/// Entries dropped because they had no URL, or one that could not be parsed
|
||||
/// or carried no scheme.
|
||||
final int skippedUrlCount;
|
||||
|
||||
const ImportBookmarkStats({
|
||||
this.bookmarkCount = 0,
|
||||
this.folderCount = 0,
|
||||
this.separatorCount = 0,
|
||||
this.skippedUrlCount = 0,
|
||||
});
|
||||
|
||||
ImportBookmarkStats copyWith({
|
||||
int? bookmarkCount,
|
||||
int? folderCount,
|
||||
int? separatorCount,
|
||||
int? skippedUrlCount,
|
||||
}) {
|
||||
return ImportBookmarkStats(
|
||||
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
|
||||
folderCount: folderCount ?? this.folderCount,
|
||||
separatorCount: separatorCount ?? this.separatorCount,
|
||||
skippedUrlCount: skippedUrlCount ?? this.skippedUrlCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
+75
-185
@@ -17,125 +17,38 @@
|
||||
* 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 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
|
||||
|
||||
part 'bookmarks.g.dart';
|
||||
|
||||
/// Check if a root folder is effectively empty (has no non-root children)
|
||||
bool _isEmptyRootFolder(BookmarkFolder folder) {
|
||||
if (folder.children == null) return true;
|
||||
// A root folder is empty if it has no children, or only contains other root folders
|
||||
return folder.children!.every(
|
||||
(child) => bookmarkRootIds.contains(child.guid),
|
||||
);
|
||||
}
|
||||
|
||||
T? _selectChildRecursive<T extends BookmarkItem>(
|
||||
List<BookmarkItem> children,
|
||||
String guid,
|
||||
) {
|
||||
for (final child in children) {
|
||||
if (child.guid == guid && child is T) {
|
||||
return child;
|
||||
}
|
||||
|
||||
if (child case final BookmarkFolder folder) {
|
||||
if (folder.children != null) {
|
||||
final result = _selectChildRecursive<T>(folder.children!, guid);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
T _cloneAndFilterChildrenType<T extends BookmarkItem>(T node) {
|
||||
if (node is BookmarkFolder) {
|
||||
if (node.children != null) {
|
||||
return node.copyWith.children(
|
||||
node.children
|
||||
?.whereType<T>()
|
||||
.map((e) => _cloneAndFilterChildrenType<T>(e))
|
||||
.toList(),
|
||||
)
|
||||
as T;
|
||||
}
|
||||
}
|
||||
|
||||
return node.clone() as T;
|
||||
}
|
||||
|
||||
BookmarkItem? _cloneAndFilterOnGuids(BookmarkItem node, Set<String> guids) {
|
||||
if (node is BookmarkFolder) {
|
||||
if (node.children != null) {
|
||||
final filtered = node.children
|
||||
?.where((e) => e is BookmarkFolder || guids.contains(e.guid))
|
||||
.map((e) => _cloneAndFilterOnGuids(e, guids))
|
||||
.nonNulls
|
||||
.toList();
|
||||
|
||||
if (filtered.isNotEmpty) {
|
||||
return node.copyWith.children(filtered);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (guids.contains(node.guid)) {
|
||||
return node.clone();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
class BookmarksSearch extends _$BookmarksSearch {
|
||||
final _service = GeckoBookmarksService();
|
||||
late StreamController<Set<String>> _streamController;
|
||||
|
||||
Future<void> search(String query, {int limit = 10}) async {
|
||||
if (query.isNotEmpty) {
|
||||
try {
|
||||
await _service.searchBookmarks(query, limit: limit).then((value) {
|
||||
if (!_streamController.isClosed) {
|
||||
_streamController.add(value.map((e) => e.guid).toSet());
|
||||
}
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == 'OperationInterrupted') return;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Set<String>> build() {
|
||||
_streamController = StreamController();
|
||||
|
||||
ref.onDispose(() async {
|
||||
await _streamController.close();
|
||||
});
|
||||
|
||||
return _streamController.stream;
|
||||
}
|
||||
/// Whether a root folder holds anything worth showing.
|
||||
///
|
||||
/// Roots always contain each other, so a root that only contains other roots
|
||||
/// counts as empty.
|
||||
bool _hasVisibleContent(BookmarkFolder? folder) {
|
||||
final children = folder?.children;
|
||||
if (children == null) return false;
|
||||
return children.any((child) => !bookmarkRootIds.contains(child.guid));
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
class BookmarkSearchResults extends _$BookmarkSearchResults {
|
||||
final _service = GeckoBookmarksService();
|
||||
|
||||
/// Identifies the most recent request.
|
||||
///
|
||||
/// Typing starts a search per keystroke and storage does not answer them in
|
||||
/// order, so a slow early query could otherwise land after a fast later one
|
||||
/// and leave the list showing results for text the user has moved on from.
|
||||
int _latestRequest = 0;
|
||||
|
||||
Future<void> search(String query, {int limit = 10}) async {
|
||||
final request = ++_latestRequest;
|
||||
|
||||
if (query.isEmpty) {
|
||||
state = [];
|
||||
return;
|
||||
@@ -143,7 +56,7 @@ class BookmarkSearchResults extends _$BookmarkSearchResults {
|
||||
|
||||
try {
|
||||
final results = await _service.searchBookmarks(query, limit: limit);
|
||||
if (!ref.mounted) return;
|
||||
if (!ref.mounted || request != _latestRequest) return;
|
||||
state = results
|
||||
.map(BookmarkItem.parseRecursive)
|
||||
.whereType<BookmarkEntry>()
|
||||
@@ -160,98 +73,75 @@ class BookmarkSearchResults extends _$BookmarkSearchResults {
|
||||
}
|
||||
}
|
||||
|
||||
/// A single folder with its direct children.
|
||||
///
|
||||
/// The load is scoped to one folder, so its cost tracks the folder being shown
|
||||
/// rather than the size of the library. Rebuilds whenever the repository
|
||||
/// reports a change.
|
||||
@Riverpod()
|
||||
AsyncValue<T?> bookmarks<T extends BookmarkItem>(
|
||||
Future<BookmarkFolder?> bookmarkFolder(Ref ref, String guid) {
|
||||
ref.watch(bookmarksRepositoryProvider);
|
||||
return ref.read(bookmarksRepositoryProvider.notifier).getFolder(guid);
|
||||
}
|
||||
|
||||
/// The folder shown by the bookmark list, with the roots the user asked to
|
||||
/// hide already removed.
|
||||
///
|
||||
/// Emptiness can only be judged by looking inside each root, but the root level
|
||||
/// has a fixed handful of children, so the extra loads are bounded and shallow.
|
||||
@Riverpod()
|
||||
Future<BookmarkFolder?> bookmarkListFolder(
|
||||
Ref ref,
|
||||
String entryGuid, {
|
||||
bool hideEmptyRoots = false,
|
||||
}) {
|
||||
final bookmarksAsync = ref.watch(bookmarksRepositoryProvider);
|
||||
}) async {
|
||||
final folder = await ref.watch(bookmarkFolderProvider(entryGuid).future);
|
||||
|
||||
return bookmarksAsync.whenData((bookmarkNode) {
|
||||
T? selectedNode;
|
||||
if (folder == null ||
|
||||
!hideEmptyRoots ||
|
||||
entryGuid != BookmarkRoot.root.id ||
|
||||
folder.children == null) {
|
||||
return folder;
|
||||
}
|
||||
|
||||
if (bookmarkNode != null && bookmarkNode is T) {
|
||||
if (bookmarkNode.guid == entryGuid) {
|
||||
selectedNode = bookmarkNode;
|
||||
} else if (bookmarkNode case final BookmarkFolder folder) {
|
||||
if (folder.children != null) {
|
||||
selectedNode = _selectChildRecursive<T>(folder.children!, entryGuid);
|
||||
}
|
||||
}
|
||||
final visible = <BookmarkItem>[];
|
||||
for (final child in folder.children!) {
|
||||
if (child is! BookmarkFolder || child.guid == BookmarkRoot.mobile.id) {
|
||||
visible.add(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (selectedNode != null) {
|
||||
var result = _cloneAndFilterChildrenType<T>(selectedNode);
|
||||
|
||||
// Filter empty root folders when viewing root level (excluding WebLibre root)
|
||||
if (hideEmptyRoots &&
|
||||
entryGuid == BookmarkRoot.root.id &&
|
||||
result is BookmarkFolder) {
|
||||
final filteredChildren = result.children
|
||||
?.where(
|
||||
(child) =>
|
||||
child is! BookmarkFolder ||
|
||||
child.guid == BookmarkRoot.mobile.id ||
|
||||
!_isEmptyRootFolder(child),
|
||||
)
|
||||
.toList();
|
||||
result = result.copyWith.children(filteredChildren) as T;
|
||||
}
|
||||
|
||||
return result;
|
||||
final loaded = await ref.watch(bookmarkFolderProvider(child.guid).future);
|
||||
if (_hasVisibleContent(loaded)) {
|
||||
visible.add(child);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
return folder.copyWith.children(visible);
|
||||
}
|
||||
|
||||
/// Guids of the bookmarks pointing at [url], or an empty list when there are
|
||||
/// none.
|
||||
///
|
||||
/// Backed by a storage lookup, so "is this page bookmarked?" costs the same
|
||||
/// whether the user has ten bookmarks or fifty thousand.
|
||||
@Riverpod()
|
||||
class SeamlessBookmarks extends _$SeamlessBookmarks {
|
||||
bool _hasSearch = false;
|
||||
Future<List<String>> bookmarkGuidsForUrl(Ref ref, Uri? url) async {
|
||||
if (url == null) return const [];
|
||||
|
||||
void search(String input) {
|
||||
if (input.isNotEmpty) {
|
||||
if (!_hasSearch) {
|
||||
_hasSearch = true;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
//Don't block
|
||||
unawaited(ref.read(bookmarksSearchProvider.notifier).search(input));
|
||||
} else if (_hasSearch) {
|
||||
_hasSearch = false;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AsyncValue<BookmarkItem?> build(
|
||||
String entryGuid, {
|
||||
bool hideEmptyRoots = false,
|
||||
}) {
|
||||
final bookmarks = ref.watch(
|
||||
bookmarksProvider<BookmarkItem>(
|
||||
entryGuid,
|
||||
hideEmptyRoots: hideEmptyRoots,
|
||||
),
|
||||
);
|
||||
|
||||
if (_hasSearch) {
|
||||
final filterGuids = ref.watch(bookmarksSearchProvider);
|
||||
return bookmarks.map(
|
||||
data: (node) =>
|
||||
node.value.mapNotNull(
|
||||
(node) => filterGuids.whenData(
|
||||
(results) => _cloneAndFilterOnGuids(node, results),
|
||||
),
|
||||
) ??
|
||||
const AsyncValue.data(null),
|
||||
error: (e) => e,
|
||||
loading: (s) => s,
|
||||
);
|
||||
} else {
|
||||
return bookmarks;
|
||||
}
|
||||
}
|
||||
ref.watch(bookmarksRepositoryProvider);
|
||||
return ref
|
||||
.read(bookmarksRepositoryProvider.notifier)
|
||||
.bookmarkGuidsForUrl(url);
|
||||
}
|
||||
|
||||
/// Number of bookmarks inside the trees rooted at [guids].
|
||||
///
|
||||
/// Used to tell the user how much a destructive action will affect.
|
||||
@Riverpod()
|
||||
Future<int> bookmarkCountInTrees(Ref ref, List<String> guids) {
|
||||
ref.watch(bookmarksRepositoryProvider);
|
||||
return ref
|
||||
.read(bookmarksRepositoryProvider.notifier)
|
||||
.countBookmarksInTrees(guids);
|
||||
}
|
||||
|
||||
+364
-215
@@ -9,50 +9,6 @@ part of 'bookmarks.dart';
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(BookmarksSearch)
|
||||
final bookmarksSearchProvider = BookmarksSearchProvider._();
|
||||
|
||||
final class BookmarksSearchProvider
|
||||
extends $StreamNotifierProvider<BookmarksSearch, Set<String>> {
|
||||
BookmarksSearchProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'bookmarksSearchProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$bookmarksSearchHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
BookmarksSearch create() => BookmarksSearch();
|
||||
}
|
||||
|
||||
String _$bookmarksSearchHash() => r'41053cbc1014e0fdd04d9510bf15c9896a9f752d';
|
||||
|
||||
abstract class _$BookmarksSearch extends $StreamNotifier<Set<String>> {
|
||||
Stream<Set<String>> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
WhenComplete runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<Set<String>>, Set<String>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<Set<String>>, Set<String>>,
|
||||
AsyncValue<Set<String>>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
return element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(BookmarkSearchResults)
|
||||
final bookmarkSearchResultsProvider = BookmarkSearchResultsProvider._();
|
||||
|
||||
@@ -106,159 +62,70 @@ abstract class _$BookmarkSearchResults extends $Notifier<List<BookmarkEntry>> {
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(bookmarks)
|
||||
final bookmarksProvider = BookmarksFamily._();
|
||||
/// A single folder with its direct children.
|
||||
///
|
||||
/// The load is scoped to one folder, so its cost tracks the folder being shown
|
||||
/// rather than the size of the library. Rebuilds whenever the repository
|
||||
/// reports a change.
|
||||
|
||||
final class BookmarksProvider<T extends BookmarkItem>
|
||||
extends $FunctionalProvider<AsyncValue<T?>, AsyncValue<T?>, AsyncValue<T?>>
|
||||
with $Provider<AsyncValue<T?>> {
|
||||
BookmarksProvider._({
|
||||
required BookmarksFamily super.from,
|
||||
required (String, {bool hideEmptyRoots}) super.argument,
|
||||
@ProviderFor(bookmarkFolder)
|
||||
final bookmarkFolderProvider = BookmarkFolderFamily._();
|
||||
|
||||
/// A single folder with its direct children.
|
||||
///
|
||||
/// The load is scoped to one folder, so its cost tracks the folder being shown
|
||||
/// rather than the size of the library. Rebuilds whenever the repository
|
||||
/// reports a change.
|
||||
|
||||
final class BookmarkFolderProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<BookmarkFolder?>,
|
||||
BookmarkFolder?,
|
||||
FutureOr<BookmarkFolder?>
|
||||
>
|
||||
with $FutureModifier<BookmarkFolder?>, $FutureProvider<BookmarkFolder?> {
|
||||
/// A single folder with its direct children.
|
||||
///
|
||||
/// The load is scoped to one folder, so its cost tracks the folder being shown
|
||||
/// rather than the size of the library. Rebuilds whenever the repository
|
||||
/// reports a change.
|
||||
BookmarkFolderProvider._({
|
||||
required BookmarkFolderFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'bookmarksProvider',
|
||||
name: r'bookmarkFolderProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$bookmarksHash();
|
||||
String debugGetCreateSourceHash() => _$bookmarkFolderHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'bookmarksProvider'
|
||||
'<${T}>'
|
||||
'$argument';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<AsyncValue<T?>> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
AsyncValue<T?> create(Ref ref) {
|
||||
final argument = this.argument as (String, {bool hideEmptyRoots});
|
||||
return bookmarks<T>(
|
||||
ref,
|
||||
argument.$1,
|
||||
hideEmptyRoots: argument.hideEmptyRoots,
|
||||
);
|
||||
}
|
||||
|
||||
$R _captureGenerics<$R>($R Function<T extends BookmarkItem>() cb) {
|
||||
return cb<T>();
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AsyncValue<T?> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AsyncValue<T?>>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is BookmarksProvider &&
|
||||
other.runtimeType == runtimeType &&
|
||||
other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType, argument);
|
||||
}
|
||||
}
|
||||
|
||||
String _$bookmarksHash() => r'72b54c4ff18cfdb60824a57607628be6b61d25d4';
|
||||
|
||||
final class BookmarksFamily extends $Family {
|
||||
BookmarksFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'bookmarksProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
BookmarksProvider<T> call<T extends BookmarkItem>(
|
||||
String entryGuid, {
|
||||
bool hideEmptyRoots = false,
|
||||
}) => BookmarksProvider<T>._(
|
||||
argument: (entryGuid, hideEmptyRoots: hideEmptyRoots),
|
||||
from: this,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => r'bookmarksProvider';
|
||||
|
||||
/// {@macro riverpod.override_with}
|
||||
Override overrideWith(
|
||||
AsyncValue<T?> Function<T extends BookmarkItem>(
|
||||
Ref ref,
|
||||
(String, {bool hideEmptyRoots}) args,
|
||||
)
|
||||
create,
|
||||
) => $FamilyOverride(
|
||||
from: this,
|
||||
createElement: (pointer) {
|
||||
final provider = pointer.origin as BookmarksProvider;
|
||||
return provider._captureGenerics(<T extends BookmarkItem>() {
|
||||
provider as BookmarksProvider<T>;
|
||||
final argument = provider.argument as (String, {bool hideEmptyRoots});
|
||||
return provider
|
||||
.$view(create: (ref) => create(ref, argument))
|
||||
.$createElement(pointer);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ProviderFor(SeamlessBookmarks)
|
||||
final seamlessBookmarksProvider = SeamlessBookmarksFamily._();
|
||||
|
||||
final class SeamlessBookmarksProvider
|
||||
extends $NotifierProvider<SeamlessBookmarks, AsyncValue<BookmarkItem?>> {
|
||||
SeamlessBookmarksProvider._({
|
||||
required SeamlessBookmarksFamily super.from,
|
||||
required (String, {bool hideEmptyRoots}) super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'seamlessBookmarksProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$seamlessBookmarksHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'seamlessBookmarksProvider'
|
||||
return r'bookmarkFolderProvider'
|
||||
''
|
||||
'$argument';
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SeamlessBookmarks create() => SeamlessBookmarks();
|
||||
$FutureProviderElement<BookmarkFolder?> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AsyncValue<BookmarkItem?> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AsyncValue<BookmarkItem?>>(value),
|
||||
);
|
||||
@override
|
||||
FutureOr<BookmarkFolder?> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return bookmarkFolder(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is SeamlessBookmarksProvider && other.argument == argument;
|
||||
return other is BookmarkFolderProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -267,64 +134,346 @@ final class SeamlessBookmarksProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$seamlessBookmarksHash() => r'240b213fa8fe595781ccc608c5d551be54c31992';
|
||||
String _$bookmarkFolderHash() => r'a0a3754a2b8099415cffad6358d9388dc7c4e7bf';
|
||||
|
||||
final class SeamlessBookmarksFamily extends $Family
|
||||
with
|
||||
$ClassFamilyOverride<
|
||||
SeamlessBookmarks,
|
||||
AsyncValue<BookmarkItem?>,
|
||||
AsyncValue<BookmarkItem?>,
|
||||
AsyncValue<BookmarkItem?>,
|
||||
(String, {bool hideEmptyRoots})
|
||||
> {
|
||||
SeamlessBookmarksFamily._()
|
||||
/// A single folder with its direct children.
|
||||
///
|
||||
/// The load is scoped to one folder, so its cost tracks the folder being shown
|
||||
/// rather than the size of the library. Rebuilds whenever the repository
|
||||
/// reports a change.
|
||||
|
||||
final class BookmarkFolderFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<BookmarkFolder?>, String> {
|
||||
BookmarkFolderFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'seamlessBookmarksProvider',
|
||||
name: r'bookmarkFolderProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
SeamlessBookmarksProvider call(
|
||||
/// A single folder with its direct children.
|
||||
///
|
||||
/// The load is scoped to one folder, so its cost tracks the folder being shown
|
||||
/// rather than the size of the library. Rebuilds whenever the repository
|
||||
/// reports a change.
|
||||
|
||||
BookmarkFolderProvider call(String guid) =>
|
||||
BookmarkFolderProvider._(argument: guid, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'bookmarkFolderProvider';
|
||||
}
|
||||
|
||||
/// The folder shown by the bookmark list, with the roots the user asked to
|
||||
/// hide already removed.
|
||||
///
|
||||
/// Emptiness can only be judged by looking inside each root, but the root level
|
||||
/// has a fixed handful of children, so the extra loads are bounded and shallow.
|
||||
|
||||
@ProviderFor(bookmarkListFolder)
|
||||
final bookmarkListFolderProvider = BookmarkListFolderFamily._();
|
||||
|
||||
/// The folder shown by the bookmark list, with the roots the user asked to
|
||||
/// hide already removed.
|
||||
///
|
||||
/// Emptiness can only be judged by looking inside each root, but the root level
|
||||
/// has a fixed handful of children, so the extra loads are bounded and shallow.
|
||||
|
||||
final class BookmarkListFolderProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<BookmarkFolder?>,
|
||||
BookmarkFolder?,
|
||||
FutureOr<BookmarkFolder?>
|
||||
>
|
||||
with $FutureModifier<BookmarkFolder?>, $FutureProvider<BookmarkFolder?> {
|
||||
/// The folder shown by the bookmark list, with the roots the user asked to
|
||||
/// hide already removed.
|
||||
///
|
||||
/// Emptiness can only be judged by looking inside each root, but the root level
|
||||
/// has a fixed handful of children, so the extra loads are bounded and shallow.
|
||||
BookmarkListFolderProvider._({
|
||||
required BookmarkListFolderFamily super.from,
|
||||
required (String, {bool hideEmptyRoots}) super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'bookmarkListFolderProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$bookmarkListFolderHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'bookmarkListFolderProvider'
|
||||
''
|
||||
'$argument';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<BookmarkFolder?> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<BookmarkFolder?> create(Ref ref) {
|
||||
final argument = this.argument as (String, {bool hideEmptyRoots});
|
||||
return bookmarkListFolder(
|
||||
ref,
|
||||
argument.$1,
|
||||
hideEmptyRoots: argument.hideEmptyRoots,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is BookmarkListFolderProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$bookmarkListFolderHash() =>
|
||||
r'da6b257ef55b38021a2da7e41209b66a58cfbbb9';
|
||||
|
||||
/// The folder shown by the bookmark list, with the roots the user asked to
|
||||
/// hide already removed.
|
||||
///
|
||||
/// Emptiness can only be judged by looking inside each root, but the root level
|
||||
/// has a fixed handful of children, so the extra loads are bounded and shallow.
|
||||
|
||||
final class BookmarkListFolderFamily extends $Family
|
||||
with
|
||||
$FunctionalFamilyOverride<
|
||||
FutureOr<BookmarkFolder?>,
|
||||
(String, {bool hideEmptyRoots})
|
||||
> {
|
||||
BookmarkListFolderFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'bookmarkListFolderProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// The folder shown by the bookmark list, with the roots the user asked to
|
||||
/// hide already removed.
|
||||
///
|
||||
/// Emptiness can only be judged by looking inside each root, but the root level
|
||||
/// has a fixed handful of children, so the extra loads are bounded and shallow.
|
||||
|
||||
BookmarkListFolderProvider call(
|
||||
String entryGuid, {
|
||||
bool hideEmptyRoots = false,
|
||||
}) => SeamlessBookmarksProvider._(
|
||||
}) => BookmarkListFolderProvider._(
|
||||
argument: (entryGuid, hideEmptyRoots: hideEmptyRoots),
|
||||
from: this,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => r'seamlessBookmarksProvider';
|
||||
String toString() => r'bookmarkListFolderProvider';
|
||||
}
|
||||
|
||||
abstract class _$SeamlessBookmarks
|
||||
extends $Notifier<AsyncValue<BookmarkItem?>> {
|
||||
late final _$args = ref.$arg as (String, {bool hideEmptyRoots});
|
||||
String get entryGuid => _$args.$1;
|
||||
bool get hideEmptyRoots => _$args.hideEmptyRoots;
|
||||
/// Guids of the bookmarks pointing at [url], or an empty list when there are
|
||||
/// none.
|
||||
///
|
||||
/// Backed by a storage lookup, so "is this page bookmarked?" costs the same
|
||||
/// whether the user has ten bookmarks or fifty thousand.
|
||||
|
||||
@ProviderFor(bookmarkGuidsForUrl)
|
||||
final bookmarkGuidsForUrlProvider = BookmarkGuidsForUrlFamily._();
|
||||
|
||||
/// Guids of the bookmarks pointing at [url], or an empty list when there are
|
||||
/// none.
|
||||
///
|
||||
/// Backed by a storage lookup, so "is this page bookmarked?" costs the same
|
||||
/// whether the user has ten bookmarks or fifty thousand.
|
||||
|
||||
final class BookmarkGuidsForUrlProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<String>>,
|
||||
List<String>,
|
||||
FutureOr<List<String>>
|
||||
>
|
||||
with $FutureModifier<List<String>>, $FutureProvider<List<String>> {
|
||||
/// Guids of the bookmarks pointing at [url], or an empty list when there are
|
||||
/// none.
|
||||
///
|
||||
/// Backed by a storage lookup, so "is this page bookmarked?" costs the same
|
||||
/// whether the user has ten bookmarks or fifty thousand.
|
||||
BookmarkGuidsForUrlProvider._({
|
||||
required BookmarkGuidsForUrlFamily super.from,
|
||||
required Uri? super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'bookmarkGuidsForUrlProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
AsyncValue<BookmarkItem?> build(
|
||||
String entryGuid, {
|
||||
bool hideEmptyRoots = false,
|
||||
});
|
||||
@$mustCallSuper
|
||||
@override
|
||||
WhenComplete runBuild() {
|
||||
final ref =
|
||||
this.ref as $Ref<AsyncValue<BookmarkItem?>, AsyncValue<BookmarkItem?>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<BookmarkItem?>, AsyncValue<BookmarkItem?>>,
|
||||
AsyncValue<BookmarkItem?>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
return element.handleCreate(
|
||||
ref,
|
||||
() => build(_$args.$1, hideEmptyRoots: _$args.hideEmptyRoots),
|
||||
);
|
||||
String debugGetCreateSourceHash() => _$bookmarkGuidsForUrlHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'bookmarkGuidsForUrlProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<String>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<String>> create(Ref ref) {
|
||||
final argument = this.argument as Uri?;
|
||||
return bookmarkGuidsForUrl(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is BookmarkGuidsForUrlProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$bookmarkGuidsForUrlHash() =>
|
||||
r'aeca791afdcad74c2e8af93c392860ea3cb92b20';
|
||||
|
||||
/// Guids of the bookmarks pointing at [url], or an empty list when there are
|
||||
/// none.
|
||||
///
|
||||
/// Backed by a storage lookup, so "is this page bookmarked?" costs the same
|
||||
/// whether the user has ten bookmarks or fifty thousand.
|
||||
|
||||
final class BookmarkGuidsForUrlFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<List<String>>, Uri?> {
|
||||
BookmarkGuidsForUrlFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'bookmarkGuidsForUrlProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Guids of the bookmarks pointing at [url], or an empty list when there are
|
||||
/// none.
|
||||
///
|
||||
/// Backed by a storage lookup, so "is this page bookmarked?" costs the same
|
||||
/// whether the user has ten bookmarks or fifty thousand.
|
||||
|
||||
BookmarkGuidsForUrlProvider call(Uri? url) =>
|
||||
BookmarkGuidsForUrlProvider._(argument: url, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'bookmarkGuidsForUrlProvider';
|
||||
}
|
||||
|
||||
/// Number of bookmarks inside the trees rooted at [guids].
|
||||
///
|
||||
/// Used to tell the user how much a destructive action will affect.
|
||||
|
||||
@ProviderFor(bookmarkCountInTrees)
|
||||
final bookmarkCountInTreesProvider = BookmarkCountInTreesFamily._();
|
||||
|
||||
/// Number of bookmarks inside the trees rooted at [guids].
|
||||
///
|
||||
/// Used to tell the user how much a destructive action will affect.
|
||||
|
||||
final class BookmarkCountInTreesProvider
|
||||
extends $FunctionalProvider<AsyncValue<int>, int, FutureOr<int>>
|
||||
with $FutureModifier<int>, $FutureProvider<int> {
|
||||
/// Number of bookmarks inside the trees rooted at [guids].
|
||||
///
|
||||
/// Used to tell the user how much a destructive action will affect.
|
||||
BookmarkCountInTreesProvider._({
|
||||
required BookmarkCountInTreesFamily super.from,
|
||||
required List<String> super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'bookmarkCountInTreesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$bookmarkCountInTreesHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'bookmarkCountInTreesProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<int> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<int> create(Ref ref) {
|
||||
final argument = this.argument as List<String>;
|
||||
return bookmarkCountInTrees(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is BookmarkCountInTreesProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$bookmarkCountInTreesHash() =>
|
||||
r'efe0cc45e7e77aa542d9ebbd9f4fee5cf9ee5903';
|
||||
|
||||
/// Number of bookmarks inside the trees rooted at [guids].
|
||||
///
|
||||
/// Used to tell the user how much a destructive action will affect.
|
||||
|
||||
final class BookmarkCountInTreesFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<int>, List<String>> {
|
||||
BookmarkCountInTreesFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'bookmarkCountInTreesProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Number of bookmarks inside the trees rooted at [guids].
|
||||
///
|
||||
/// Used to tell the user how much a destructive action will affect.
|
||||
|
||||
BookmarkCountInTreesProvider call(List<String> guids) =>
|
||||
BookmarkCountInTreesProvider._(argument: guids, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'bookmarkCountInTreesProvider';
|
||||
}
|
||||
|
||||
+88
-16
@@ -18,11 +18,12 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:nullability/nullability.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/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';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart';
|
||||
|
||||
part 'bookmarks.g.dart';
|
||||
@@ -40,7 +41,7 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
int? position,
|
||||
}) async {
|
||||
await _service.addItem(parentGuid, url, title, position);
|
||||
ref.invalidateSelf();
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
Future<void> addFolder({
|
||||
@@ -49,7 +50,7 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
int? position,
|
||||
}) async {
|
||||
await _service.addFolder(parentGuid, title, position);
|
||||
ref.invalidateSelf();
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
Future<void> editBookmark({
|
||||
@@ -68,7 +69,7 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
position: position,
|
||||
),
|
||||
);
|
||||
ref.invalidateSelf();
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
Future<void> editFolder({
|
||||
@@ -81,12 +82,12 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
guid,
|
||||
BookmarkInfo(title: title, parentGuid: parentGuid, position: position),
|
||||
);
|
||||
ref.invalidateSelf();
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
Future<void> delete(String guid) async {
|
||||
await _service.deleteNode(guid);
|
||||
ref.invalidateSelf();
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
Future<void> moveMany({
|
||||
@@ -104,7 +105,7 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
BookmarkInfo(parentGuid: targetParentGuid),
|
||||
);
|
||||
}
|
||||
ref.invalidateSelf();
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
Future<void> deleteMany(Iterable<String> guids) async {
|
||||
@@ -115,7 +116,7 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
}
|
||||
await _service.deleteNode(guid);
|
||||
}
|
||||
ref.invalidateSelf();
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
Future<void> flattenFolder({required BookmarkFolder folder}) async {
|
||||
@@ -137,7 +138,45 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
}
|
||||
}
|
||||
await _service.deleteNode(folder.guid);
|
||||
ref.invalidateSelf();
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
/// Loads a single folder and its direct children.
|
||||
///
|
||||
/// This is the load the bookmark UI is built on: the cost is proportional to
|
||||
/// the folder being shown, not to the size of the library. Reach for
|
||||
/// [getFolderTree] only when an operation genuinely needs descendants.
|
||||
Future<BookmarkFolder?> getFolder(String guid) async {
|
||||
final node = await _service.getTree(guid);
|
||||
if (node == null || node.type != BookmarkNodeType.folder) return null;
|
||||
return BookmarkItem.parseRecursive(node) as BookmarkFolder?;
|
||||
}
|
||||
|
||||
/// Loads a folder with every descendant.
|
||||
///
|
||||
/// Only for operations that need the whole subtree — export, flatten, "open
|
||||
/// all in folder". Never for rendering a list.
|
||||
Future<BookmarkFolder?> getFolderTree(String guid) async {
|
||||
final node = await _service.getTree(guid, recursive: true);
|
||||
if (node == null || node.type != BookmarkNodeType.folder) return null;
|
||||
return BookmarkItem.parseRecursive(node) as BookmarkFolder?;
|
||||
}
|
||||
|
||||
/// Number of bookmark items inside the trees rooted at [guids].
|
||||
///
|
||||
/// Counted in storage, so this stays cheap on a large library.
|
||||
Future<int> countBookmarksInTrees(Iterable<String> guids) {
|
||||
if (guids.isEmpty) return Future.value(0);
|
||||
return _service.countBookmarksInTrees(guids.toList());
|
||||
}
|
||||
|
||||
/// Guids of every bookmark entry pointing at [url].
|
||||
///
|
||||
/// Answers "is this page bookmarked?" through a storage lookup instead of
|
||||
/// scanning an in-memory tree.
|
||||
Future<List<String>> bookmarkGuidsForUrl(Uri url) async {
|
||||
final nodes = await _service.getBookmarksWithUrl(url);
|
||||
return nodes.map((node) => node.guid).toList();
|
||||
}
|
||||
|
||||
/// Returns the GUIDs of all descendant folders of [guid] by fetching the
|
||||
@@ -163,18 +202,44 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
|
||||
Future<void> eraseEverything(BookmarkRoot root) async {
|
||||
await _service.eraseEverything(root);
|
||||
ref.invalidateSelf();
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
Future<int> importFromJSON(String jsonString, {bool replace = false}) async {
|
||||
final count = await _jsonUtils.importFromJSON(jsonString, replace: replace);
|
||||
ref.invalidateSelf();
|
||||
_notifyChanged();
|
||||
return count;
|
||||
}
|
||||
|
||||
Future<int> importFromHTML(String htmlString, {bool replace = false}) async {
|
||||
final count = await _htmlUtils.importFromHTML(htmlString, replace: replace);
|
||||
ref.invalidateSelf();
|
||||
_notifyChanged();
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Imports the bookmark file at [path], parsing it in a background isolate.
|
||||
///
|
||||
/// Preferred over [importFromHTML]/[importFromJSON] for user-initiated
|
||||
/// imports: neither the raw file nor the intermediate parse tree ever touches
|
||||
/// the UI isolate.
|
||||
Future<int> importFromFile({
|
||||
required String path,
|
||||
required BookmarkImportFormat format,
|
||||
bool replace = false,
|
||||
}) async {
|
||||
final tree = await parseBookmarkFile(
|
||||
path: path,
|
||||
format: format,
|
||||
// Replacing the library is the only case where a file's own root folders
|
||||
// should take over the corresponding Places roots.
|
||||
preserveRootFolders: replace,
|
||||
);
|
||||
|
||||
final count = await BookmarkTreeImporter(
|
||||
_service,
|
||||
).import(tree, replace: replace);
|
||||
|
||||
_notifyChanged();
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -188,9 +253,16 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
return await _htmlUtils.exportToHTML(root: root);
|
||||
}
|
||||
|
||||
/// A revision that advances whenever bookmarks change.
|
||||
///
|
||||
/// The repository deliberately holds no bookmark data. It used to build the
|
||||
/// entire tree recursively from [BookmarkRoot.root], which meant every
|
||||
/// consumer paid for the whole library — the reason opening bookmarks after
|
||||
/// a large import or sync could bring the app down. Data now comes from the
|
||||
/// folder-scoped providers, which watch this value to know when to reload.
|
||||
@override
|
||||
Future<BookmarkItem?> build() async {
|
||||
final node = await _service.getTree(BookmarkRoot.root.id, recursive: true);
|
||||
return node.mapNotNull(BookmarkItem.parseRecursive);
|
||||
}
|
||||
int build() => 0;
|
||||
|
||||
/// Signals that stored bookmarks changed, prompting dependents to reload.
|
||||
void _notifyChanged() => state++;
|
||||
}
|
||||
|
||||
+15
-7
@@ -13,7 +13,7 @@ part of 'bookmarks.dart';
|
||||
final bookmarksRepositoryProvider = BookmarksRepositoryProvider._();
|
||||
|
||||
final class BookmarksRepositoryProvider
|
||||
extends $AsyncNotifierProvider<BookmarksRepository, BookmarkItem?> {
|
||||
extends $NotifierProvider<BookmarksRepository, int> {
|
||||
BookmarksRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
@@ -31,22 +31,30 @@ final class BookmarksRepositoryProvider
|
||||
@$internal
|
||||
@override
|
||||
BookmarksRepository create() => BookmarksRepository();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(int value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<int>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$bookmarksRepositoryHash() =>
|
||||
r'2169d5b354c4a22192096451c96ab1490cf55ab4';
|
||||
r'bf95d30f21773e931b12fd85d03d072d88d5b715';
|
||||
|
||||
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
|
||||
FutureOr<BookmarkItem?> build();
|
||||
abstract class _$BookmarksRepository extends $Notifier<int> {
|
||||
int build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
WhenComplete runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<BookmarkItem?>, BookmarkItem?>;
|
||||
final ref = this.ref as $Ref<int, int>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<BookmarkItem?>, BookmarkItem?>,
|
||||
AsyncValue<BookmarkItem?>,
|
||||
AnyNotifier<int, int>,
|
||||
int,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
|
||||
+72
-113
@@ -21,142 +21,101 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
|
||||
|
||||
/// Recursively sorts a bookmark tree by the given sort type.
|
||||
/// Root-level built-in folders are kept in their canonical order.
|
||||
BookmarkItem sortBookmarkTree(
|
||||
BookmarkItem item,
|
||||
/// Sorts one folder's direct children by the given sort type.
|
||||
///
|
||||
/// Only the level being displayed is sorted; descendants are not loaded, so
|
||||
/// there is nothing deeper to order. Root-level built-in folders are kept
|
||||
/// pinned in their canonical order ahead of everything else.
|
||||
List<BookmarkItem> sortBookmarkChildren(
|
||||
List<BookmarkItem> children,
|
||||
BookmarkSortType sortType, {
|
||||
bool isRoot = false,
|
||||
}) {
|
||||
if (sortType == BookmarkSortType.manual) return item;
|
||||
if (sortType == BookmarkSortType.manual) return children;
|
||||
|
||||
if (item is BookmarkFolder && item.children != null) {
|
||||
final sortedChildren = item.children!.map((child) {
|
||||
return sortBookmarkTree(child, sortType);
|
||||
}).toList();
|
||||
|
||||
// At root level, keep built-in root folders pinned in original order
|
||||
if (isRoot) {
|
||||
final rootFolders = <BookmarkItem>[];
|
||||
final nonRootItems = <BookmarkItem>[];
|
||||
for (final child in sortedChildren) {
|
||||
if (bookmarkRootIds.contains(child.guid)) {
|
||||
rootFolders.add(child);
|
||||
} else {
|
||||
nonRootItems.add(child);
|
||||
}
|
||||
}
|
||||
nonRootItems.sort((a, b) => compareBookmarkItems(a, b, sortType));
|
||||
return BookmarkFolder(
|
||||
guid: item.guid,
|
||||
parentGuid: item.parentGuid,
|
||||
title: item.title,
|
||||
position: item.position,
|
||||
dateAdded: item.dateAdded,
|
||||
children: [...rootFolders, ...nonRootItems],
|
||||
);
|
||||
}
|
||||
|
||||
sortedChildren.sort((a, b) => compareBookmarkItems(a, b, sortType));
|
||||
return BookmarkFolder(
|
||||
guid: item.guid,
|
||||
parentGuid: item.parentGuid,
|
||||
title: item.title,
|
||||
position: item.position,
|
||||
dateAdded: item.dateAdded,
|
||||
children: sortedChildren,
|
||||
);
|
||||
if (!isRoot) {
|
||||
return [...children]..sort((a, b) => compareBookmarkItems(a, b, sortType));
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/// Collects all descendant folder GUIDs from a folder (not including the folder itself).
|
||||
Set<String> collectDescendantFolderGuids(BookmarkFolder folder) {
|
||||
final result = <String>{};
|
||||
if (folder.children != null) {
|
||||
for (final child in folder.children!) {
|
||||
if (child is BookmarkFolder) {
|
||||
result.add(child.guid);
|
||||
result.addAll(collectDescendantFolderGuids(child));
|
||||
}
|
||||
final rootFolders = <BookmarkItem>[];
|
||||
final rest = <BookmarkItem>[];
|
||||
for (final child in children) {
|
||||
if (bookmarkRootIds.contains(child.guid)) {
|
||||
rootFolders.add(child);
|
||||
} else {
|
||||
rest.add(child);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
rest.sort((a, b) => compareBookmarkItems(a, b, sortType));
|
||||
|
||||
return [...rootFolders, ...rest];
|
||||
}
|
||||
|
||||
/// Resolves BookmarkItems from a tree by their GUIDs.
|
||||
List<BookmarkItem> resolveSelectedItems(BookmarkItem root, Set<String> guids) {
|
||||
final result = <BookmarkItem>[];
|
||||
_collectByGuids(root, guids, result);
|
||||
return result;
|
||||
/// One rendered line of the bookmark list: an item and how deep it sits.
|
||||
///
|
||||
/// The list flattens the expanded folders into rows rather than nesting
|
||||
/// widgets, so it can stay a `ListView.builder` and only build what is on
|
||||
/// screen — a folder with thousands of entries costs the same to expand as a
|
||||
/// small one.
|
||||
class BookmarkRow {
|
||||
final BookmarkItem item;
|
||||
final int depth;
|
||||
|
||||
/// True when this row only stands in for [item]'s children while they load.
|
||||
///
|
||||
/// A placeholder repeats the folder it belongs to, so it must never be
|
||||
/// treated as a second occurrence of that folder — acting on it would apply
|
||||
/// the same operation twice.
|
||||
final bool isPlaceholder;
|
||||
|
||||
const BookmarkRow(this.item, this.depth, {this.isPlaceholder = false});
|
||||
}
|
||||
|
||||
void _collectByGuids(
|
||||
BookmarkItem item,
|
||||
/// Resolves the items of [children] whose GUIDs are in [guids].
|
||||
///
|
||||
/// The caller passes whatever is currently displayed, which may be one folder's
|
||||
/// children, several expanded levels, or search hits from all over the library.
|
||||
List<BookmarkItem> resolveSelectedItems(
|
||||
List<BookmarkItem> children,
|
||||
Set<String> guids,
|
||||
List<BookmarkItem> result,
|
||||
) {
|
||||
if (guids.contains(item.guid)) {
|
||||
result.add(item);
|
||||
}
|
||||
if (item is BookmarkFolder && item.children != null) {
|
||||
for (final child in item.children!) {
|
||||
_collectByGuids(child, guids, result);
|
||||
}
|
||||
}
|
||||
return children.where((child) => guids.contains(child.guid)).toList();
|
||||
}
|
||||
|
||||
/// Whether a folder can be flattened (non-root, has a parent, has children).
|
||||
bool canFlattenFolder(BookmarkFolder folder) {
|
||||
return folder.parentGuid != null &&
|
||||
!bookmarkRootIds.contains(folder.guid) &&
|
||||
folder.children != null &&
|
||||
folder.children!.isNotEmpty;
|
||||
}
|
||||
/// Drops selections that sit inside another selected folder.
|
||||
///
|
||||
/// Expanding a folder makes its children selectable alongside it, and acting on
|
||||
/// both would move a child out of the very folder that just moved, or delete it
|
||||
/// twice. A folder's descendants are exactly the rows that follow it until the
|
||||
/// depth returns to its own, so one pass over [rows] is enough.
|
||||
Set<String> normalizeSelection(List<BookmarkRow> rows, Set<String> selected) {
|
||||
final result = <String>{};
|
||||
var skipBelowDepth = -1;
|
||||
|
||||
/// Normalizes a selection set: removes items that are descendants of selected folders.
|
||||
/// This prevents double-applying moves when both a folder and its children are selected.
|
||||
Set<String> normalizeSelection(BookmarkItem root, Set<String> selectedGuids) {
|
||||
final items = resolveSelectedItems(root, selectedGuids);
|
||||
final folderGuidsToRemove = <String>{};
|
||||
|
||||
for (final item in items) {
|
||||
if (item is BookmarkFolder) {
|
||||
_collectAllDescendantGuids(item, folderGuidsToRemove);
|
||||
for (final row in rows) {
|
||||
if (skipBelowDepth >= 0) {
|
||||
if (row.depth > skipBelowDepth) continue;
|
||||
skipBelowDepth = -1;
|
||||
}
|
||||
}
|
||||
|
||||
return selectedGuids.difference(folderGuidsToRemove);
|
||||
}
|
||||
if (row.isPlaceholder) continue;
|
||||
|
||||
void _collectAllDescendantGuids(BookmarkFolder folder, Set<String> result) {
|
||||
if (folder.children != null) {
|
||||
for (final child in folder.children!) {
|
||||
result.add(child.guid);
|
||||
if (child is BookmarkFolder) {
|
||||
_collectAllDescendantGuids(child, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns GUIDs of all bookmark entries matching [url] in the tree.
|
||||
List<String> bookmarkGuidsForUrl(BookmarkItem? root, Uri? url) {
|
||||
final result = <String>[];
|
||||
if (root == null || url == null) return result;
|
||||
|
||||
void collect(BookmarkItem item) {
|
||||
if (item is BookmarkEntry && item.url == url) {
|
||||
result.add(item.guid);
|
||||
}
|
||||
if (item is BookmarkFolder) {
|
||||
for (final child in item.children ?? const <BookmarkItem>[]) {
|
||||
collect(child);
|
||||
if (selected.contains(row.item.guid)) {
|
||||
result.add(row.item.guid);
|
||||
if (row.item is BookmarkFolder) {
|
||||
skipBelowDepth = row.depth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Whether a folder can be flattened (non-root and has a parent).
|
||||
///
|
||||
/// Whether it actually holds anything is left to the repository, which reads
|
||||
/// the folder's children at the moment of the operation rather than relying on
|
||||
/// what the list happens to have loaded.
|
||||
bool canFlattenFolder(BookmarkFolder folder) {
|
||||
return folder.parentGuid != null && !bookmarkRootIds.contains(folder.guid);
|
||||
}
|
||||
|
||||
+21
-4
@@ -19,16 +19,33 @@
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<bool?> showDeleteFolderDialog(BuildContext context) {
|
||||
/// Confirms deleting a folder and everything inside it.
|
||||
///
|
||||
/// Pass [bookmarkCount] to name how many bookmarks go with it. The list only
|
||||
/// shows one level at a time, so the contents of a folder are usually off
|
||||
/// screen when this is asked.
|
||||
Future<bool?> showDeleteFolderDialog(
|
||||
BuildContext context, {
|
||||
int? bookmarkCount,
|
||||
}) {
|
||||
return showDialog<bool?>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
icon: const Icon(Icons.warning),
|
||||
title: const Text('Delete Folder'),
|
||||
content: const Text(
|
||||
'Are you sure you want to delete this Folder including all bookmarks?',
|
||||
),
|
||||
content: Text(switch (bookmarkCount) {
|
||||
null =>
|
||||
'Are you sure you want to delete this Folder including '
|
||||
'all bookmarks?',
|
||||
0 => 'Are you sure you want to delete this Folder?',
|
||||
1 =>
|
||||
'Are you sure you want to delete this Folder and the '
|
||||
'1 bookmark inside it?',
|
||||
final count =>
|
||||
'Are you sure you want to delete this Folder and the '
|
||||
'$count bookmarks inside it?',
|
||||
}),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
|
||||
+340
-270
@@ -17,10 +17,9 @@
|
||||
* 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 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:animated_tree_view/animated_tree_view.dart';
|
||||
import 'package:convert/convert.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -44,6 +43,7 @@ import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dial
|
||||
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/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';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||
@@ -52,6 +52,15 @@ import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
/// How many hits an in-list search asks storage for.
|
||||
///
|
||||
/// Generous enough to be useful on a large library while staying a bounded,
|
||||
/// lazily rendered list.
|
||||
const _searchResultLimit = 200;
|
||||
|
||||
/// Indentation applied per level of folder nesting.
|
||||
const _indentPerDepth = 20.0;
|
||||
|
||||
class BookmarkListScreen extends HookConsumerWidget {
|
||||
final String entryGuid;
|
||||
|
||||
@@ -59,18 +68,15 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final treeController =
|
||||
useState<TreeViewController<BookmarkItem, TreeNode<BookmarkItem>>?>(
|
||||
null,
|
||||
);
|
||||
// Tracks which folder GUIDs are expanded, so expansion state survives
|
||||
// tree rebuilds caused by sort type changes.
|
||||
final expandedGuids = useRef(<String>{});
|
||||
final hideEmptyRoots = useState(true);
|
||||
final uiState = ref.watch(bookmarkListUiStateProvider);
|
||||
final uiStateNotifier = ref.read(bookmarkListUiStateProvider.notifier);
|
||||
final bookmarkList = ref.watch(
|
||||
seamlessBookmarksProvider(
|
||||
|
||||
// Only the folder being shown is loaded. Descendants stay in storage until
|
||||
// the user navigates into them, which is what keeps this screen openable on
|
||||
// a large library.
|
||||
final folderAsync = ref.watch(
|
||||
bookmarkListFolderProvider(
|
||||
entryGuid,
|
||||
hideEmptyRoots: hideEmptyRoots.value,
|
||||
),
|
||||
@@ -78,25 +84,46 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
|
||||
final textFilterEnabled = useState(false);
|
||||
final textFilterController = useTextEditingController();
|
||||
final searchQuery = useState('');
|
||||
|
||||
useOnListenableChange(textFilterController, () {
|
||||
if (ref.exists(
|
||||
seamlessBookmarksProvider(
|
||||
entryGuid,
|
||||
hideEmptyRoots: hideEmptyRoots.value,
|
||||
),
|
||||
)) {
|
||||
searchQuery.value = textFilterController.text;
|
||||
// Searching goes through storage rather than filtering a loaded tree, so
|
||||
// it reaches bookmarks this screen never loaded.
|
||||
unawaited(
|
||||
ref
|
||||
.read(
|
||||
seamlessBookmarksProvider(
|
||||
entryGuid,
|
||||
hideEmptyRoots: hideEmptyRoots.value,
|
||||
).notifier,
|
||||
)
|
||||
.search(textFilterController.text);
|
||||
}
|
||||
.read(bookmarkSearchResultsProvider.notifier)
|
||||
.search(textFilterController.text, limit: _searchResultLimit),
|
||||
);
|
||||
});
|
||||
|
||||
final isSearching = searchQuery.value.isNotEmpty;
|
||||
final searchResults = ref.watch(bookmarkSearchResultsProvider);
|
||||
|
||||
// Folders the user has opened. Keyed by guid so expansion survives sort and
|
||||
// filter changes.
|
||||
final expandedGuids = useState(<String>{});
|
||||
|
||||
// Storage can only match bookmarks, never folders, so "Folders Only" and a
|
||||
// search query have no overlap to show.
|
||||
final searchSuppressedByFilter = isSearching && uiState.foldersOnly;
|
||||
|
||||
// Everything currently on screen, flattened. Selection is resolved against
|
||||
// exactly these rows — search hits come from anywhere in the library, and
|
||||
// expanded folders contribute items from below the entry folder.
|
||||
final rows = isSearching
|
||||
? [
|
||||
if (!searchSuppressedByFilter)
|
||||
for (final result in searchResults) BookmarkRow(result, 0),
|
||||
]
|
||||
: _buildRows(
|
||||
ref,
|
||||
folderAsync.value,
|
||||
uiState,
|
||||
expandedGuids.value,
|
||||
depth: 0,
|
||||
);
|
||||
|
||||
return PopScope(
|
||||
canPop: !uiState.selectionMode,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
@@ -106,13 +133,18 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: uiState.selectionMode
|
||||
? _buildSelectionAppBar(context, ref, uiState, uiStateNotifier)
|
||||
? _buildSelectionAppBar(
|
||||
context,
|
||||
ref,
|
||||
uiState,
|
||||
uiStateNotifier,
|
||||
rows,
|
||||
)
|
||||
: _buildNormalAppBar(
|
||||
context,
|
||||
ref,
|
||||
treeController,
|
||||
expandedGuids,
|
||||
hideEmptyRoots,
|
||||
expandedGuids,
|
||||
textFilterEnabled,
|
||||
textFilterController,
|
||||
uiStateNotifier,
|
||||
@@ -121,117 +153,173 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0),
|
||||
child: bookmarkList.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (list) {
|
||||
final sortedList = list != null
|
||||
? sortBookmarkTree(
|
||||
list,
|
||||
uiState.sortType,
|
||||
isRoot: entryGuid == BookmarkRoot.root.id,
|
||||
)
|
||||
: null;
|
||||
|
||||
TreeNode<BookmarkItem> addChildren(
|
||||
TreeNode<BookmarkItem>? parent,
|
||||
BookmarkItem item,
|
||||
) {
|
||||
if (uiState.foldersOnly && item is BookmarkEntry) {
|
||||
return parent ?? TreeNode<BookmarkItem>.root();
|
||||
}
|
||||
|
||||
final node = TreeNode(
|
||||
key: item.guid,
|
||||
data: item,
|
||||
parent: parent,
|
||||
);
|
||||
final targetNode = (parent?..add(node)) ?? node;
|
||||
|
||||
if (item is BookmarkFolder && item.children != null) {
|
||||
for (final child in item.children!) {
|
||||
addChildren(node, child);
|
||||
}
|
||||
}
|
||||
|
||||
return targetNode;
|
||||
}
|
||||
|
||||
final root = (sortedList != null)
|
||||
? addChildren(null, sortedList)
|
||||
: TreeNode<BookmarkItem>.root();
|
||||
|
||||
return TreeView.simple(
|
||||
key: ValueKey((uiState.sortType, uiState.foldersOnly)),
|
||||
tree: root,
|
||||
showRootNode: entryGuid != BookmarkRoot.root.id,
|
||||
onTreeReady: (controller) {
|
||||
treeController.value = controller;
|
||||
if (expandedGuids.value.isNotEmpty) {
|
||||
_restoreExpansion(controller, root, expandedGuids.value);
|
||||
} else {
|
||||
controller.expandAllChildren(root, recursive: true);
|
||||
}
|
||||
},
|
||||
expansionIndicatorBuilder: (context, tree) =>
|
||||
ChevronIndicator.upDown(
|
||||
tree: tree,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16.0,
|
||||
horizontal: 12.0,
|
||||
),
|
||||
child: isSearching
|
||||
? _buildRowList(
|
||||
context,
|
||||
ref,
|
||||
rows,
|
||||
uiState,
|
||||
uiStateNotifier,
|
||||
expandedGuids,
|
||||
emptyLabel: searchSuppressedByFilter
|
||||
? 'Search matches bookmarks, which "Folders Only" is '
|
||||
'hiding'
|
||||
: 'No bookmarks match "${searchQuery.value}"',
|
||||
)
|
||||
: folderAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (_) => _buildRowList(
|
||||
context,
|
||||
ref,
|
||||
rows,
|
||||
uiState,
|
||||
uiStateNotifier,
|
||||
expandedGuids,
|
||||
emptyLabel: 'Empty',
|
||||
),
|
||||
error: (error, stackTrace) => Center(
|
||||
child: FailureWidget(
|
||||
title: 'Failed to load Bookmarks',
|
||||
exception: error,
|
||||
onRetry: () {
|
||||
ref.invalidate(bookmarkFolderProvider(entryGuid));
|
||||
},
|
||||
),
|
||||
builder: (context, item) {
|
||||
final BookmarkItem? data = item.data;
|
||||
final isSelected = uiState.selectedGuids.contains(
|
||||
data?.guid,
|
||||
);
|
||||
final bool isLeaf = item.isLeaf;
|
||||
final bool isExpanded = item.isExpanded;
|
||||
|
||||
return switch (data) {
|
||||
final BookmarkEntry bookmark => _buildEntryTile(
|
||||
context,
|
||||
ref,
|
||||
bookmark,
|
||||
uiState,
|
||||
uiStateNotifier,
|
||||
isSelected,
|
||||
sortedList,
|
||||
),
|
||||
final BookmarkFolder folder => _buildFolderTile(
|
||||
context,
|
||||
ref,
|
||||
folder,
|
||||
isLeaf: isLeaf,
|
||||
isExpanded: isExpanded,
|
||||
uiState: uiState,
|
||||
uiStateNotifier: uiStateNotifier,
|
||||
isSelected: isSelected,
|
||||
rootItem: sortedList,
|
||||
),
|
||||
null => const Center(child: Text('Empty')),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) => Center(
|
||||
child: FailureWidget(
|
||||
title: 'Failed to load Bookmarks',
|
||||
exception: error,
|
||||
onRetry: () {
|
||||
// ignore: unused_result
|
||||
ref.refresh(bookmarksProvider<BookmarkItem>(entryGuid));
|
||||
},
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Flattens the entry folder and every expanded folder beneath it into rows.
|
||||
///
|
||||
/// A folder's children are only requested once it is expanded, so a collapsed
|
||||
/// subtree costs nothing — this is what keeps the screen openable after a
|
||||
/// large import or sync. Watching the child providers here means expanding
|
||||
/// starts the load and the row list rebuilds when it lands.
|
||||
List<BookmarkRow> _buildRows(
|
||||
WidgetRef ref,
|
||||
BookmarkFolder? folder,
|
||||
BookmarkListUiState uiState,
|
||||
Set<String> expandedGuids, {
|
||||
required int depth,
|
||||
}) {
|
||||
final rows = <BookmarkRow>[];
|
||||
|
||||
for (final item in _visibleChildren(folder, uiState, depth: depth)) {
|
||||
rows.add(BookmarkRow(item, depth));
|
||||
|
||||
if (item is! BookmarkFolder || !expandedGuids.contains(item.guid)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final child = ref.watch(bookmarkFolderProvider(item.guid));
|
||||
if (child.value == null) {
|
||||
rows.add(BookmarkRow(item, depth + 1, isPlaceholder: true));
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.addAll(
|
||||
_buildRows(ref, child.value, uiState, expandedGuids, depth: depth + 1),
|
||||
);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// The children of [folder] in display order, with the folders-only filter
|
||||
/// applied.
|
||||
List<BookmarkItem> _visibleChildren(
|
||||
BookmarkFolder? folder,
|
||||
BookmarkListUiState uiState, {
|
||||
required int depth,
|
||||
}) {
|
||||
var children = folder?.children ?? const <BookmarkItem>[];
|
||||
|
||||
if (uiState.foldersOnly) {
|
||||
children = children.whereType<BookmarkFolder>().toList();
|
||||
}
|
||||
|
||||
return sortBookmarkChildren(
|
||||
children,
|
||||
uiState.sortType,
|
||||
// Only the entry level can be the tree root, and only there do the
|
||||
// built-in folders need pinning.
|
||||
isRoot: depth == 0 && entryGuid == BookmarkRoot.root.id,
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders the flattened rows lazily.
|
||||
///
|
||||
/// [ListView.builder] only builds the tiles actually on screen, so expanding
|
||||
/// a folder with thousands of entries stays as cheap as a small one.
|
||||
Widget _buildRowList(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<BookmarkRow> rows,
|
||||
BookmarkListUiState uiState,
|
||||
BookmarkListUiStateNotifier uiStateNotifier,
|
||||
ValueNotifier<Set<String>> expandedGuids, {
|
||||
required String emptyLabel,
|
||||
}) {
|
||||
if (rows.isEmpty) {
|
||||
return Center(child: Text(emptyLabel));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final row = rows[index];
|
||||
final item = row.item;
|
||||
final isSelected = uiState.selectedGuids.contains(item.guid);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: row.depth * _indentPerDepth),
|
||||
child: row.isPlaceholder
|
||||
? const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: SizedBox(
|
||||
height: 16.0,
|
||||
width: 16.0,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.0),
|
||||
),
|
||||
),
|
||||
)
|
||||
: switch (item) {
|
||||
final BookmarkEntry bookmark => _buildEntryTile(
|
||||
context,
|
||||
ref,
|
||||
bookmark,
|
||||
uiState,
|
||||
uiStateNotifier,
|
||||
isSelected,
|
||||
),
|
||||
final BookmarkFolder folder => _buildFolderTile(
|
||||
context,
|
||||
ref,
|
||||
folder,
|
||||
uiState: uiState,
|
||||
uiStateNotifier: uiStateNotifier,
|
||||
isSelected: isSelected,
|
||||
isExpanded: expandedGuids.value.contains(folder.guid),
|
||||
onToggleExpanded: () {
|
||||
final next = {...expandedGuids.value};
|
||||
if (!next.remove(folder.guid)) next.add(folder.guid);
|
||||
expandedGuids.value = next;
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -- App Bars --
|
||||
|
||||
PreferredSizeWidget _buildSelectionAppBar(
|
||||
@@ -239,6 +327,7 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
WidgetRef ref,
|
||||
BookmarkListUiState uiState,
|
||||
BookmarkListUiStateNotifier uiStateNotifier,
|
||||
List<BookmarkRow> rows,
|
||||
) {
|
||||
final count = uiState.selectedGuids.length;
|
||||
return AppBar(
|
||||
@@ -252,19 +341,21 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
icon: const Icon(MdiIcons.tabPlus),
|
||||
tooltip: 'Open in background',
|
||||
onPressed: count > 0
|
||||
? () => _bulkOpenInBackground(context, ref, uiState)
|
||||
? () => _bulkOpenInBackground(context, ref, uiState, rows)
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(MdiIcons.folderMove),
|
||||
tooltip: 'Move selected',
|
||||
onPressed: count > 0 ? () => _bulkMove(context, ref, uiState) : null,
|
||||
onPressed: count > 0
|
||||
? () => _bulkMove(context, ref, uiState, rows)
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(MdiIcons.delete),
|
||||
tooltip: 'Delete selected',
|
||||
onPressed: count > 0
|
||||
? () => _bulkDelete(context, ref, uiState, uiStateNotifier)
|
||||
? () => _bulkDelete(context, ref, uiState, uiStateNotifier, rows)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
@@ -274,10 +365,8 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
AppBar _buildNormalAppBar(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ValueNotifier<TreeViewController<BookmarkItem, TreeNode<BookmarkItem>>?>
|
||||
treeController,
|
||||
ObjectRef<Set<String>> expandedGuids,
|
||||
ValueNotifier<bool> hideEmptyRoots,
|
||||
ValueNotifier<Set<String>> expandedGuids,
|
||||
ValueNotifier<bool> textFilterEnabled,
|
||||
TextEditingController textFilterController,
|
||||
BookmarkListUiStateNotifier uiStateNotifier,
|
||||
@@ -315,21 +404,41 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
),
|
||||
MenuAnchor(
|
||||
menuChildren: [
|
||||
// Adding is otherwise only reachable from a child folder's row
|
||||
// menu, which leaves an empty folder with no way to fill it.
|
||||
// BookmarkRoot.root holds only the built-in folders, so it takes
|
||||
// no children of its own.
|
||||
if (entryGuid != BookmarkRoot.root.id) ...[
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.bookmarkPlus),
|
||||
child: const Text('Add Bookmark Here'),
|
||||
onPressed: () async {
|
||||
await BookmarkEntryAddRoute(
|
||||
bookmarkInfo: jsonEncode(
|
||||
BookmarkInfo(parentGuid: entryGuid).encode(),
|
||||
),
|
||||
).push(context);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.folderPlus),
|
||||
child: const Text('Add Subfolder Here'),
|
||||
onPressed: () async {
|
||||
await BookmarkFolderAddRoute(
|
||||
parentGuid: entryGuid,
|
||||
).push(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
SubmenuButton(
|
||||
leadingIcon: const Icon(MdiIcons.eye),
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.expandAll),
|
||||
child: const Text('Expand All'),
|
||||
onPressed: () {
|
||||
final controller = treeController.value;
|
||||
if (controller != null) {
|
||||
controller.expandAllChildren(
|
||||
controller.tree,
|
||||
recursive: true,
|
||||
);
|
||||
}
|
||||
},
|
||||
leadingIcon: const Icon(MdiIcons.collapseAll),
|
||||
onPressed: expandedGuids.value.isEmpty
|
||||
? null
|
||||
: () => expandedGuids.value = <String>{},
|
||||
child: const Text('Collapse All'),
|
||||
),
|
||||
if (entryGuid == BookmarkRoot.root.id)
|
||||
MenuItemButton(
|
||||
@@ -353,13 +462,10 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
? MdiIcons.bookmarkMultiple
|
||||
: MdiIcons.folderOutline,
|
||||
),
|
||||
onPressed: uiStateNotifier.toggleFoldersOnly,
|
||||
child: Text(
|
||||
uiState.foldersOnly ? 'Show Bookmarks' : 'Folders Only',
|
||||
),
|
||||
onPressed: () {
|
||||
_snapshotExpansion(treeController.value, expandedGuids);
|
||||
uiStateNotifier.toggleFoldersOnly();
|
||||
},
|
||||
),
|
||||
],
|
||||
child: const Text('Visibility'),
|
||||
@@ -373,10 +479,7 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
? const Icon(Icons.check)
|
||||
: const SizedBox(width: 24),
|
||||
child: Text(sortType.label),
|
||||
onPressed: () {
|
||||
_snapshotExpansion(treeController.value, expandedGuids);
|
||||
uiStateNotifier.setSortType(sortType);
|
||||
},
|
||||
onPressed: () => uiStateNotifier.setSortType(sortType),
|
||||
),
|
||||
],
|
||||
child: const Text('Sort'),
|
||||
@@ -387,12 +490,14 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.codeJson),
|
||||
child: const Text('JSON'),
|
||||
onPressed: () => _handleImport(context, ref, 'json'),
|
||||
onPressed: () =>
|
||||
_handleImport(context, ref, BookmarkImportFormat.json),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.xml),
|
||||
child: const Text('HTML'),
|
||||
onPressed: () => _handleImport(context, ref, 'html'),
|
||||
onPressed: () =>
|
||||
_handleImport(context, ref, BookmarkImportFormat.html),
|
||||
),
|
||||
],
|
||||
child: const Text('Import'),
|
||||
@@ -438,7 +543,6 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
BookmarkListUiState uiState,
|
||||
BookmarkListUiStateNotifier uiStateNotifier,
|
||||
bool isSelected,
|
||||
BookmarkItem? rootItem,
|
||||
) {
|
||||
if (uiState.selectionMode) {
|
||||
return ListTile(
|
||||
@@ -463,7 +567,7 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
key: ValueKey(bookmark.guid),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: UrlIcon([bookmark.url], iconSize: 34.0),
|
||||
trailing: _buildEntryMenu(context, ref, bookmark, rootItem),
|
||||
trailing: _buildEntryMenu(context, ref, bookmark),
|
||||
title: Text(bookmark.title, maxLines: 3, overflow: TextOverflow.ellipsis),
|
||||
subtitle: UriBreadcrumb(uri: bookmark.url),
|
||||
onTap: () async {
|
||||
@@ -487,7 +591,6 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
BookmarkEntry bookmark,
|
||||
BookmarkItem? rootItem,
|
||||
) {
|
||||
return HookBuilder(
|
||||
builder: (context) {
|
||||
@@ -605,26 +708,21 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
BookmarkFolder folder, {
|
||||
required bool isLeaf,
|
||||
required bool isExpanded,
|
||||
required BookmarkListUiState uiState,
|
||||
required BookmarkListUiStateNotifier uiStateNotifier,
|
||||
required bool isSelected,
|
||||
required BookmarkItem? rootItem,
|
||||
required bool isExpanded,
|
||||
required VoidCallback onToggleExpanded,
|
||||
}) {
|
||||
final isRoot = bookmarkRootIds.contains(folder.guid);
|
||||
|
||||
if (uiState.selectionMode) {
|
||||
return Padding(
|
||||
key: ValueKey(folder.guid),
|
||||
padding: isLeaf
|
||||
? const EdgeInsets.only(right: 4.0)
|
||||
: const EdgeInsets.only(right: 42.0),
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: isExpanded
|
||||
? const Icon(MdiIcons.folderOpen)
|
||||
: const Icon(MdiIcons.folder),
|
||||
leading: Icon(isExpanded ? MdiIcons.folderOpen : MdiIcons.folder),
|
||||
title: Text(folder.title),
|
||||
trailing: isRoot
|
||||
? null
|
||||
@@ -642,18 +740,21 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
|
||||
return Padding(
|
||||
key: ValueKey(folder.guid),
|
||||
padding: isLeaf
|
||||
? const EdgeInsets.only(right: 4.0)
|
||||
: const EdgeInsets.only(right: 42.0),
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
child: HookBuilder(
|
||||
builder: (context) {
|
||||
final controller = useMenuController();
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: isExpanded
|
||||
? const Icon(MdiIcons.folderOpen)
|
||||
: const Icon(MdiIcons.folder),
|
||||
// Whether a folder has children is unknown until it is opened, so
|
||||
// every folder offers the toggle. Tapping the row still navigates
|
||||
// into it, as it did before.
|
||||
leading: IconButton(
|
||||
icon: Icon(isExpanded ? MdiIcons.folderOpen : MdiIcons.folder),
|
||||
tooltip: isExpanded ? 'Collapse' : 'Expand',
|
||||
onPressed: onToggleExpanded,
|
||||
),
|
||||
title: Text(folder.title),
|
||||
trailing: MenuAnchor(
|
||||
controller: controller,
|
||||
@@ -777,15 +878,14 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
BookmarkListUiState uiState,
|
||||
List<BookmarkRow> rows,
|
||||
) async {
|
||||
final bookmarkData = ref.read(
|
||||
seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true),
|
||||
);
|
||||
final root = bookmarkData.value;
|
||||
if (root == null) return;
|
||||
|
||||
final items = resolveSelectedItems(root, uiState.selectedGuids);
|
||||
final entries = items.whereType<BookmarkEntry>().toList();
|
||||
// Not normalised: opening is additive, so a bookmark selected inside an
|
||||
// also-selected folder should still open rather than be dropped.
|
||||
final entries = resolveSelectedItems([
|
||||
for (final row in rows)
|
||||
if (!row.isPlaceholder) row.item,
|
||||
], uiState.selectedGuids).whereType<BookmarkEntry>().toList();
|
||||
|
||||
if (entries.isEmpty) {
|
||||
if (context.mounted) {
|
||||
@@ -820,18 +920,14 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
BookmarkListUiState uiState,
|
||||
List<BookmarkRow> rows,
|
||||
) async {
|
||||
final bookmarkData = ref.read(
|
||||
seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true),
|
||||
);
|
||||
final root = bookmarkData.value;
|
||||
if (root == null) return;
|
||||
final items = _selectedItems(rows, uiState.selectedGuids);
|
||||
if (items.isEmpty) return;
|
||||
|
||||
final items = resolveSelectedItems(root, uiState.selectedGuids);
|
||||
|
||||
// Build exclusion set from selected folders and their full descendant
|
||||
// trees fetched from storage, so hidden folders (e.g. filtered by search)
|
||||
// are still properly excluded as move targets.
|
||||
// A folder cannot be moved inside itself, so exclude each selected folder
|
||||
// and its descendants. Fetched from storage rather than read off the list,
|
||||
// which only knows the level currently on screen.
|
||||
final repo = ref.read(bookmarksRepositoryProvider.notifier);
|
||||
final excludeGuids = <String>{};
|
||||
for (final item in items) {
|
||||
@@ -850,18 +946,12 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
|
||||
if (targetGuid == null) return;
|
||||
|
||||
// Normalize selection to avoid double-moves
|
||||
final normalizedGuids = normalizeSelection(root, uiState.selectedGuids);
|
||||
final normalizedItems = resolveSelectedItems(root, normalizedGuids);
|
||||
|
||||
await ref
|
||||
.read(bookmarksRepositoryProvider.notifier)
|
||||
.moveMany(items: normalizedItems, targetParentGuid: targetGuid);
|
||||
await repo.moveMany(items: items, targetParentGuid: targetGuid);
|
||||
|
||||
ref.read(bookmarkListUiStateProvider.notifier).exitSelectionMode();
|
||||
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'Moved ${normalizedItems.length} items');
|
||||
showInfoMessage(context, 'Moved ${items.length} items');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -870,81 +960,56 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
WidgetRef ref,
|
||||
BookmarkListUiState uiState,
|
||||
BookmarkListUiStateNotifier uiStateNotifier,
|
||||
List<BookmarkRow> rows,
|
||||
) async {
|
||||
final bookmarkData = ref.read(
|
||||
seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true),
|
||||
);
|
||||
final root = bookmarkData.value;
|
||||
if (root == null) return;
|
||||
final items = _selectedItems(rows, uiState.selectedGuids);
|
||||
if (items.isEmpty) return;
|
||||
|
||||
final items = resolveSelectedItems(root, uiState.selectedGuids);
|
||||
final hasFolders = items.any((item) => item is BookmarkFolder);
|
||||
final folderGuids = items
|
||||
.whereType<BookmarkFolder>()
|
||||
.map((folder) => folder.guid)
|
||||
.toList();
|
||||
|
||||
// Deleting a folder takes everything under it, which the user cannot see
|
||||
// from here — so say how much before asking.
|
||||
final nestedCount = folderGuids.isEmpty
|
||||
? 0
|
||||
: await ref
|
||||
.read(bookmarksRepositoryProvider.notifier)
|
||||
.countBookmarksInTrees(folderGuids);
|
||||
|
||||
if (!context.mounted) return;
|
||||
final result = await (hasFolders
|
||||
? showDeleteFolderDialog(context)
|
||||
final result = await (folderGuids.isNotEmpty
|
||||
? showDeleteFolderDialog(context, bookmarkCount: nestedCount)
|
||||
: showDeleteBookmarkDialog(context));
|
||||
if (result != true) return;
|
||||
|
||||
// Normalize to avoid deleting children whose parent folder is also being deleted
|
||||
final normalizedGuids = normalizeSelection(root, uiState.selectedGuids);
|
||||
|
||||
await ref
|
||||
.read(bookmarksRepositoryProvider.notifier)
|
||||
.deleteMany(normalizedGuids);
|
||||
final guids = items.map((item) => item.guid).toSet();
|
||||
await ref.read(bookmarksRepositoryProvider.notifier).deleteMany(guids);
|
||||
|
||||
uiStateNotifier.exitSelectionMode();
|
||||
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'Deleted ${normalizedGuids.length} items');
|
||||
showInfoMessage(context, 'Deleted ${guids.length} items');
|
||||
}
|
||||
}
|
||||
|
||||
// -- Tree Expansion State Helpers --
|
||||
|
||||
/// Collects the GUIDs of all currently expanded nodes from the tree.
|
||||
void _snapshotExpansion(
|
||||
TreeViewController<BookmarkItem, TreeNode<BookmarkItem>>? controller,
|
||||
ObjectRef<Set<String>> expandedGuids,
|
||||
/// The selected items, with anything nested inside another selected folder
|
||||
/// dropped.
|
||||
///
|
||||
/// Expanding a folder puts its children on screen next to it, so a user can
|
||||
/// select both — acting on each in turn would move a child out of the folder
|
||||
/// that just moved, or delete it a second time.
|
||||
List<BookmarkItem> _selectedItems(
|
||||
List<BookmarkRow> rows,
|
||||
Set<String> selectedGuids,
|
||||
) {
|
||||
if (controller == null) return;
|
||||
final guids = <String>{};
|
||||
_collectExpandedGuids(controller.tree, guids);
|
||||
expandedGuids.value = guids;
|
||||
}
|
||||
final guids = normalizeSelection(rows, selectedGuids);
|
||||
|
||||
void _collectExpandedGuids(TreeNode<BookmarkItem> node, Set<String> guids) {
|
||||
if (node.isExpanded && node.key != INode.ROOT_KEY) {
|
||||
guids.add(node.key);
|
||||
}
|
||||
for (final child in node.childrenAsList) {
|
||||
_collectExpandedGuids(child as TreeNode<BookmarkItem>, guids);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores expansion state by expanding nodes whose GUIDs are in the set.
|
||||
void _restoreExpansion(
|
||||
TreeViewController<BookmarkItem, TreeNode<BookmarkItem>> controller,
|
||||
TreeNode<BookmarkItem> root,
|
||||
Set<String> guids,
|
||||
) {
|
||||
// Always expand root
|
||||
controller.expandNode(root);
|
||||
_expandMatchingNodes(controller, root, guids);
|
||||
}
|
||||
|
||||
void _expandMatchingNodes(
|
||||
TreeViewController<BookmarkItem, TreeNode<BookmarkItem>> controller,
|
||||
TreeNode<BookmarkItem> node,
|
||||
Set<String> guids,
|
||||
) {
|
||||
for (final child in node.childrenAsList) {
|
||||
final typedChild = child as TreeNode<BookmarkItem>;
|
||||
if (guids.contains(typedChild.key)) {
|
||||
controller.expandNode(typedChild);
|
||||
}
|
||||
_expandMatchingNodes(controller, typedChild, guids);
|
||||
}
|
||||
return resolveSelectedItems([
|
||||
for (final row in rows)
|
||||
if (!row.isPlaceholder) row.item,
|
||||
], guids);
|
||||
}
|
||||
|
||||
// -- Tab Opening Helper --
|
||||
@@ -995,12 +1060,14 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
Future<void> _handleImport(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String format,
|
||||
BookmarkImportFormat format,
|
||||
) async {
|
||||
try {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: format == 'json' ? ['json'] : ['html', 'htm'],
|
||||
allowedExtensions: format == BookmarkImportFormat.json
|
||||
? ['json']
|
||||
: ['html', 'htm'],
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
@@ -1019,12 +1086,15 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
final shouldReplace = await showImportBookmarksDialog(context);
|
||||
if (shouldReplace == null) return; // User cancelled dialog
|
||||
|
||||
final content = await File(file.path!).readAsString();
|
||||
final repository = ref.read(bookmarksRepositoryProvider.notifier);
|
||||
|
||||
final count = format == 'json'
|
||||
? await repository.importFromJSON(content, replace: shouldReplace)
|
||||
: await repository.importFromHTML(content, replace: shouldReplace);
|
||||
// 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) {
|
||||
showInfoMessage(context, 'Imported $count bookmarks successfully');
|
||||
|
||||
+164
-87
@@ -17,7 +17,6 @@
|
||||
* 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:animated_tree_view/animated_tree_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
@@ -28,11 +27,19 @@ import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/b
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
|
||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
|
||||
/// Indentation applied per level of folder nesting.
|
||||
const _indentPerDepth = 20.0;
|
||||
|
||||
/// A widget that displays a tree view of bookmark folders and allows the user
|
||||
/// to select a parent folder.
|
||||
///
|
||||
/// Folders are loaded one level at a time as the user expands them, so opening
|
||||
/// the picker never pulls in the whole bookmark tree.
|
||||
///
|
||||
/// When editing a folder, pass [excludeFolderGuids] to prevent selecting the
|
||||
/// folders or their descendants as the parent (which would create a circular reference).
|
||||
/// folders or their descendants as the parent (which would create a circular
|
||||
/// reference). Descendants are excluded implicitly: an excluded folder is never
|
||||
/// rendered, so nothing underneath it can be reached or expanded.
|
||||
class FolderTreePicker extends HookConsumerWidget {
|
||||
/// The currently selected folder GUID
|
||||
final ValueNotifier<String> selectedFolderGuid;
|
||||
@@ -52,99 +59,39 @@ class FolderTreePicker extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final treeKey = useMemoized(() => GlobalKey<TreeViewState>());
|
||||
// Folders start collapsed apart from the entry point, so the picker opens
|
||||
// after a single shallow load.
|
||||
final expandedGuids = useState(<String>{entryGuid});
|
||||
|
||||
final folderList = ref.watch(bookmarksProvider<BookmarkFolder>(entryGuid));
|
||||
final rootFolder = ref.watch(bookmarkFolderProvider(entryGuid));
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Folder', style: Theme.of(context).textTheme.labelMedium),
|
||||
folderList.when(
|
||||
rootFolder.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (list) {
|
||||
TreeNode<BookmarkFolder> addChildren(
|
||||
TreeNode<BookmarkFolder>? parent,
|
||||
BookmarkFolder item,
|
||||
) {
|
||||
final node = TreeNode(key: item.guid, data: item, parent: parent);
|
||||
final targetNode = (parent?..add(node)) ?? node;
|
||||
data: (folder) {
|
||||
if (folder == null) return const SizedBox.shrink();
|
||||
|
||||
if (item.children != null) {
|
||||
for (final child in item.children!) {
|
||||
// Skip excluded folders and their descendants
|
||||
if (child is BookmarkFolder &&
|
||||
!excludeFolderGuids.contains(child.guid)) {
|
||||
addChildren(node, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targetNode;
|
||||
}
|
||||
|
||||
final root = (list != null)
|
||||
? addChildren(null, list)
|
||||
: TreeNode<BookmarkFolder>.root();
|
||||
|
||||
return TreeView.simple(
|
||||
key: treeKey,
|
||||
tree: root,
|
||||
shrinkWrap: true,
|
||||
showRootNode: entryGuid != BookmarkRoot.root.id,
|
||||
onTreeReady: (controller) {
|
||||
controller.expandAllChildren(root, recursive: true);
|
||||
},
|
||||
expansionIndicatorBuilder: (context, tree) =>
|
||||
ChevronIndicator.upDown(
|
||||
tree: tree,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16.0,
|
||||
horizontal: 12.0,
|
||||
),
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (entryGuid != BookmarkRoot.root.id)
|
||||
_FolderRow(
|
||||
folder: folder,
|
||||
depth: 0,
|
||||
expandedGuids: expandedGuids,
|
||||
selectedFolderGuid: selectedFolderGuid,
|
||||
),
|
||||
builder: (context, item) {
|
||||
final isSelected = item.data?.guid == selectedFolderGuid.value;
|
||||
|
||||
// BookmarkRoot.root cannot be selected as a parent
|
||||
final isRootFolder = item.data?.guid == BookmarkRoot.root.id;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 42.0),
|
||||
child: switch (item.data) {
|
||||
final BookmarkFolder folder => ListTile(
|
||||
key: ValueKey(folder.guid),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
selected: isSelected,
|
||||
enabled: !isRootFolder,
|
||||
leading: (item.isExpanded)
|
||||
? const Icon(MdiIcons.folderOpen)
|
||||
: const Icon(MdiIcons.folder),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isSelected) const Icon(Icons.check),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
await BookmarkFolderAddRoute(
|
||||
parentGuid: folder.guid,
|
||||
).push(context);
|
||||
},
|
||||
icon: const Icon(MdiIcons.folderPlus),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(folder.title),
|
||||
onTap: !isRootFolder
|
||||
? () {
|
||||
selectedFolderGuid.value = folder.guid;
|
||||
}
|
||||
: null,
|
||||
),
|
||||
null => const SizedBox.shrink(),
|
||||
},
|
||||
);
|
||||
},
|
||||
_FolderChildren(
|
||||
parentGuid: folder.guid,
|
||||
depth: entryGuid != BookmarkRoot.root.id ? 1 : 0,
|
||||
expandedGuids: expandedGuids,
|
||||
selectedFolderGuid: selectedFolderGuid,
|
||||
excludeFolderGuids: excludeFolderGuids,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) => Center(
|
||||
@@ -152,7 +99,7 @@ class FolderTreePicker extends HookConsumerWidget {
|
||||
title: 'Failed to load Bookmark Folders',
|
||||
exception: error,
|
||||
onRetry: () {
|
||||
ref.invalidate(bookmarksProvider<BookmarkFolder>(entryGuid));
|
||||
ref.invalidate(bookmarkFolderProvider(entryGuid));
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -162,3 +109,133 @@ class FolderTreePicker extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The direct subfolders of [parentGuid], rendered only while its parent is
|
||||
/// expanded.
|
||||
///
|
||||
/// Mounting this widget is what triggers the load, so a collapsed folder costs
|
||||
/// nothing.
|
||||
class _FolderChildren extends HookConsumerWidget {
|
||||
final String parentGuid;
|
||||
final int depth;
|
||||
final ValueNotifier<Set<String>> expandedGuids;
|
||||
final ValueNotifier<String> selectedFolderGuid;
|
||||
final Set<String> excludeFolderGuids;
|
||||
|
||||
const _FolderChildren({
|
||||
required this.parentGuid,
|
||||
required this.depth,
|
||||
required this.expandedGuids,
|
||||
required this.selectedFolderGuid,
|
||||
required this.excludeFolderGuids,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final folder = ref.watch(bookmarkFolderProvider(parentGuid));
|
||||
|
||||
final subfolders = (folder.value?.children ?? const <BookmarkItem>[])
|
||||
.whereType<BookmarkFolder>()
|
||||
.where((child) => !excludeFolderGuids.contains(child.guid))
|
||||
.toList();
|
||||
|
||||
if (folder.isLoading && folder.value == null) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: depth * _indentPerDepth, top: 8.0),
|
||||
child: const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
height: 16.0,
|
||||
width: 16.0,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.0),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final subfolder in subfolders) ...[
|
||||
_FolderRow(
|
||||
folder: subfolder,
|
||||
depth: depth,
|
||||
expandedGuids: expandedGuids,
|
||||
selectedFolderGuid: selectedFolderGuid,
|
||||
),
|
||||
if (expandedGuids.value.contains(subfolder.guid))
|
||||
_FolderChildren(
|
||||
parentGuid: subfolder.guid,
|
||||
depth: depth + 1,
|
||||
expandedGuids: expandedGuids,
|
||||
selectedFolderGuid: selectedFolderGuid,
|
||||
excludeFolderGuids: excludeFolderGuids,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FolderRow extends HookConsumerWidget {
|
||||
final BookmarkFolder folder;
|
||||
final int depth;
|
||||
final ValueNotifier<Set<String>> expandedGuids;
|
||||
final ValueNotifier<String> selectedFolderGuid;
|
||||
|
||||
const _FolderRow({
|
||||
required this.folder,
|
||||
required this.depth,
|
||||
required this.expandedGuids,
|
||||
required this.selectedFolderGuid,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isExpanded = expandedGuids.value.contains(folder.guid);
|
||||
final isSelected = folder.guid == selectedFolderGuid.value;
|
||||
|
||||
// BookmarkRoot.root cannot be selected as a parent
|
||||
final isRootFolder = folder.guid == BookmarkRoot.root.id;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: depth * _indentPerDepth, right: 42.0),
|
||||
child: ListTile(
|
||||
key: ValueKey(folder.guid),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
selected: isSelected,
|
||||
enabled: !isRootFolder,
|
||||
// Whether a folder has subfolders is unknown until it is opened, so
|
||||
// every folder offers the toggle.
|
||||
leading: IconButton(
|
||||
icon: Icon(isExpanded ? MdiIcons.folderOpen : MdiIcons.folder),
|
||||
onPressed: () {
|
||||
expandedGuids.value = isExpanded
|
||||
? ({...expandedGuids.value}..remove(folder.guid))
|
||||
: ({...expandedGuids.value}..add(folder.guid));
|
||||
},
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isSelected) const Icon(Icons.check),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
await BookmarkFolderAddRoute(
|
||||
parentGuid: folder.guid,
|
||||
).push(context);
|
||||
},
|
||||
icon: const Icon(MdiIcons.folderPlus),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(folder.title),
|
||||
onTap: !isRootFolder
|
||||
? () {
|
||||
selectedFolderGuid.value = folder.guid;
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+218
-202
@@ -20,7 +20,8 @@
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_importer.dart';
|
||||
|
||||
const _containerNormal = 0;
|
||||
const _containerToolbar = 1;
|
||||
@@ -30,18 +31,22 @@ 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);
|
||||
/// Parses a Netscape bookmark file into an [ImportBookmarkTree].
|
||||
///
|
||||
/// Pure and free of platform channels, so it is safe to run inside an isolate;
|
||||
/// see `bookmark_import_isolate.dart`.
|
||||
///
|
||||
/// When [preserveRootFolders] is set, folders carrying Firefox root markers
|
||||
/// (`PERSONAL_TOOLBAR_FOLDER`, `BOOKMARKS_MENU`, `UNFILED_BOOKMARKS_FOLDER`,
|
||||
/// `PLACES_ROOT`) at the top level are routed to the matching Places root
|
||||
/// instead of being imported as ordinary folders. Everything else lands under
|
||||
/// [BookmarkRoot.menu].
|
||||
ImportBookmarkTree parseBookmarkHtml(
|
||||
String htmlString, {
|
||||
required bool preserveRootFolders,
|
||||
}) {
|
||||
final parser = _BookmarkHtmlParser(preserveRootFolders: preserveRootFolders);
|
||||
return parser.parse(htmlString);
|
||||
}
|
||||
|
||||
class BookmarkHTMLUtils {
|
||||
@@ -50,9 +55,9 @@ class BookmarkHTMLUtils {
|
||||
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);
|
||||
Future<int> importFromHTML(String htmlString, {bool replace = false}) {
|
||||
final tree = parseBookmarkHtml(htmlString, preserveRootFolders: replace);
|
||||
return BookmarkTreeImporter(_service).import(tree, replace: replace);
|
||||
}
|
||||
|
||||
/// Export bookmarks to HTML string
|
||||
@@ -67,27 +72,80 @@ class BookmarkHTMLUtils {
|
||||
}
|
||||
}
|
||||
|
||||
class _BookmarkImporter {
|
||||
final GeckoBookmarksService _service;
|
||||
final bool _isImportDefaults;
|
||||
final Map<String, dynamic> _bookmarkTree;
|
||||
/// A node collected while parsing.
|
||||
///
|
||||
/// Folders stay mutable until their closing tag so children can be appended in
|
||||
/// place; leaves are immutable as soon as they are complete.
|
||||
sealed class _ParsedNode {}
|
||||
|
||||
final class _ParsedLeaf extends _ParsedNode {
|
||||
final ImportBookmarkNode node;
|
||||
|
||||
_ParsedLeaf(this.node);
|
||||
}
|
||||
|
||||
final class _ParsedFolder extends _ParsedNode {
|
||||
String title = '';
|
||||
|
||||
/// Set when the folder carried a Firefox root marker, naming the Places root
|
||||
/// its children belong to.
|
||||
String? rootGuid;
|
||||
DateTime? dateAdded;
|
||||
DateTime? lastModified;
|
||||
final List<_ParsedNode> children = [];
|
||||
}
|
||||
|
||||
/// A bookmark whose `<A>` tag has been opened but whose title text has not been
|
||||
/// read yet.
|
||||
class _PendingItem {
|
||||
final Uri url;
|
||||
final DateTime? dateAdded;
|
||||
final DateTime? lastModified;
|
||||
|
||||
_PendingItem({required this.url, this.dateAdded, this.lastModified});
|
||||
}
|
||||
|
||||
class _Frame {
|
||||
final _ParsedFolder folder;
|
||||
int containerNesting = 0;
|
||||
int lastContainerType = _containerNormal;
|
||||
String previousText = '';
|
||||
bool inDescription = false;
|
||||
_PendingItem? pendingItem;
|
||||
DateTime? previousDateAdded;
|
||||
DateTime? previousLastModifiedDate;
|
||||
|
||||
_Frame(this.folder);
|
||||
}
|
||||
|
||||
class _BookmarkHtmlParser {
|
||||
final bool preserveRootFolders;
|
||||
|
||||
final _ParsedFolder _root = _ParsedFolder();
|
||||
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));
|
||||
int _bookmarkCount = 0;
|
||||
int _folderCount = 0;
|
||||
int _separatorCount = 0;
|
||||
int _skippedUrlCount = 0;
|
||||
|
||||
_BookmarkHtmlParser({required this.preserveRootFolders}) {
|
||||
_frames.add(_Frame(_root));
|
||||
}
|
||||
|
||||
_Frame get _curFrame => _frames.last;
|
||||
|
||||
Future<int> importFromHTML(String htmlString) async {
|
||||
ImportBookmarkTree parse(String htmlString) {
|
||||
final document = html_parser.parse(htmlString);
|
||||
_walkTreeForImport(document.body);
|
||||
return await _importBookmarks();
|
||||
|
||||
// Close whatever the document left open so nothing is dropped.
|
||||
while (_frames.length > 1) {
|
||||
_popFrame();
|
||||
}
|
||||
_flushPendingItem();
|
||||
|
||||
return _buildTree();
|
||||
}
|
||||
|
||||
dom.Node? _nextSibling(dom.Node node) {
|
||||
@@ -186,29 +244,30 @@ class _BookmarkImporter {
|
||||
}
|
||||
|
||||
void _handleHeadBegin(dom.Element element) {
|
||||
final frame = _curFrame;
|
||||
|
||||
frame.previousLink = null;
|
||||
frame.lastContainerType = _containerNormal;
|
||||
|
||||
if (frame.containerNesting == 0 && _frames.length > 1) {
|
||||
_frames.removeLast();
|
||||
// A heading that arrives while the current folder never opened its `<DL>`
|
||||
// closes that folder first. Everything below must describe the *new*
|
||||
// heading, so the frame is only captured once the stack has settled.
|
||||
if (_curFrame.containerNesting == 0 && _frames.length > 1) {
|
||||
_popFrame();
|
||||
}
|
||||
|
||||
final frame = _curFrame;
|
||||
frame.lastContainerType = _containerNormal;
|
||||
|
||||
if (element.attributes.containsKey('personal_toolbar_folder')) {
|
||||
if (_isImportDefaults) {
|
||||
if (preserveRootFolders) {
|
||||
frame.lastContainerType = _containerToolbar;
|
||||
}
|
||||
} else if (element.attributes.containsKey('bookmarks_menu')) {
|
||||
if (_isImportDefaults) {
|
||||
if (preserveRootFolders) {
|
||||
frame.lastContainerType = _containerMenu;
|
||||
}
|
||||
} else if (element.attributes.containsKey('unfiled_bookmarks_folder')) {
|
||||
if (_isImportDefaults) {
|
||||
if (preserveRootFolders) {
|
||||
frame.lastContainerType = _containerUnfiled;
|
||||
}
|
||||
} else if (element.attributes.containsKey('places_root')) {
|
||||
if (_isImportDefaults) {
|
||||
if (preserveRootFolders) {
|
||||
frame.lastContainerType = _containerPlaces;
|
||||
}
|
||||
} else {
|
||||
@@ -227,67 +286,76 @@ class _BookmarkImporter {
|
||||
}
|
||||
|
||||
void _handleLinkBegin(dom.Element element) {
|
||||
final frame = _curFrame;
|
||||
// An unterminated `<A>` must not swallow the one that follows it.
|
||||
_flushPendingItem();
|
||||
|
||||
frame.previousItem = null;
|
||||
final frame = _curFrame;
|
||||
frame.previousText = '';
|
||||
|
||||
// TAGS, SHORTCUTURL, POST_DATA and LAST_CHARSET are read by Firefox but
|
||||
// have no representation in Places' bookmark storage, so they are dropped.
|
||||
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;
|
||||
_skippedUrlCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
final Uri url;
|
||||
try {
|
||||
final uri = Uri.parse(href);
|
||||
if (!uri.hasScheme) {
|
||||
frame.previousLink = null;
|
||||
_skippedUrlCount++;
|
||||
return;
|
||||
}
|
||||
frame.previousLink = uri.toString();
|
||||
url = uri;
|
||||
} catch (e) {
|
||||
frame.previousLink = null;
|
||||
_skippedUrlCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
final bookmark = <String, dynamic>{'url': frame.previousLink};
|
||||
final lastModifiedDate = lastModified != null
|
||||
? _convertImportedDateToInternalDate(lastModified)
|
||||
: null;
|
||||
|
||||
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'];
|
||||
}
|
||||
frame.pendingItem = _PendingItem(
|
||||
url: url,
|
||||
// A bookmark that only records a modification time is treated as having
|
||||
// been added then, matching Firefox's own importer.
|
||||
dateAdded: dateAdded != null
|
||||
? _convertImportedDateToInternalDate(dateAdded)
|
||||
: lastModifiedDate,
|
||||
lastModified: lastModifiedDate,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
/// Materialises the frame's open bookmark, if any, using [title].
|
||||
void _flushPendingItem({String title = ''}) {
|
||||
final frame = _curFrame;
|
||||
final pending = frame.pendingItem;
|
||||
if (pending == null) return;
|
||||
|
||||
(frame.folder['children'] as List).add(bookmark);
|
||||
frame.previousItem = bookmark;
|
||||
frame.pendingItem = null;
|
||||
frame.folder.children.add(
|
||||
_ParsedLeaf(
|
||||
ImportBookmarkItem(
|
||||
url: pending.url,
|
||||
title: title,
|
||||
dateAdded: pending.dateAdded,
|
||||
lastModified: pending.lastModified,
|
||||
),
|
||||
),
|
||||
);
|
||||
_bookmarkCount++;
|
||||
}
|
||||
|
||||
/// Completes the innermost folder and hands it to its parent.
|
||||
void _popFrame() {
|
||||
_flushPendingItem();
|
||||
final frame = _frames.removeLast();
|
||||
_curFrame.folder.children.add(frame.folder);
|
||||
}
|
||||
|
||||
void _handleContainerBegin() {
|
||||
@@ -300,7 +368,7 @@ class _BookmarkImporter {
|
||||
frame.containerNesting--;
|
||||
}
|
||||
if (_frames.length > 1 && frame.containerNesting == 0) {
|
||||
_frames.removeLast();
|
||||
_popFrame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,163 +378,111 @@ class _BookmarkImporter {
|
||||
|
||||
void _handleLinkEnd() {
|
||||
final frame = _curFrame;
|
||||
frame.previousText = frame.previousText.trim();
|
||||
|
||||
if (frame.previousItem != null) {
|
||||
frame.previousItem!['title'] = frame.previousText;
|
||||
}
|
||||
|
||||
_flushPendingItem(title: frame.previousText.trim());
|
||||
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;
|
||||
_flushPendingItem();
|
||||
_curFrame.folder.children.add(_ParsedLeaf(const ImportBookmarkSeparator()));
|
||||
_separatorCount++;
|
||||
}
|
||||
|
||||
void _newFrame() {
|
||||
_flushPendingItem();
|
||||
|
||||
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,
|
||||
};
|
||||
final folder = _ParsedFolder();
|
||||
|
||||
switch (containerType) {
|
||||
switch (frame.lastContainerType) {
|
||||
case _containerNormal:
|
||||
folder['title'] = containerTitle;
|
||||
folder.title = containerTitle;
|
||||
case _containerPlaces:
|
||||
folder['guid'] = BookmarkRoot.root.id;
|
||||
folder.rootGuid = BookmarkRoot.root.id;
|
||||
case _containerMenu:
|
||||
folder['guid'] = BookmarkRoot.menu.id;
|
||||
folder.rootGuid = BookmarkRoot.menu.id;
|
||||
case _containerUnfiled:
|
||||
folder['guid'] = BookmarkRoot.unfiled.id;
|
||||
folder.rootGuid = BookmarkRoot.unfiled.id;
|
||||
case _containerToolbar:
|
||||
folder['guid'] = BookmarkRoot.toolbar.id;
|
||||
folder.rootGuid = BookmarkRoot.toolbar.id;
|
||||
}
|
||||
|
||||
(frame.folder['children'] as List).add(folder);
|
||||
folder.lastModified = frame.previousLastModifiedDate;
|
||||
// As for items, a folder that only records a modification time is treated
|
||||
// as having been created then.
|
||||
folder.dateAdded = frame.previousDateAdded ?? folder.lastModified;
|
||||
frame.previousDateAdded = null;
|
||||
frame.previousLastModifiedDate = null;
|
||||
|
||||
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
|
||||
final parsed = int.tryParse(seconds);
|
||||
if (parsed != null) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(parsed * 1000);
|
||||
}
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _getBookmarkTrees() {
|
||||
if (!_isImportDefaults) {
|
||||
return [_bookmarkTree];
|
||||
/// Groups the parsed top level into per-root sections.
|
||||
///
|
||||
/// Only top-level folders carrying a root marker are routed to their own
|
||||
/// Places root; a marker deeper in the file describes a folder that Firefox
|
||||
/// itself would have nested, so it is imported as an ordinary (untitled)
|
||||
/// folder.
|
||||
ImportBookmarkTree _buildTree() {
|
||||
final menuNodes = <ImportBookmarkNode>[];
|
||||
final rootSections = <String, List<ImportBookmarkNode>>{};
|
||||
|
||||
for (final child in _root.children) {
|
||||
if (child is _ParsedFolder && child.rootGuid != null) {
|
||||
rootSections
|
||||
.putIfAbsent(child.rootGuid!, () => <ImportBookmarkNode>[])
|
||||
.addAll(child.children.map(_toImmutable));
|
||||
} else {
|
||||
menuNodes.add(_toImmutable(child));
|
||||
}
|
||||
}
|
||||
|
||||
final bookmarkTrees = <Map<String, dynamic>>[_bookmarkTree];
|
||||
final children = _bookmarkTree['children'] as List<Map<String, dynamic>>;
|
||||
final sections = <String, List<ImportBookmarkNode>>{
|
||||
if (menuNodes.isNotEmpty) BookmarkRoot.menu.id: menuNodes,
|
||||
};
|
||||
for (final section in rootSections.entries) {
|
||||
sections.update(
|
||||
section.key,
|
||||
(existing) => existing..addAll(section.value),
|
||||
ifAbsent: () => section.value,
|
||||
);
|
||||
}
|
||||
|
||||
_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;
|
||||
return ImportBookmarkTree(
|
||||
sections: sections,
|
||||
stats: ImportBookmarkStats(
|
||||
bookmarkCount: _bookmarkCount,
|
||||
folderCount: _folderCount,
|
||||
separatorCount: _separatorCount,
|
||||
skippedUrlCount: _skippedUrlCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> _importBookmarks() async {
|
||||
if (_isImportDefaults) {
|
||||
// Delete bookmarks from each root folder (except root itself to avoid errors)
|
||||
for (final root in BookmarkRoot.values) {
|
||||
if (root != BookmarkRoot.root) {
|
||||
await _service.eraseEverything(root);
|
||||
}
|
||||
}
|
||||
ImportBookmarkNode _toImmutable(_ParsedNode node) {
|
||||
switch (node) {
|
||||
case final _ParsedLeaf leaf:
|
||||
return leaf.node;
|
||||
case final _ParsedFolder folder:
|
||||
_folderCount++;
|
||||
return ImportBookmarkFolder(
|
||||
title: folder.title,
|
||||
children: folder.children.map(_toImmutable).toList(),
|
||||
dateAdded: folder.dateAdded,
|
||||
lastModified: folder.lastModified,
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.e('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) {
|
||||
logger.e('Failed to import folder "$title": $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
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_json_utils.dart';
|
||||
|
||||
/// The bookmark file formats WebLibre can read.
|
||||
enum BookmarkImportFormat { html, json }
|
||||
|
||||
/// Reads and parses the bookmark file at [path] in a background isolate.
|
||||
///
|
||||
/// Both the file read and the parse happen off the UI isolate, so a large
|
||||
/// export — the reported problem case is around 25k bookmarks — does not block
|
||||
/// frames. Only the resulting [ImportBookmarkTree] crosses back, which is far
|
||||
/// smaller than the HTML DOM or decoded JSON it was built from.
|
||||
///
|
||||
/// The file is read inside the isolate rather than handed over as a string, so
|
||||
/// the UI isolate never holds the raw document in memory.
|
||||
Future<ImportBookmarkTree> parseBookmarkFile({
|
||||
required String path,
|
||||
required BookmarkImportFormat format,
|
||||
required bool preserveRootFolders,
|
||||
}) {
|
||||
return Isolate.run(
|
||||
() => _parseBookmarkFile(path, format, preserveRootFolders),
|
||||
debugName: 'bookmark-import-parse',
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs inside the spawned isolate; must stay free of platform channels and of
|
||||
/// anything tied to the UI isolate's state.
|
||||
ImportBookmarkTree _parseBookmarkFile(
|
||||
String path,
|
||||
BookmarkImportFormat format,
|
||||
bool preserveRootFolders,
|
||||
) {
|
||||
final content = File(path).readAsStringSync();
|
||||
|
||||
return switch (format) {
|
||||
BookmarkImportFormat.html => parseBookmarkHtml(
|
||||
content,
|
||||
preserveRootFolders: preserveRootFolders,
|
||||
),
|
||||
BookmarkImportFormat.json => parseBookmarkJson(content),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||
|
||||
/// Writes a parsed [ImportBookmarkTree] into Places.
|
||||
///
|
||||
/// Insertion is bulk: one native call per destination root, with each top-level
|
||||
/// folder written as a single storage tree insertion. A 25k-bookmark file
|
||||
/// therefore costs a handful of platform channel round trips rather than one
|
||||
/// per node.
|
||||
class BookmarkTreeImporter {
|
||||
final GeckoBookmarksService _service;
|
||||
|
||||
const BookmarkTreeImporter(this._service);
|
||||
|
||||
/// Inserts [tree] and returns the number of bookmark items written.
|
||||
///
|
||||
/// When [replace] is set every root except [BookmarkRoot.root] is emptied
|
||||
/// 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 {
|
||||
if (tree.isEmpty) return 0;
|
||||
|
||||
if (replace) {
|
||||
for (final root in BookmarkRoot.values) {
|
||||
if (root != BookmarkRoot.root) {
|
||||
await _service.eraseEverything(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
|
||||
importedCount += result.insertedItemCount;
|
||||
|
||||
if (result.failedNodeCount > 0) {
|
||||
logger.e(
|
||||
'Failed to import ${result.failedNodeCount} top-level nodes into ${section.key}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return importedCount;
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a parsed node into the Pigeon transport type.
|
||||
///
|
||||
/// Timestamps become milliseconds since epoch, with 0 standing in for "the file
|
||||
/// did not say", which is what Places expects for an unknown timestamp.
|
||||
BookmarkImportNode toPigeonImportNode(ImportBookmarkNode node) {
|
||||
return switch (node) {
|
||||
final ImportBookmarkFolder folder => BookmarkImportNode(
|
||||
type: BookmarkNodeType.folder,
|
||||
title: folder.title,
|
||||
url: null,
|
||||
dateAdded: _toMillis(folder.dateAdded),
|
||||
lastModified: _toMillis(folder.lastModified),
|
||||
children: folder.children.map(toPigeonImportNode).toList(),
|
||||
),
|
||||
final ImportBookmarkItem item => BookmarkImportNode(
|
||||
type: BookmarkNodeType.item,
|
||||
title: item.title,
|
||||
url: item.url.toString(),
|
||||
dateAdded: _toMillis(item.dateAdded),
|
||||
lastModified: _toMillis(item.lastModified),
|
||||
children: const [],
|
||||
),
|
||||
final ImportBookmarkSeparator separator => BookmarkImportNode(
|
||||
type: BookmarkNodeType.separator,
|
||||
title: null,
|
||||
url: null,
|
||||
dateAdded: _toMillis(separator.dateAdded),
|
||||
lastModified: _toMillis(separator.lastModified),
|
||||
children: const [],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
int _toMillis(DateTime? value) => value?.millisecondsSinceEpoch ?? 0;
|
||||
+217
-220
@@ -23,8 +23,28 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_importer.dart';
|
||||
import 'package:weblibre/utils/uri_input_parser.dart';
|
||||
|
||||
/// Parses a Firefox JSON bookmark backup into an [ImportBookmarkTree].
|
||||
///
|
||||
/// Pure and free of platform channels, so it is safe to run inside an isolate;
|
||||
/// see `bookmark_import_isolate.dart`.
|
||||
///
|
||||
/// Only top-level nodes that identify themselves as a Places root are imported,
|
||||
/// each into its matching root. `place:` query URLs have their folder ids
|
||||
/// rewritten to guids, and the tags folder is ignored.
|
||||
ImportBookmarkTree parseBookmarkJson(String jsonString) {
|
||||
final data = jsonDecode(jsonString);
|
||||
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw const FormatException('Invalid JSON format');
|
||||
}
|
||||
|
||||
return const _BookmarkJsonParser().parse(data);
|
||||
}
|
||||
|
||||
class BookmarkJSONUtils {
|
||||
final GeckoBookmarksService _service;
|
||||
|
||||
@@ -33,18 +53,10 @@ class BookmarkJSONUtils {
|
||||
/// 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);
|
||||
final tree = parseBookmarkJson(jsonString);
|
||||
return await BookmarkTreeImporter(
|
||||
_service,
|
||||
).import(tree, replace: replace);
|
||||
} catch (ex) {
|
||||
logger.e('Failed to import bookmarks: $ex');
|
||||
rethrow;
|
||||
@@ -63,206 +75,6 @@ class BookmarkJSONUtils {
|
||||
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) {
|
||||
// Delete bookmarks from each root folder (except root itself to avoid errors)
|
||||
for (final root in BookmarkRoot.values) {
|
||||
if (root != BookmarkRoot.root) {
|
||||
await _service.eraseEverything(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 {
|
||||
final parsed = Uri.tryParse(url);
|
||||
final redacted = parsed != null
|
||||
? redactUriCredentials(parsed)
|
||||
: url;
|
||||
logger.w('Skipping invalid URL: $redacted');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.e('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) {
|
||||
logger.e('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
|
||||
@@ -344,20 +156,188 @@ class BookmarkJSONUtils {
|
||||
if (guid == BookmarkRoot.mobile.id) return 'mobileFolder';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Running totals collected while converting a JSON backup.
|
||||
class _Counters {
|
||||
int bookmarks = 0;
|
||||
int folders = 0;
|
||||
int separators = 0;
|
||||
int skippedUrls = 0;
|
||||
}
|
||||
|
||||
class _BookmarkJsonParser {
|
||||
const _BookmarkJsonParser();
|
||||
|
||||
ImportBookmarkTree parse(Map<String, dynamic> data) {
|
||||
final nodes =
|
||||
(data['children'] as List?)
|
||||
?.whereType<Map<String, dynamic>>()
|
||||
.where(
|
||||
(node) =>
|
||||
node['root'] != 'tagsFolder' &&
|
||||
node['guid'] != 'tags________',
|
||||
)
|
||||
.toList() ??
|
||||
const [];
|
||||
|
||||
// `place:` query URLs reference folders by numeric id, which is meaningless
|
||||
// once imported. Collect every id -> guid pair up front, including from
|
||||
// sections that are not themselves imported, so the rewrite below can
|
||||
// resolve references that point across roots.
|
||||
final folderIdToGuid = <String, String>{};
|
||||
for (final node in nodes) {
|
||||
if (_childrenOf(node).isEmpty) continue;
|
||||
_collectFolderGuids(node, folderIdToGuid);
|
||||
}
|
||||
|
||||
final counters = _Counters();
|
||||
final sections = <String, List<ImportBookmarkNode>>{};
|
||||
|
||||
for (final node in nodes) {
|
||||
final children = _childrenOf(node);
|
||||
if (children.isEmpty) continue;
|
||||
|
||||
// Anything that does not name a Places root has nowhere to go: a backup
|
||||
// always describes its roots explicitly.
|
||||
final guid = node['guid'] as String?;
|
||||
if (guid == null || !bookmarkRootIds.contains(guid)) continue;
|
||||
|
||||
final converted = <ImportBookmarkNode>[];
|
||||
for (final child in children) {
|
||||
final node = _convert(child, folderIdToGuid, counters);
|
||||
if (node != null) converted.add(node);
|
||||
}
|
||||
|
||||
if (converted.isEmpty) continue;
|
||||
sections
|
||||
.putIfAbsent(guid, () => <ImportBookmarkNode>[])
|
||||
.addAll(converted);
|
||||
}
|
||||
|
||||
return ImportBookmarkTree(
|
||||
sections: sections,
|
||||
stats: ImportBookmarkStats(
|
||||
bookmarkCount: counters.bookmarks,
|
||||
folderCount: counters.folders,
|
||||
separatorCount: counters.separators,
|
||||
skippedUrlCount: counters.skippedUrls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ImportBookmarkNode? _convert(
|
||||
Map<String, dynamic> node,
|
||||
Map<String, String> folderIdToGuid,
|
||||
_Counters counters,
|
||||
) {
|
||||
final dateAdded = _parseTimestamp(node['dateAdded']);
|
||||
final lastModified = _parseTimestamp(node['lastModified']);
|
||||
|
||||
switch (_getNodeType(node)) {
|
||||
case BookmarkNodeType.folder:
|
||||
final children = <ImportBookmarkNode>[];
|
||||
for (final child in _childrenOf(node)) {
|
||||
final converted = _convert(child, folderIdToGuid, counters);
|
||||
if (converted != null) children.add(converted);
|
||||
}
|
||||
counters.folders++;
|
||||
return ImportBookmarkFolder(
|
||||
title: node['title'] as String? ?? '',
|
||||
children: children,
|
||||
dateAdded: dateAdded,
|
||||
lastModified: lastModified,
|
||||
);
|
||||
|
||||
case BookmarkNodeType.item:
|
||||
var url = _getNodeUrl(node);
|
||||
if (url == null || url.isEmpty) {
|
||||
counters.skippedUrls++;
|
||||
return null;
|
||||
}
|
||||
if (url.startsWith('place:')) {
|
||||
url = _fixupQuery(url, folderIdToGuid);
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !uri.hasScheme) {
|
||||
counters.skippedUrls++;
|
||||
logger.w(
|
||||
'Skipping invalid URL: ${uri != null ? redactUriCredentials(uri) : url}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
counters.bookmarks++;
|
||||
return ImportBookmarkItem(
|
||||
url: uri,
|
||||
title: node['title'] as String? ?? '',
|
||||
dateAdded: dateAdded,
|
||||
lastModified: lastModified,
|
||||
);
|
||||
|
||||
case BookmarkNodeType.separator:
|
||||
counters.separators++;
|
||||
return ImportBookmarkSeparator(
|
||||
dateAdded: dateAdded,
|
||||
lastModified: lastModified,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _collectFolderGuids(
|
||||
Map<String, dynamic> node,
|
||||
Map<String, String> folderIdToGuid,
|
||||
) {
|
||||
if (node['type'] == 'text/x-moz-place-container') {
|
||||
final id = node['id']?.toString();
|
||||
final guid = node['guid'] as String?;
|
||||
if (id != null && guid != null) {
|
||||
folderIdToGuid[id] = guid;
|
||||
}
|
||||
}
|
||||
|
||||
for (final child in _childrenOf(node)) {
|
||||
_collectFolderGuids(child, folderIdToGuid);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _childrenOf(Map<String, dynamic> node) {
|
||||
return (node['children'] as List?)
|
||||
?.whereType<Map<String, dynamic>>()
|
||||
.toList() ??
|
||||
const [];
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// 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'];
|
||||
@@ -373,4 +353,21 @@ class BookmarkJSONUtils {
|
||||
return BookmarkNodeType.separator;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a backup timestamp, which may be in either unit.
|
||||
///
|
||||
/// Firefox writes PRTime (microseconds), while WebLibre's own JSON export
|
||||
/// writes Places' native milliseconds. Any microsecond value for a date after
|
||||
/// ~1973 exceeds [_microsecondThreshold], whereas a millisecond value would
|
||||
/// have to be a date past the year 5138 to reach it.
|
||||
static const _microsecondThreshold = 100000000000000;
|
||||
|
||||
DateTime? _parseTimestamp(Object? value) {
|
||||
final raw = value is int ? value : int.tryParse(value?.toString() ?? '');
|
||||
if (raw == null || raw <= 0) return null;
|
||||
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
raw > _microsecondThreshold ? raw ~/ 1000 : raw,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-26
@@ -17,7 +17,6 @@
|
||||
* 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:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
@@ -31,8 +30,8 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/entities/font_size_constants.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart';
|
||||
@@ -783,18 +782,16 @@ class _BookmarkToolbarButton extends HookConsumerWidget {
|
||||
final tabUrl = scope.tabState?.url;
|
||||
final bookmarkable = tabUrl != null && !scope.isPreview;
|
||||
|
||||
final existingGuids = ref
|
||||
.watch(
|
||||
bookmarksRepositoryProvider.select(
|
||||
(async) => EquatableValue(
|
||||
bookmarkable
|
||||
? bookmarkGuidsForUrl(async.value, tabUrl)
|
||||
: const <String>[],
|
||||
),
|
||||
),
|
||||
)
|
||||
.value;
|
||||
// Answered by a storage lookup keyed on the URL, so this does not depend on
|
||||
// the whole bookmark tree being resident in memory.
|
||||
final bookmarkLookup = ref.watch(
|
||||
bookmarkGuidsForUrlProvider(bookmarkable ? tabUrl : null),
|
||||
);
|
||||
final existingGuids = bookmarkLookup.value ?? const <String>[];
|
||||
|
||||
// Until the lookup settles an existing bookmark is indistinguishable from
|
||||
// none, and assuming none would let a quick tap add a second copy.
|
||||
final canToggleBookmark = bookmarkable && bookmarkLookup.hasValue;
|
||||
final isBookmarked = existingGuids.isNotEmpty;
|
||||
|
||||
return MenuAnchor(
|
||||
@@ -819,7 +816,7 @@ class _BookmarkToolbarButton extends HookConsumerWidget {
|
||||
else
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.bookmarkPlus),
|
||||
onPressed: !bookmarkable
|
||||
onPressed: !canToggleBookmark
|
||||
? null
|
||||
: () async {
|
||||
await ref
|
||||
@@ -869,25 +866,23 @@ class _BookmarkToggleToolbarButton extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabUrl = scope.tabState?.url;
|
||||
final bookmarkable = tabUrl != null && !scope.isPreview;
|
||||
final existingGuids = ref
|
||||
.watch(
|
||||
bookmarksRepositoryProvider.select(
|
||||
(async) => EquatableValue(
|
||||
bookmarkable
|
||||
? bookmarkGuidsForUrl(async.value, tabUrl)
|
||||
: const <String>[],
|
||||
),
|
||||
),
|
||||
)
|
||||
.value;
|
||||
// Answered by a storage lookup keyed on the URL, so this does not depend on
|
||||
// the whole bookmark tree being resident in memory.
|
||||
final bookmarkLookup = ref.watch(
|
||||
bookmarkGuidsForUrlProvider(bookmarkable ? tabUrl : null),
|
||||
);
|
||||
final existingGuids = bookmarkLookup.value ?? const <String>[];
|
||||
|
||||
// Until the lookup settles an existing bookmark is indistinguishable from
|
||||
// none, and assuming none would let a quick tap add a second copy.
|
||||
final canToggleBookmark = bookmarkable && bookmarkLookup.hasValue;
|
||||
final isBookmarked = existingGuids.isNotEmpty;
|
||||
|
||||
return IconButton(
|
||||
tooltip: isBookmarked ? 'Remove bookmark' : 'Add bookmark',
|
||||
onPressed: scope.isPreview
|
||||
? () {}
|
||||
: !bookmarkable
|
||||
: !canToggleBookmark
|
||||
? null
|
||||
: () async {
|
||||
if (isBookmarked) {
|
||||
|
||||
+2
-1
@@ -628,7 +628,8 @@ class RailAppBarTitleView extends StatelessWidget {
|
||||
: MdiIcons.shieldAlert,
|
||||
size: 10,
|
||||
color:
|
||||
siteSettingsBadgeState == SiteSettingsBadgeState.improved
|
||||
siteSettingsBadgeState ==
|
||||
SiteSettingsBadgeState.improved
|
||||
? Colors.green
|
||||
: appColors.warningAmber,
|
||||
),
|
||||
|
||||
+49
-47
@@ -317,7 +317,9 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
|
||||
if (entries.isEmpty) {
|
||||
// Hold the 48px slot; the bar visibility is decided upstream by
|
||||
// quickTabSwitcherRowCountProvider.
|
||||
return isVertical ? const SizedBox(width: 48) : const SizedBox(height: 48);
|
||||
return isVertical
|
||||
? const SizedBox(width: 48)
|
||||
: const SizedBox(height: 48);
|
||||
}
|
||||
|
||||
return NotificationListener<UserScrollNotification>(
|
||||
@@ -522,19 +524,54 @@ class _AccordionHeaderChip extends StatelessWidget {
|
||||
// count badge inside the label instead, dropping the avatar slot.
|
||||
return wrapLongPress(
|
||||
FilterChip(
|
||||
labelPadding: EdgeInsets.zero,
|
||||
label: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (iconAvatar != null) iconAvatar,
|
||||
if (countBadge != null) ...[
|
||||
if (iconAvatar != null) const SizedBox(height: 4),
|
||||
// Multi-digit counts can exceed the narrow rail's fixed 48px chip
|
||||
// width; scale the badge down to fit instead of overflowing.
|
||||
FittedBox(fit: BoxFit.scaleDown, child: countBadge),
|
||||
labelPadding: EdgeInsets.zero,
|
||||
label: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (iconAvatar != null) iconAvatar,
|
||||
if (countBadge != null) ...[
|
||||
if (iconAvatar != null) const SizedBox(height: 4),
|
||||
// Multi-digit counts can exceed the narrow rail's fixed 48px chip
|
||||
// width; scale the badge down to fit instead of overflowing.
|
||||
FittedBox(fit: BoxFit.scaleDown, child: countBadge),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
color: WidgetStatePropertyAll(fill),
|
||||
selected: false,
|
||||
showCheckmark: false,
|
||||
onSelected: (value) {
|
||||
if (value) {
|
||||
onSelected();
|
||||
}
|
||||
},
|
||||
side: side,
|
||||
shape: shape,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return wrapLongPress(
|
||||
FilterChip(
|
||||
avatar: iconAvatar,
|
||||
label: container != null
|
||||
? buildContainerChipLabel(
|
||||
context,
|
||||
container,
|
||||
true,
|
||||
trailing: countBadge,
|
||||
)
|
||||
: SizedBox(
|
||||
height: 20,
|
||||
child: Center(
|
||||
child:
|
||||
countBadge ??
|
||||
DefaultTextStyle.merge(
|
||||
style: TextStyle(color: nullForeground),
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
color: WidgetStatePropertyAll(fill),
|
||||
selected: false,
|
||||
showCheckmark: false,
|
||||
@@ -545,41 +582,6 @@ class _AccordionHeaderChip extends StatelessWidget {
|
||||
},
|
||||
side: side,
|
||||
shape: shape,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return wrapLongPress(
|
||||
FilterChip(
|
||||
avatar: iconAvatar,
|
||||
label: container != null
|
||||
? buildContainerChipLabel(
|
||||
context,
|
||||
container,
|
||||
true,
|
||||
trailing: countBadge,
|
||||
)
|
||||
: SizedBox(
|
||||
height: 20,
|
||||
child: Center(
|
||||
child:
|
||||
countBadge ??
|
||||
DefaultTextStyle.merge(
|
||||
style: TextStyle(color: nullForeground),
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
color: WidgetStatePropertyAll(fill),
|
||||
selected: false,
|
||||
showCheckmark: false,
|
||||
onSelected: (value) {
|
||||
if (value) {
|
||||
onSelected();
|
||||
}
|
||||
},
|
||||
side: side,
|
||||
shape: shape,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+5
-6
@@ -82,12 +82,11 @@ class OpenInNewTab extends HookConsumerWidget {
|
||||
|
||||
// Alternative tab types the user can explicitly choose, excluding the type
|
||||
// that the main tile action already opens (the current tab's type).
|
||||
final alternativeTypes =
|
||||
<TabType>[
|
||||
TabType.regular,
|
||||
TabType.private,
|
||||
if (settings.showIsolatedTabUi) TabType.isolated,
|
||||
]..remove(currentTabMode.toTabType());
|
||||
final alternativeTypes = <TabType>[
|
||||
TabType.regular,
|
||||
TabType.private,
|
||||
if (settings.showIsolatedTabUi) TabType.isolated,
|
||||
]..remove(currentTabMode.toTabType());
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(MdiIcons.tabPlus),
|
||||
|
||||
+3
-1
@@ -90,7 +90,9 @@ class VisitContainerRecorder extends _$VisitContainerRecorder {
|
||||
// permanently. A genuine miss (deleted container) leaves the cache
|
||||
// non-empty, so this fallback does not fire repeatedly in steady state.
|
||||
if (containerId == null && contextIdToContainerId.isEmpty) {
|
||||
applyContainers(await ref.read(watchContainersWithCountProvider.future));
|
||||
applyContainers(
|
||||
await ref.read(watchContainersWithCountProvider.future),
|
||||
);
|
||||
containerId = contextIdToContainerId[contextId];
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -75,9 +75,9 @@ class VisitContainerDao extends DatabaseAccessor<TabDatabase>
|
||||
start,
|
||||
math.min(start + chunkSize, canonicalList.length),
|
||||
);
|
||||
final rows = await (db.select(db.visitContainer)
|
||||
..where((t) => t.urlCanonical.isIn(chunk)))
|
||||
.get();
|
||||
final rows = await (db.select(
|
||||
db.visitContainer,
|
||||
)..where((t) => t.urlCanonical.isIn(chunk))).get();
|
||||
results.addAll(rows);
|
||||
}
|
||||
return results;
|
||||
@@ -103,9 +103,9 @@ class VisitContainerDao extends DatabaseAccessor<TabDatabase>
|
||||
/// Container deletion dissolves relations automatically via ON DELETE CASCADE
|
||||
/// and does not go through here.
|
||||
Future<int> deleteForContainer(String containerId) {
|
||||
return (db.delete(db.visitContainer)
|
||||
..where((t) => t.containerId.equals(containerId)))
|
||||
.go();
|
||||
return (db.delete(
|
||||
db.visitContainer,
|
||||
)..where((t) => t.containerId.equals(containerId))).go();
|
||||
}
|
||||
|
||||
/// Remove every relation row, all containers. Used when the user clears all
|
||||
|
||||
+2
-1
@@ -62,7 +62,8 @@ Future<void> _removeAppLinkOverrides(
|
||||
) {
|
||||
if (!ids.any(current.appLinkContextOverrides.containsKey)) return current;
|
||||
return current.copyWith.appLinkContextOverrides(
|
||||
{...current.appLinkContextOverrides}..removeWhere((key, _) => ids.contains(key)),
|
||||
{...current.appLinkContextOverrides}
|
||||
..removeWhere((key, _) => ids.contains(key)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/entities/font_size_constants.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
||||
@@ -229,10 +228,8 @@ class GestureControlService extends _$GestureControlService {
|
||||
final bookmarkUrl =
|
||||
ref.read(sandboxSourceUriForTabProvider(tabId: tabId)) ?? tabState.url;
|
||||
|
||||
final bookmarks = ref.read(bookmarksRepositoryProvider).value;
|
||||
final existingGuids = bookmarkGuidsForUrl(bookmarks, bookmarkUrl);
|
||||
|
||||
final repository = ref.read(bookmarksRepositoryProvider.notifier);
|
||||
final existingGuids = await repository.bookmarkGuidsForUrl(bookmarkUrl);
|
||||
if (existingGuids.isNotEmpty) {
|
||||
for (final guid in existingGuids) {
|
||||
await repository.delete(guid);
|
||||
|
||||
@@ -66,7 +66,7 @@ final class GestureControlServiceProvider
|
||||
}
|
||||
|
||||
String _$gestureControlServiceHash() =>
|
||||
r'12bd852a5b90b67bee4a94e7bd55fccc53c11bd4';
|
||||
r'3741c0a3d9cb6726c044bab1efd4ce80208f21d8';
|
||||
|
||||
/// Bridges gesture settings and recognized-gesture events to app actions.
|
||||
///
|
||||
|
||||
@@ -781,8 +781,8 @@ class _AppLinksModeSection extends HookConsumerWidget {
|
||||
await ref
|
||||
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(current) =>
|
||||
current.copyWith.appLinkMarketplaceFallback(value),
|
||||
(current) => current.copyWith
|
||||
.appLinkMarketplaceFallback(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -845,9 +845,9 @@ class _AppLinkRulesSubsection extends ConsumerWidget {
|
||||
await ref
|
||||
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(current) => current.copyWith.appLinkRules({
|
||||
...current.appLinkRules,
|
||||
}..remove(key)),
|
||||
(current) => current.copyWith.appLinkRules(
|
||||
{...current.appLinkRules}..remove(key),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
+11
-14
@@ -19,13 +19,12 @@
|
||||
*/
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
// What words does the wanderer whisper?
|
||||
@@ -173,18 +172,16 @@ class SmallWebBottomBar extends HookConsumerWidget {
|
||||
|
||||
final tabUrl = currentTabUrl;
|
||||
final bookmarkable = tabUrl != null;
|
||||
final existingGuids = ref
|
||||
.watch(
|
||||
bookmarksRepositoryProvider.select(
|
||||
(async) => EquatableValue(
|
||||
bookmarkable
|
||||
? bookmarkGuidsForUrl(async.value, tabUrl)
|
||||
: const <String>[],
|
||||
),
|
||||
),
|
||||
)
|
||||
.value;
|
||||
// Answered by a storage lookup keyed on the URL, so this does not depend on
|
||||
// the whole bookmark tree being resident in memory.
|
||||
final bookmarkLookup = ref.watch(
|
||||
bookmarkGuidsForUrlProvider(bookmarkable ? tabUrl : null),
|
||||
);
|
||||
final existingGuids = bookmarkLookup.value ?? const <String>[];
|
||||
|
||||
// Until the lookup settles an existing bookmark is indistinguishable from
|
||||
// none, and assuming none would let a quick tap add a second copy.
|
||||
final canToggleBookmark = bookmarkable && bookmarkLookup.hasValue;
|
||||
final isBookmarked = existingGuids.isNotEmpty;
|
||||
|
||||
return SizedBox(
|
||||
@@ -224,7 +221,7 @@ class SmallWebBottomBar extends HookConsumerWidget {
|
||||
IconButton(
|
||||
icon: Icon(isBookmarked ? Icons.bookmark : Icons.bookmark_border),
|
||||
tooltip: isBookmarked ? 'Remove bookmark' : 'Add bookmark',
|
||||
onPressed: !bookmarkable
|
||||
onPressed: !canToggleBookmark
|
||||
? null
|
||||
: () async {
|
||||
if (isBookmarked) {
|
||||
|
||||
@@ -8,7 +8,6 @@ environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
animated_tree_view: ^2.3.0
|
||||
background_fetch: ^1.7.0
|
||||
collection: ^1.19.1
|
||||
convert: ^3.1.2
|
||||
|
||||
@@ -86,7 +86,9 @@ void main() {
|
||||
|
||||
test('is producible even for an ambiguous resolution', () {
|
||||
// neverOpen never launches, so it does not need a bound package.
|
||||
final rule = neverOpenRuleFor(_target(isAmbiguous: true, packageName: null));
|
||||
final rule = neverOpenRuleFor(
|
||||
_target(isAmbiguous: true, packageName: null),
|
||||
);
|
||||
expect(rule.decision, AppLinkRuleDecision.neverOpen);
|
||||
expect(rule.isValid, isTrue);
|
||||
});
|
||||
|
||||
@@ -80,15 +80,15 @@ void main() {
|
||||
},
|
||||
});
|
||||
expect(parsed.length, 2);
|
||||
expect(parsed['host:youtube.com']!.decision, AppLinkRuleDecision.alwaysOpen);
|
||||
expect(
|
||||
parsed['host:youtube.com']!.decision,
|
||||
AppLinkRuleDecision.alwaysOpen,
|
||||
);
|
||||
});
|
||||
|
||||
test('drops entries whose map key disagrees with the rule scope', () {
|
||||
final parsed = parseAppLinkRules({
|
||||
'host:wrong.com': {
|
||||
'decision': 'neverOpen',
|
||||
'scope': 'host:right.com',
|
||||
},
|
||||
'host:wrong.com': {'decision': 'neverOpen', 'scope': 'host:right.com'},
|
||||
});
|
||||
expect(parsed, isEmpty);
|
||||
});
|
||||
|
||||
@@ -23,11 +23,8 @@ import 'package:weblibre/features/app_links/domain/services/effective_routing.da
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
|
||||
SiteAssignment _assignment(String site, {String? contextId}) => SiteAssignment(
|
||||
id: site,
|
||||
contextualIdentity: contextId,
|
||||
assignedSite: site,
|
||||
);
|
||||
SiteAssignment _assignment(String site, {String? contextId}) =>
|
||||
SiteAssignment(id: site, contextualIdentity: contextId, assignedSite: site);
|
||||
|
||||
void main() {
|
||||
group('resolveContainerAssignment', () {
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* 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_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
|
||||
|
||||
BookmarkEntry entry(String guid, String title) => BookmarkEntry(
|
||||
guid: guid,
|
||||
parentGuid: BookmarkRoot.menu.id,
|
||||
url: Uri.parse('https://example.com/$guid'),
|
||||
title: title,
|
||||
previewImageUrl: Uri.parse('https://example.com/$guid'),
|
||||
position: 0,
|
||||
dateAdded: 0,
|
||||
);
|
||||
|
||||
BookmarkFolder folder(String guid, String title, {List<BookmarkItem>? kids}) =>
|
||||
BookmarkFolder(
|
||||
guid: guid,
|
||||
parentGuid: BookmarkRoot.root.id,
|
||||
title: title,
|
||||
position: 0,
|
||||
dateAdded: 0,
|
||||
children: kids,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('sortBookmarkChildren', () {
|
||||
test('should leave order untouched for manual sorting', () {
|
||||
final children = [entry('b', 'Beta'), entry('a', 'Alpha')];
|
||||
|
||||
final sorted = sortBookmarkChildren(children, BookmarkSortType.manual);
|
||||
|
||||
expect(sorted.map((c) => c.guid), equals(['b', 'a']));
|
||||
});
|
||||
|
||||
test('should sort a plain level by title', () {
|
||||
final children = [
|
||||
entry('c', 'Charlie'),
|
||||
entry('a', 'Alpha'),
|
||||
entry('b', 'Bravo'),
|
||||
];
|
||||
|
||||
final sorted = sortBookmarkChildren(children, BookmarkSortType.titleAsc);
|
||||
|
||||
expect(sorted.map((c) => c.title), equals(['Alpha', 'Bravo', 'Charlie']));
|
||||
});
|
||||
|
||||
test('should not mutate the list it was given', () {
|
||||
final children = [entry('c', 'Charlie'), entry('a', 'Alpha')];
|
||||
|
||||
sortBookmarkChildren(children, BookmarkSortType.titleAsc);
|
||||
|
||||
expect(children.map((c) => c.guid), equals(['c', 'a']));
|
||||
});
|
||||
|
||||
test('should keep built-in roots pinned ahead of the rest at root', () {
|
||||
final children = <BookmarkItem>[
|
||||
entry('z', 'Zulu'),
|
||||
folder(BookmarkRoot.mobile.id, 'WebLibre'),
|
||||
entry('a', 'Alpha'),
|
||||
folder(BookmarkRoot.menu.id, 'Menu'),
|
||||
];
|
||||
|
||||
final sorted = sortBookmarkChildren(
|
||||
children,
|
||||
BookmarkSortType.titleAsc,
|
||||
isRoot: true,
|
||||
);
|
||||
|
||||
expect(
|
||||
sorted.map((c) => c.guid),
|
||||
equals([BookmarkRoot.mobile.id, BookmarkRoot.menu.id, 'a', 'z']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveSelectedItems', () {
|
||||
test('should return the children whose guids are selected', () {
|
||||
final children = [entry('a', 'Alpha'), entry('b', 'Bravo')];
|
||||
|
||||
final selected = resolveSelectedItems(children, {'b'});
|
||||
|
||||
expect(selected.single.guid, equals('b'));
|
||||
});
|
||||
|
||||
test('should ignore guids that are not in the given list', () {
|
||||
final children = [entry('a', 'Alpha')];
|
||||
|
||||
expect(resolveSelectedItems(children, {'somewhere-else'}), isEmpty);
|
||||
});
|
||||
|
||||
test('should resolve items that live in other folders', () {
|
||||
// Search results come from anywhere in the library, so the list handed to
|
||||
// this function is not always one folder's children. Callers must pass
|
||||
// whatever is actually on screen — resolving against the current folder
|
||||
// instead would silently drop every result from a subfolder.
|
||||
final results = [
|
||||
entry('a', 'Alpha').copyWith(parentGuid: 'folder_one__'),
|
||||
entry('b', 'Bravo').copyWith(parentGuid: 'folder_two__'),
|
||||
];
|
||||
|
||||
final selected = resolveSelectedItems(results, {'a', 'b'});
|
||||
|
||||
expect(selected.map((item) => item.guid), equals(['a', 'b']));
|
||||
});
|
||||
});
|
||||
|
||||
group('normalizeSelection', () {
|
||||
test('should keep selections that are siblings', () {
|
||||
final rows = [
|
||||
BookmarkRow(entry('a', 'Alpha'), 0),
|
||||
BookmarkRow(entry('b', 'Bravo'), 0),
|
||||
];
|
||||
|
||||
expect(normalizeSelection(rows, {'a', 'b'}), equals({'a', 'b'}));
|
||||
});
|
||||
|
||||
test('should drop children of a selected folder', () {
|
||||
final rows = [
|
||||
BookmarkRow(folder('f1__________', 'Folder'), 0),
|
||||
BookmarkRow(entry('child_______', 'Child'), 1),
|
||||
BookmarkRow(entry('after_______', 'After'), 0),
|
||||
];
|
||||
|
||||
final normalized = normalizeSelection(rows, {
|
||||
'f1__________',
|
||||
'child_______',
|
||||
'after_______',
|
||||
});
|
||||
|
||||
expect(normalized, equals({'f1__________', 'after_______'}));
|
||||
});
|
||||
|
||||
test('should drop the whole subtree under a selected folder', () {
|
||||
final rows = [
|
||||
BookmarkRow(folder('outer_______', 'Outer'), 0),
|
||||
BookmarkRow(folder('inner_______', 'Inner'), 1),
|
||||
BookmarkRow(entry('deep________', 'Deep'), 2),
|
||||
BookmarkRow(entry('sibling_____', 'Sibling'), 0),
|
||||
];
|
||||
|
||||
final normalized = normalizeSelection(rows, {
|
||||
'outer_______',
|
||||
'inner_______',
|
||||
'deep________',
|
||||
'sibling_____',
|
||||
});
|
||||
|
||||
expect(normalized, equals({'outer_______', 'sibling_____'}));
|
||||
});
|
||||
|
||||
test('should keep a nested selection when its parent is not selected', () {
|
||||
final rows = [
|
||||
BookmarkRow(folder('f1__________', 'Folder'), 0),
|
||||
BookmarkRow(entry('child_______', 'Child'), 1),
|
||||
];
|
||||
|
||||
expect(
|
||||
normalizeSelection(rows, {'child_______'}),
|
||||
equals({'child_______'}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should not count a loading placeholder as a second occurrence', () {
|
||||
// While an expanded folder loads, a placeholder row repeats it one level
|
||||
// down. Treating that as a real row would resolve the folder twice and
|
||||
// apply the same move or delete to it twice over.
|
||||
final selectedFolder = folder('f1__________', 'Folder');
|
||||
final rows = [
|
||||
BookmarkRow(selectedFolder, 0),
|
||||
BookmarkRow(selectedFolder, 1, isPlaceholder: true),
|
||||
];
|
||||
|
||||
final normalized = normalizeSelection(rows, {'f1__________'});
|
||||
|
||||
expect(normalized, equals({'f1__________'}));
|
||||
});
|
||||
|
||||
test('should resume after leaving a selected folder subtree', () {
|
||||
final rows = [
|
||||
BookmarkRow(folder('f1__________', 'One'), 0),
|
||||
BookmarkRow(entry('inside______', 'Inside'), 1),
|
||||
BookmarkRow(folder('f2__________', 'Two'), 0),
|
||||
BookmarkRow(entry('later_______', 'Later'), 1),
|
||||
];
|
||||
|
||||
final normalized = normalizeSelection(rows, {
|
||||
'f1__________',
|
||||
'inside______',
|
||||
'later_______',
|
||||
});
|
||||
|
||||
expect(normalized, equals({'f1__________', 'later_______'}));
|
||||
});
|
||||
});
|
||||
|
||||
group('canFlattenFolder', () {
|
||||
test('should reject built-in roots', () {
|
||||
expect(canFlattenFolder(folder(BookmarkRoot.menu.id, 'Menu')), isFalse);
|
||||
});
|
||||
|
||||
test('should accept an ordinary folder even before its children load', () {
|
||||
// The list only loads one level, so a folder shown in it has no children
|
||||
// attached; emptiness is decided by the repository at operation time.
|
||||
expect(canFlattenFolder(folder('normal______', 'Normal')), isTrue);
|
||||
});
|
||||
|
||||
test('should reject a folder without a parent', () {
|
||||
final orphan = BookmarkFolder(
|
||||
guid: 'orphan______',
|
||||
parentGuid: null,
|
||||
title: 'Orphan',
|
||||
position: 0,
|
||||
dateAdded: 0,
|
||||
children: null,
|
||||
);
|
||||
|
||||
expect(canFlattenFolder(orphan), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
+421
-181
@@ -24,11 +24,26 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.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';
|
||||
|
||||
@GenerateMocks([GeckoBookmarksService])
|
||||
import 'bookmark_html_utils_test.mocks.dart';
|
||||
|
||||
/// Nodes the parser routed to [root], or an empty list if it produced no such
|
||||
/// section.
|
||||
List<ImportBookmarkNode> section(ImportBookmarkTree tree, BookmarkRoot root) =>
|
||||
tree.sections[root.id] ?? const [];
|
||||
|
||||
/// Mirrors what the native side reports back: bookmark items only, recursively.
|
||||
int countItems(List<BookmarkImportNode> nodes) => nodes.fold(
|
||||
0,
|
||||
(total, node) =>
|
||||
total +
|
||||
(node.type == BookmarkNodeType.item ? 1 : 0) +
|
||||
countItems(node.children),
|
||||
);
|
||||
|
||||
void main() {
|
||||
late MockGeckoBookmarksService mockService;
|
||||
late BookmarkHTMLUtils utils;
|
||||
@@ -36,72 +51,20 @@ void main() {
|
||||
setUp(() {
|
||||
mockService = MockGeckoBookmarksService();
|
||||
utils = BookmarkHTMLUtils(mockService);
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(mockService.insertTree(any, any)).thenAnswer(
|
||||
(invocation) async => BookmarkInsertTreeResult(
|
||||
insertedItemCount: countItems(
|
||||
invocation.positionalArguments[1] as List<BookmarkImportNode>,
|
||||
),
|
||||
failedNodeCount: 0,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
group('BookmarkHTMLUtils - Import', () {
|
||||
test('should handle corrupt HTML file with malformed URIs', () async {
|
||||
// Load the corrupt fixture
|
||||
final fixtureFile = File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.corrupt.html',
|
||||
);
|
||||
final htmlString = await fixtureFile.readAsString();
|
||||
|
||||
// Mock the service calls
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'generated_guid');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'generated_guid');
|
||||
|
||||
final count = await utils.importFromHTML(htmlString, replace: true);
|
||||
|
||||
// Should import valid bookmarks and skip the corrupt one
|
||||
expect(count, greaterThan(0));
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
});
|
||||
|
||||
test('should import from valid HTML file', () async {
|
||||
final fixtureFile = File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.preplaces.html',
|
||||
);
|
||||
final htmlString = await fixtureFile.readAsString();
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder_guid');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark_guid');
|
||||
|
||||
final count = await utils.importFromHTML(htmlString, replace: true);
|
||||
|
||||
expect(count, greaterThan(0));
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
// Verify some bookmarks were added
|
||||
verify(mockService.addItem(any, any, any, any)).called(greaterThan(0));
|
||||
});
|
||||
|
||||
test('should handle empty HTML', () async {
|
||||
const emptyHtml = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
|
||||
final count = await utils.importFromHTML(emptyHtml, replace: true);
|
||||
|
||||
expect(count, equals(0));
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
});
|
||||
|
||||
test('should not erase when replace is false', () async {
|
||||
group('parseBookmarkHtml', () {
|
||||
test('should route everything under menu without root markers', () {
|
||||
const simpleHtml = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -111,16 +74,19 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(simpleHtml, preserveRootFolders: false);
|
||||
|
||||
await utils.importFromHTML(simpleHtml);
|
||||
|
||||
verifyNever(mockService.eraseEverything(any));
|
||||
expect(tree.sections.keys, equals([BookmarkRoot.menu.id]));
|
||||
expect(
|
||||
section(tree, BookmarkRoot.menu).single,
|
||||
isA<ImportBookmarkItem>()
|
||||
.having((i) => i.url, 'url', Uri.parse('https://example.com'))
|
||||
.having((i) => i.title, 'title', 'Example'),
|
||||
);
|
||||
expect(tree.stats.bookmarkCount, equals(1));
|
||||
});
|
||||
|
||||
test('should handle bookmarks with special characters in title', () async {
|
||||
test('should decode HTML entities in titles', () {
|
||||
const htmlWithSpecialChars = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -130,21 +96,16 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(
|
||||
htmlWithSpecialChars,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithSpecialChars);
|
||||
|
||||
expect(count, equals(1));
|
||||
final captured = verify(
|
||||
mockService.addItem(any, any, captureAny, any),
|
||||
).captured;
|
||||
// Should properly decode HTML entities
|
||||
expect(captured[0], equals('<unescaped="test">'));
|
||||
final item = section(tree, BookmarkRoot.menu).single;
|
||||
expect((item as ImportBookmarkItem).title, equals('<unescaped="test">'));
|
||||
});
|
||||
|
||||
test('should import bookmarks with timestamps', () async {
|
||||
test('should preserve item timestamps as seconds since epoch', () {
|
||||
const htmlWithDates = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -154,16 +115,63 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(htmlWithDates, preserveRootFolders: false);
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithDates);
|
||||
|
||||
expect(count, equals(1));
|
||||
final item =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkItem;
|
||||
expect(
|
||||
item.dateAdded,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1177375336 * 1000)),
|
||||
);
|
||||
expect(
|
||||
item.lastModified,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1177375423 * 1000)),
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle folder hierarchy', () async {
|
||||
test('should fall back to LAST_MODIFIED when ADD_DATE is absent', () {
|
||||
const html = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com" LAST_MODIFIED="1177375423">Test</A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final tree = parseBookmarkHtml(html, preserveRootFolders: false);
|
||||
|
||||
final item =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkItem;
|
||||
expect(item.dateAdded, equals(item.lastModified));
|
||||
});
|
||||
|
||||
test('should preserve folder timestamps', () {
|
||||
const html = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3 ADD_DATE="1177375336" LAST_MODIFIED="1177375423">Dated</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com">Child</A>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final tree = parseBookmarkHtml(html, preserveRootFolders: false);
|
||||
|
||||
final folder =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkFolder;
|
||||
expect(
|
||||
folder.dateAdded,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1177375336 * 1000)),
|
||||
);
|
||||
expect(
|
||||
folder.lastModified,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1177375423 * 1000)),
|
||||
);
|
||||
});
|
||||
|
||||
test('should nest folders and keep child order', () {
|
||||
const htmlWithFolders = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -180,24 +188,98 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder_guid');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark_guid');
|
||||
final tree = parseBookmarkHtml(
|
||||
htmlWithFolders,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithFolders);
|
||||
final parent =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkFolder;
|
||||
expect(parent.title, equals('Parent Folder'));
|
||||
expect(parent.children, hasLength(2));
|
||||
|
||||
expect(count, equals(2)); // 2 bookmarks
|
||||
verify(mockService.addFolder(any, any, any)).called(2); // 2 folders
|
||||
expect(
|
||||
(parent.children[0] as ImportBookmarkItem).title,
|
||||
equals('Child 1'),
|
||||
);
|
||||
|
||||
final nested = parent.children[1] as ImportBookmarkFolder;
|
||||
expect(nested.title, equals('Nested Folder'));
|
||||
expect(
|
||||
(nested.children.single as ImportBookmarkItem).title,
|
||||
equals('Grandchild'),
|
||||
);
|
||||
|
||||
expect(tree.stats.bookmarkCount, equals(2));
|
||||
expect(tree.stats.folderCount, equals(2));
|
||||
});
|
||||
|
||||
test('should recognize toolbar folder', () async {
|
||||
const htmlWithToolbar = '''
|
||||
test('should keep empty folders', () {
|
||||
const html = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3>Empty</H3>
|
||||
<DL><p>
|
||||
</DL><p>
|
||||
<DT><A HREF="https://example.com">After</A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final tree = parseBookmarkHtml(html, preserveRootFolders: false);
|
||||
final nodes = section(tree, BookmarkRoot.menu);
|
||||
|
||||
expect(nodes, hasLength(2));
|
||||
expect(
|
||||
nodes[0],
|
||||
isA<ImportBookmarkFolder>()
|
||||
.having((f) => f.title, 'title', 'Empty')
|
||||
.having((f) => f.children, 'children', isEmpty),
|
||||
);
|
||||
expect(nodes[1], isA<ImportBookmarkItem>());
|
||||
});
|
||||
|
||||
test('should route root-marked folders when preserving roots', () {
|
||||
const htmlWithRoots = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3 PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com/toolbar">Toolbar Bookmark</A>
|
||||
</DL><p>
|
||||
<DT><H3 UNFILED_BOOKMARKS_FOLDER="true">Unsorted Bookmarks</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com/unfiled">Unfiled Bookmark</A>
|
||||
</DL><p>
|
||||
<DT><A HREF="https://example.com/loose">Loose</A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final tree = parseBookmarkHtml(htmlWithRoots, preserveRootFolders: true);
|
||||
|
||||
expect(
|
||||
(section(tree, BookmarkRoot.toolbar).single as ImportBookmarkItem)
|
||||
.title,
|
||||
equals('Toolbar Bookmark'),
|
||||
);
|
||||
expect(
|
||||
(section(tree, BookmarkRoot.unfiled).single as ImportBookmarkItem)
|
||||
.title,
|
||||
equals('Unfiled Bookmark'),
|
||||
);
|
||||
// The marked folders themselves are not recreated, only their contents.
|
||||
expect(
|
||||
(section(tree, BookmarkRoot.menu).single as ImportBookmarkItem).title,
|
||||
equals('Loose'),
|
||||
);
|
||||
});
|
||||
|
||||
test('should treat root markers as plain folders when not preserving', () {
|
||||
const htmlWithToolbar = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3 PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3>
|
||||
<DL><p>
|
||||
@@ -206,47 +288,19 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(
|
||||
htmlWithToolbar,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
await utils.importFromHTML(htmlWithToolbar, replace: true);
|
||||
|
||||
// When replace is true, should add to toolbar
|
||||
final captured = verify(
|
||||
mockService.addItem(captureAny, any, any, any),
|
||||
).captured;
|
||||
expect(captured[0], equals(BookmarkRoot.toolbar.id));
|
||||
expect(tree.sections.keys, equals([BookmarkRoot.menu.id]));
|
||||
expect(
|
||||
(section(tree, BookmarkRoot.menu).single as ImportBookmarkFolder).title,
|
||||
equals('Bookmarks Toolbar'),
|
||||
);
|
||||
});
|
||||
|
||||
test('should recognize unfiled folder', () async {
|
||||
const htmlWithUnfiled = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3 UNFILED_BOOKMARKS_FOLDER="true">Unsorted Bookmarks</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com">Unfiled Bookmark</A>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
|
||||
await utils.importFromHTML(htmlWithUnfiled, replace: true);
|
||||
|
||||
final captured = verify(
|
||||
mockService.addItem(captureAny, any, any, any),
|
||||
).captured;
|
||||
expect(captured[0], equals(BookmarkRoot.unfiled.id));
|
||||
});
|
||||
|
||||
test('should handle separators', () async {
|
||||
test('should keep separators between bookmarks', () {
|
||||
const htmlWithSeparator = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -258,17 +312,19 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(
|
||||
htmlWithSeparator,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
final nodes = section(tree, BookmarkRoot.menu);
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithSeparator);
|
||||
|
||||
// Should import 2 bookmarks (separator is not supported by Android API)
|
||||
expect(count, equals(2));
|
||||
expect(nodes, hasLength(3));
|
||||
expect(nodes[1], isA<ImportBookmarkSeparator>());
|
||||
expect(tree.stats.bookmarkCount, equals(2));
|
||||
expect(tree.stats.separatorCount, equals(1));
|
||||
});
|
||||
|
||||
test('should skip bookmarks without URLs', () async {
|
||||
test('should skip bookmarks without URLs', () {
|
||||
const htmlWithoutUrl = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -279,16 +335,16 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(
|
||||
htmlWithoutUrl,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithoutUrl);
|
||||
|
||||
expect(count, equals(1)); // Only the valid one
|
||||
expect(section(tree, BookmarkRoot.menu), hasLength(1));
|
||||
expect(tree.stats.skippedUrlCount, equals(1));
|
||||
});
|
||||
|
||||
test('should skip bookmarks with invalid URLs', () async {
|
||||
test('should skip bookmarks with schemeless URLs', () {
|
||||
const htmlWithInvalidUrl = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -299,31 +355,209 @@ void main() {
|
||||
</DL>
|
||||
''';
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'guid');
|
||||
final tree = parseBookmarkHtml(
|
||||
htmlWithInvalidUrl,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithInvalidUrl);
|
||||
|
||||
expect(count, equals(1));
|
||||
expect(section(tree, BookmarkRoot.menu), hasLength(1));
|
||||
expect(tree.stats.skippedUrlCount, equals(1));
|
||||
});
|
||||
|
||||
test('should handle single frame HTML', () async {
|
||||
final fixtureFile = File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks_html_singleframe.html',
|
||||
test('should produce no sections for an empty document', () {
|
||||
const emptyHtml = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final tree = parseBookmarkHtml(emptyHtml, preserveRootFolders: true);
|
||||
|
||||
expect(tree.isEmpty, isTrue);
|
||||
expect(tree.stats.bookmarkCount, equals(0));
|
||||
});
|
||||
|
||||
test('should keep headings that never open a list', () {
|
||||
// Firefox writes empty folders without a `<DL>`; the folder must still be
|
||||
// emitted, and the heading after it must not inherit its metadata.
|
||||
const html = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3>First</H3>
|
||||
<DT><H3 ADD_DATE="1177375336">Second</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com">Child</A>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final tree = parseBookmarkHtml(html, preserveRootFolders: false);
|
||||
final nodes = section(tree, BookmarkRoot.menu);
|
||||
|
||||
expect(nodes, hasLength(2));
|
||||
expect((nodes[0] as ImportBookmarkFolder).title, equals('First'));
|
||||
expect((nodes[0] as ImportBookmarkFolder).dateAdded, isNull);
|
||||
|
||||
final second = nodes[1] as ImportBookmarkFolder;
|
||||
expect(second.title, equals('Second'));
|
||||
expect(
|
||||
second.dateAdded,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1177375336 * 1000)),
|
||||
);
|
||||
final htmlString = await fixtureFile.readAsString();
|
||||
expect(second.children, hasLength(1));
|
||||
});
|
||||
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder_guid');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark_guid');
|
||||
test('should handle deeply nested folders', () {
|
||||
const depth = 60;
|
||||
final buffer = StringBuffer(
|
||||
'<!DOCTYPE NETSCAPE-Bookmark-file-1>\n<DL><p>',
|
||||
);
|
||||
for (var i = 0; i < depth; i++) {
|
||||
buffer.write('<DT><H3>Level $i</H3>\n<DL><p>');
|
||||
}
|
||||
buffer.write('<DT><A HREF="https://example.com">Deep</A>');
|
||||
for (var i = 0; i < depth; i++) {
|
||||
buffer.write('</DL><p>');
|
||||
}
|
||||
buffer.write('</DL>');
|
||||
|
||||
final count = await utils.importFromHTML(htmlString);
|
||||
final tree = parseBookmarkHtml(
|
||||
buffer.toString(),
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
expect(count, greaterThan(0));
|
||||
var node = section(tree, BookmarkRoot.menu).single;
|
||||
for (var i = 0; i < depth; i++) {
|
||||
node = (node as ImportBookmarkFolder).children.single;
|
||||
}
|
||||
expect(node, isA<ImportBookmarkItem>());
|
||||
expect(tree.stats.folderCount, equals(depth));
|
||||
});
|
||||
|
||||
test('should parse the corrupt fixture without throwing', () async {
|
||||
final htmlString = await File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.corrupt.html',
|
||||
).readAsString();
|
||||
|
||||
final tree = parseBookmarkHtml(htmlString, preserveRootFolders: true);
|
||||
|
||||
expect(tree.stats.bookmarkCount, greaterThan(0));
|
||||
});
|
||||
|
||||
test('should parse the pre-places fixture', () async {
|
||||
final htmlString = await File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.preplaces.html',
|
||||
).readAsString();
|
||||
|
||||
final tree = parseBookmarkHtml(htmlString, preserveRootFolders: true);
|
||||
|
||||
expect(tree.stats.bookmarkCount, greaterThan(0));
|
||||
});
|
||||
|
||||
test('should parse the single frame fixture', () async {
|
||||
final htmlString = await File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks_html_singleframe.html',
|
||||
).readAsString();
|
||||
|
||||
final tree = parseBookmarkHtml(htmlString, preserveRootFolders: false);
|
||||
|
||||
expect(tree.stats.bookmarkCount, greaterThan(0));
|
||||
});
|
||||
});
|
||||
|
||||
group('BookmarkHTMLUtils - Import', () {
|
||||
test('should insert each section with a single bulk call', () async {
|
||||
const htmlWithFolders = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3>Parent Folder</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com/1">Child 1</A>
|
||||
<DT><H3>Nested Folder</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com/2">Grandchild</A>
|
||||
</DL><p>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final count = await utils.importFromHTML(htmlWithFolders);
|
||||
|
||||
expect(count, equals(2));
|
||||
verify(mockService.insertTree(BookmarkRoot.menu.id, any)).called(1);
|
||||
verifyNever(mockService.addItem(any, any, any, any));
|
||||
verifyNever(mockService.addFolder(any, any, any));
|
||||
});
|
||||
|
||||
test(
|
||||
'should erase every root except the tree root when replacing',
|
||||
() async {
|
||||
final htmlString = await File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.preplaces.html',
|
||||
).readAsString();
|
||||
|
||||
final count = await utils.importFromHTML(htmlString, replace: true);
|
||||
|
||||
expect(count, greaterThan(0));
|
||||
for (final root in BookmarkRoot.values) {
|
||||
if (root == BookmarkRoot.root) {
|
||||
verifyNever(mockService.eraseEverything(root));
|
||||
} else {
|
||||
verify(mockService.eraseEverything(root)).called(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('should not erase when replace is false', () async {
|
||||
const simpleHtml = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com">Example</A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
await utils.importFromHTML(simpleHtml);
|
||||
|
||||
verifyNever(mockService.eraseEverything(any));
|
||||
});
|
||||
|
||||
test('should route root-marked sections to their Places roots', () async {
|
||||
const htmlWithToolbar = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3 PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com">Toolbar Bookmark</A>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
await utils.importFromHTML(htmlWithToolbar, replace: true);
|
||||
|
||||
verify(mockService.insertTree(BookmarkRoot.toolbar.id, any)).called(1);
|
||||
});
|
||||
|
||||
test('should insert nothing for an empty document', () async {
|
||||
const emptyHtml = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
final count = await utils.importFromHTML(emptyHtml, replace: true);
|
||||
|
||||
expect(count, equals(0));
|
||||
verifyNever(mockService.insertTree(any, any));
|
||||
// Nothing parsed means nothing to replace, so existing bookmarks survive.
|
||||
verifyNever(mockService.eraseEverything(any));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -747,18 +981,24 @@ void main() {
|
||||
expect(html, isNotEmpty);
|
||||
|
||||
// Re-import
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder1_____');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark1___');
|
||||
|
||||
final count = await utils.importFromHTML(html, replace: true);
|
||||
|
||||
expect(count, equals(1)); // One bookmark imported
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
verify(mockService.eraseEverything(BookmarkRoot.menu)).called(1);
|
||||
|
||||
final inserted =
|
||||
verify(
|
||||
mockService.insertTree(BookmarkRoot.menu.id, captureAny),
|
||||
).captured.single
|
||||
as List<BookmarkImportNode>;
|
||||
|
||||
final folder = inserted.single;
|
||||
expect(folder.type, equals(BookmarkNodeType.folder));
|
||||
expect(folder.title, equals('Test Folder'));
|
||||
|
||||
final bookmark = folder.children.single;
|
||||
expect(bookmark.title, equals('Test Bookmark'));
|
||||
expect(bookmark.url, equals('https://example.com'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+69
-39
@@ -3,11 +3,11 @@
|
||||
// Do not manually edit this file.
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:async' as _i3;
|
||||
import 'dart:async' as _i4;
|
||||
|
||||
import 'package:flutter_mozilla_components/src/domain/services/gecko_bookmarks.dart'
|
||||
as _i2;
|
||||
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i4;
|
||||
as _i3;
|
||||
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i2;
|
||||
import 'package:mockito/mockito.dart' as _i1;
|
||||
import 'package:mockito/src/dummies.dart' as _i5;
|
||||
|
||||
@@ -26,46 +26,52 @@ import 'package:mockito/src/dummies.dart' as _i5;
|
||||
// ignore_for_file: subtype_of_sealed_class
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
class _FakeBookmarkInsertTreeResult_0 extends _i1.SmartFake
|
||||
implements _i2.BookmarkInsertTreeResult {
|
||||
_FakeBookmarkInsertTreeResult_0(Object parent, Invocation parentInvocation)
|
||||
: super(parent, parentInvocation);
|
||||
}
|
||||
|
||||
/// A class which mocks [GeckoBookmarksService].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockGeckoBookmarksService extends _i1.Mock
|
||||
implements _i2.GeckoBookmarksService {
|
||||
implements _i3.GeckoBookmarksService {
|
||||
MockGeckoBookmarksService() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
|
||||
@override
|
||||
_i3.Future<_i4.BookmarkNode?> getTree(
|
||||
_i4.Future<_i2.BookmarkNode?> getTree(
|
||||
String? guid, {
|
||||
bool? recursive = false,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getTree, [guid], {#recursive: recursive}),
|
||||
returnValue: _i3.Future<_i4.BookmarkNode?>.value(),
|
||||
returnValue: _i4.Future<_i2.BookmarkNode?>.value(),
|
||||
)
|
||||
as _i3.Future<_i4.BookmarkNode?>);
|
||||
as _i4.Future<_i2.BookmarkNode?>);
|
||||
|
||||
@override
|
||||
_i3.Future<_i4.BookmarkNode?> getBookmark(String? guid) =>
|
||||
_i4.Future<_i2.BookmarkNode?> getBookmark(String? guid) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getBookmark, [guid]),
|
||||
returnValue: _i3.Future<_i4.BookmarkNode?>.value(),
|
||||
returnValue: _i4.Future<_i2.BookmarkNode?>.value(),
|
||||
)
|
||||
as _i3.Future<_i4.BookmarkNode?>);
|
||||
as _i4.Future<_i2.BookmarkNode?>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> getBookmarksWithUrl(Uri? url) =>
|
||||
_i4.Future<List<_i2.BookmarkNode>> getBookmarksWithUrl(Uri? url) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getBookmarksWithUrl, [url]),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
|
||||
<_i2.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
as _i4.Future<List<_i2.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> getRecentBookmarks(
|
||||
_i4.Future<List<_i2.BookmarkNode>> getRecentBookmarks(
|
||||
int? limit, {
|
||||
Duration? maxAge = Duration.zero,
|
||||
DateTime? currentTime,
|
||||
@@ -76,27 +82,27 @@ class MockGeckoBookmarksService extends _i1.Mock
|
||||
[limit],
|
||||
{#maxAge: maxAge, #currentTime: currentTime},
|
||||
),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
|
||||
<_i2.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
as _i4.Future<List<_i2.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> searchBookmarks(
|
||||
_i4.Future<List<_i2.BookmarkNode>> searchBookmarks(
|
||||
String? query, {
|
||||
int? limit = 10,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#searchBookmarks, [query], {#limit: limit}),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
|
||||
<_i2.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
as _i4.Future<List<_i2.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<String> addItem(
|
||||
_i4.Future<String> addItem(
|
||||
String? parentGuid,
|
||||
Uri? url,
|
||||
String? title,
|
||||
@@ -104,55 +110,79 @@ class MockGeckoBookmarksService extends _i1.Mock
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#addItem, [parentGuid, url, title, position]),
|
||||
returnValue: _i3.Future<String>.value(
|
||||
returnValue: _i4.Future<String>.value(
|
||||
_i5.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#addItem, [parentGuid, url, title, position]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i3.Future<String>);
|
||||
as _i4.Future<String>);
|
||||
|
||||
@override
|
||||
_i3.Future<String> addFolder(
|
||||
_i4.Future<String> addFolder(
|
||||
String? parentGuid,
|
||||
String? title,
|
||||
int? position,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#addFolder, [parentGuid, title, position]),
|
||||
returnValue: _i3.Future<String>.value(
|
||||
returnValue: _i4.Future<String>.value(
|
||||
_i5.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#addFolder, [parentGuid, title, position]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i3.Future<String>);
|
||||
as _i4.Future<String>);
|
||||
|
||||
@override
|
||||
_i3.Future<void> updateNode(String? guid, _i4.BookmarkInfo? info) =>
|
||||
_i4.Future<void> updateNode(String? guid, _i2.BookmarkInfo? info) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#updateNode, [guid, info]),
|
||||
returnValue: _i3.Future<void>.value(),
|
||||
returnValueForMissingStub: _i3.Future<void>.value(),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i3.Future<void>);
|
||||
as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i3.Future<bool> deleteNode(String? guid) =>
|
||||
_i4.Future<bool> deleteNode(String? guid) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#deleteNode, [guid]),
|
||||
returnValue: _i3.Future<bool>.value(false),
|
||||
returnValue: _i4.Future<bool>.value(false),
|
||||
)
|
||||
as _i3.Future<bool>);
|
||||
as _i4.Future<bool>);
|
||||
|
||||
@override
|
||||
_i3.Future<void> eraseEverything(_i2.BookmarkRoot? root) =>
|
||||
_i4.Future<_i2.BookmarkInsertTreeResult> insertTree(
|
||||
String? parentGuid,
|
||||
List<_i2.BookmarkImportNode>? children,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#insertTree, [parentGuid, children]),
|
||||
returnValue: _i4.Future<_i2.BookmarkInsertTreeResult>.value(
|
||||
_FakeBookmarkInsertTreeResult_0(
|
||||
this,
|
||||
Invocation.method(#insertTree, [parentGuid, children]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i4.Future<_i2.BookmarkInsertTreeResult>);
|
||||
|
||||
@override
|
||||
_i4.Future<int> countBookmarksInTrees(List<String>? guids) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#countBookmarksInTrees, [guids]),
|
||||
returnValue: _i4.Future<int>.value(0),
|
||||
)
|
||||
as _i4.Future<int>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> eraseEverything(_i3.BookmarkRoot? root) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#eraseEverything, [root]),
|
||||
returnValue: _i3.Future<void>.value(),
|
||||
returnValueForMissingStub: _i3.Future<void>.value(),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i3.Future<void>);
|
||||
as _i4.Future<void>);
|
||||
}
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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 'dart:io';
|
||||
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_import_isolate.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('bookmark-import-test');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<File> writeFixture(String name, String contents) async {
|
||||
final file = File('${tempDir.path}/$name');
|
||||
await file.writeAsString(contents);
|
||||
return file;
|
||||
}
|
||||
|
||||
group('parseBookmarkFile', () {
|
||||
test('should return an HTML tree across the isolate boundary', () async {
|
||||
final file = await writeFixture('bookmarks.html', '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3>Folder</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com" ADD_DATE="1177375336">Example</A>
|
||||
<HR>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''');
|
||||
|
||||
final tree = await parseBookmarkFile(
|
||||
path: file.path,
|
||||
format: BookmarkImportFormat.html,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
final folder =
|
||||
tree.sections[BookmarkRoot.menu.id]!.single as ImportBookmarkFolder;
|
||||
expect(folder.title, equals('Folder'));
|
||||
|
||||
final item = folder.children[0] as ImportBookmarkItem;
|
||||
expect(item.url, equals(Uri.parse('https://example.com')));
|
||||
expect(
|
||||
item.dateAdded,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1177375336 * 1000)),
|
||||
);
|
||||
expect(folder.children[1], isA<ImportBookmarkSeparator>());
|
||||
|
||||
expect(tree.stats.bookmarkCount, equals(1));
|
||||
expect(tree.stats.separatorCount, equals(1));
|
||||
});
|
||||
|
||||
test('should return a JSON tree across the isolate boundary', () async {
|
||||
final file = await writeFixture('bookmarks.json', '''
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"guid": "menu________",
|
||||
"type": "text/x-moz-place-container",
|
||||
"children": [
|
||||
{
|
||||
"guid": "bookmark1___",
|
||||
"title": "Example",
|
||||
"type": "text/x-moz-place",
|
||||
"uri": "https://example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
''');
|
||||
|
||||
final tree = await parseBookmarkFile(
|
||||
path: file.path,
|
||||
format: BookmarkImportFormat.json,
|
||||
preserveRootFolders: false,
|
||||
);
|
||||
|
||||
final item =
|
||||
tree.sections[BookmarkRoot.menu.id]!.single as ImportBookmarkItem;
|
||||
expect(item.title, equals('Example'));
|
||||
expect(item.url, equals(Uri.parse('https://example.com')));
|
||||
});
|
||||
|
||||
test('should propagate a missing file as an error', () {
|
||||
expect(
|
||||
parseBookmarkFile(
|
||||
path: '${tempDir.path}/does-not-exist.html',
|
||||
format: BookmarkImportFormat.html,
|
||||
preserveRootFolders: false,
|
||||
),
|
||||
throwsA(isA<FileSystemException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('should propagate a malformed JSON document as an error', () async {
|
||||
final file = await writeFixture('broken.json', '{not json');
|
||||
|
||||
expect(
|
||||
parseBookmarkFile(
|
||||
path: file.path,
|
||||
format: BookmarkImportFormat.json,
|
||||
preserveRootFolders: false,
|
||||
),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+305
-223
@@ -27,11 +27,26 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/import_bookmark_node.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart';
|
||||
|
||||
@GenerateMocks([GeckoBookmarksService])
|
||||
import 'bookmark_json_utils_test.mocks.dart';
|
||||
|
||||
/// Nodes the parser routed to [root], or an empty list if it produced no such
|
||||
/// section.
|
||||
List<ImportBookmarkNode> section(ImportBookmarkTree tree, BookmarkRoot root) =>
|
||||
tree.sections[root.id] ?? const [];
|
||||
|
||||
/// Mirrors what the native side reports back: bookmark items only, recursively.
|
||||
int countItems(List<BookmarkImportNode> nodes) => nodes.fold(
|
||||
0,
|
||||
(total, node) =>
|
||||
total +
|
||||
(node.type == BookmarkNodeType.item ? 1 : 0) +
|
||||
countItems(node.children),
|
||||
);
|
||||
|
||||
void main() {
|
||||
late MockGeckoBookmarksService mockService;
|
||||
late BookmarkJSONUtils utils;
|
||||
@@ -39,108 +54,52 @@ void main() {
|
||||
setUp(() {
|
||||
mockService = MockGeckoBookmarksService();
|
||||
utils = BookmarkJSONUtils(mockService);
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(mockService.insertTree(any, any)).thenAnswer(
|
||||
(invocation) async => BookmarkInsertTreeResult(
|
||||
insertedItemCount: countItems(
|
||||
invocation.positionalArguments[1] as List<BookmarkImportNode>,
|
||||
),
|
||||
failedNodeCount: 0,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
group('BookmarkJSONUtils - Import', () {
|
||||
test('should reject invalid JSON format', () {
|
||||
const invalidJson = '[]';
|
||||
|
||||
expect(
|
||||
() => utils.importFromJSON(invalidJson),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
group('parseBookmarkJson', () {
|
||||
test('should reject a document that is not an object', () {
|
||||
expect(() => parseBookmarkJson('[]'), throwsA(isA<FormatException>()));
|
||||
});
|
||||
|
||||
test('should return 0 for empty children', () async {
|
||||
const emptyJson = '{"children": []}';
|
||||
|
||||
final count = await utils.importFromJSON(emptyJson);
|
||||
|
||||
expect(count, equals(0));
|
||||
test('should produce nothing for empty or missing children', () {
|
||||
expect(parseBookmarkJson('{"children": []}').isEmpty, isTrue);
|
||||
expect(parseBookmarkJson('{"guid": "root________"}').isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('should return 0 when children is null', () async {
|
||||
const noChildrenJson = '{"guid": "root________"}';
|
||||
|
||||
final count = await utils.importFromJSON(noChildrenJson);
|
||||
|
||||
expect(count, equals(0));
|
||||
});
|
||||
|
||||
test('should filter out tags folder during import', () async {
|
||||
test('should filter out the tags folder', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'tags________',
|
||||
'root': 'tagsFolder',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [],
|
||||
'children': [
|
||||
{
|
||||
'guid': 'tag1________',
|
||||
'title': 'Tagged',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com/tagged',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'root': 'bookmarksMenuFolder',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
|
||||
final count = await utils.importFromJSON(
|
||||
jsonEncode(jsonData),
|
||||
replace: true,
|
||||
);
|
||||
|
||||
// Only the menu folder should be processed, tags should be filtered
|
||||
expect(count, equals(0)); // No bookmarks, just folders
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
});
|
||||
|
||||
test('should erase everything when replace is true', () async {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
|
||||
await utils.importFromJSON(jsonEncode(jsonData), replace: true);
|
||||
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
});
|
||||
|
||||
test('should not erase when replace is false', () async {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
verifyNever(mockService.eraseEverything(any));
|
||||
});
|
||||
|
||||
test('should import bookmarks with URI field', () async {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Test Bookmark',
|
||||
'title': 'Kept',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com',
|
||||
},
|
||||
@@ -149,59 +108,65 @@ void main() {
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark1___');
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
expect(count, equals(1));
|
||||
verify(
|
||||
mockService.addItem(
|
||||
'menu________',
|
||||
Uri.parse('https://example.com'),
|
||||
'Test Bookmark',
|
||||
0,
|
||||
),
|
||||
).called(1);
|
||||
expect(tree.sections.keys, equals([BookmarkRoot.menu.id]));
|
||||
expect(tree.stats.bookmarkCount, equals(1));
|
||||
});
|
||||
|
||||
test('should import bookmarks with URL field', () async {
|
||||
test('should ignore top-level nodes that are not Places roots', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'guid': 'notaroot____',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Test Bookmark',
|
||||
'title': 'Orphan',
|
||||
'type': 'text/x-moz-place',
|
||||
'url': 'https://example.com',
|
||||
'uri': 'https://example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark1___');
|
||||
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
expect(count, equals(1));
|
||||
verify(
|
||||
mockService.addItem(
|
||||
'menu________',
|
||||
Uri.parse('https://example.com'),
|
||||
'Test Bookmark',
|
||||
0,
|
||||
),
|
||||
).called(1);
|
||||
expect(parseBookmarkJson(jsonEncode(jsonData)).isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('should skip bookmarks with invalid URLs', () async {
|
||||
test('should accept both the uri and url fields', () {
|
||||
for (final field in ['uri', 'url']) {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Test Bookmark',
|
||||
'type': 'text/x-moz-place',
|
||||
field: 'https://example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
expect(
|
||||
section(tree, BookmarkRoot.menu).single,
|
||||
isA<ImportBookmarkItem>()
|
||||
.having((i) => i.url, 'url', Uri.parse('https://example.com'))
|
||||
.having((i) => i.title, 'title', 'Test Bookmark'),
|
||||
reason: 'field "$field" should be read as the bookmark URL',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should skip bookmarks with invalid URLs', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
@@ -225,26 +190,13 @@ void main() {
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'valid1______');
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
// Only one valid bookmark should be imported
|
||||
expect(count, equals(1));
|
||||
// Note: position is 1 because the invalid bookmark was skipped first
|
||||
verify(
|
||||
mockService.addItem(
|
||||
'menu________',
|
||||
Uri.parse('https://example.com'),
|
||||
'Valid URL',
|
||||
1,
|
||||
),
|
||||
).called(1);
|
||||
expect(section(tree, BookmarkRoot.menu), hasLength(1));
|
||||
expect(tree.stats.skippedUrlCount, equals(1));
|
||||
});
|
||||
|
||||
test('should import nested folders recursively', () async {
|
||||
test('should nest folders recursively', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
@@ -269,28 +221,20 @@ void main() {
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder1_____');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark1___');
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
expect(count, equals(1));
|
||||
verify(mockService.addFolder('menu________', 'Folder 1', 0)).called(1);
|
||||
verify(
|
||||
mockService.addItem(
|
||||
'folder1_____',
|
||||
Uri.parse('https://example.com'),
|
||||
'Nested Bookmark',
|
||||
0,
|
||||
),
|
||||
).called(1);
|
||||
final folder =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkFolder;
|
||||
expect(folder.title, equals('Folder 1'));
|
||||
expect(
|
||||
(folder.children.single as ImportBookmarkItem).title,
|
||||
equals('Nested Bookmark'),
|
||||
);
|
||||
expect(tree.stats.bookmarkCount, equals(1));
|
||||
expect(tree.stats.folderCount, equals(1));
|
||||
});
|
||||
|
||||
test('should handle separators gracefully (skip them)', () async {
|
||||
test('should keep separators', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
@@ -315,18 +259,80 @@ void main() {
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((invocation) async => 'generated_guid');
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
final nodes = section(tree, BookmarkRoot.menu);
|
||||
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
// Two bookmarks, separator should be skipped
|
||||
expect(count, equals(2));
|
||||
verify(mockService.addItem(any, any, any, any)).called(2);
|
||||
expect(nodes, hasLength(3));
|
||||
expect(nodes[1], isA<ImportBookmarkSeparator>());
|
||||
expect(tree.stats.bookmarkCount, equals(2));
|
||||
expect(tree.stats.separatorCount, equals(1));
|
||||
});
|
||||
|
||||
test('should fixup place: queries with folder shortcuts', () async {
|
||||
test('should read microsecond timestamps from Firefox backups', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Dated',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com',
|
||||
'dateAdded': 1361551979350273,
|
||||
'lastModified': 1361551979376699,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
final item =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkItem;
|
||||
expect(
|
||||
item.dateAdded,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1361551979350)),
|
||||
);
|
||||
expect(
|
||||
item.lastModified,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1361551979376)),
|
||||
);
|
||||
});
|
||||
|
||||
test('should read millisecond timestamps from WebLibre exports', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Dated',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com',
|
||||
'dateAdded': 1361551979350,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
final item =
|
||||
section(tree, BookmarkRoot.menu).single as ImportBookmarkItem;
|
||||
expect(
|
||||
item.dateAdded,
|
||||
equals(DateTime.fromMillisecondsSinceEpoch(1361551979350)),
|
||||
);
|
||||
expect(item.lastModified, isNull);
|
||||
});
|
||||
|
||||
test('should fixup place: queries with folder shortcuts', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
@@ -352,25 +358,14 @@ void main() {
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder1_____');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'shortcut1___');
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
// Capture the URI argument to verify it was fixed up
|
||||
// Note: position is 1 because the folder was added first at position 0
|
||||
final captured = verify(
|
||||
mockService.addItem('unfiled_____', captureAny, 'Folder Shortcut', 1),
|
||||
).captured;
|
||||
|
||||
expect((captured[0] as Uri).toString(), contains('parent=folder1_____'));
|
||||
final shortcut =
|
||||
section(tree, BookmarkRoot.unfiled)[1] as ImportBookmarkItem;
|
||||
expect(shortcut.url.toString(), contains('parent=folder1_____'));
|
||||
});
|
||||
|
||||
test('should handle invalid folder references in place: queries', () async {
|
||||
test('should handle invalid folder references in place: queries', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
@@ -388,48 +383,129 @@ void main() {
|
||||
],
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'shortcut1___');
|
||||
final tree = parseBookmarkJson(jsonEncode(jsonData));
|
||||
|
||||
await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
final captured = verify(
|
||||
mockService.addItem(
|
||||
'unfiled_____',
|
||||
captureAny,
|
||||
'Invalid Folder Shortcut',
|
||||
0,
|
||||
),
|
||||
).captured;
|
||||
|
||||
final url = (captured[0] as Uri).toString();
|
||||
final url =
|
||||
(section(tree, BookmarkRoot.unfiled).single as ImportBookmarkItem).url
|
||||
.toString();
|
||||
expect(url, contains('invalidOldParentId=999999'));
|
||||
expect(url, contains('excludeItems=1'));
|
||||
});
|
||||
|
||||
test('should count imported bookmarks correctly from fixture', () async {
|
||||
// Load the fixture
|
||||
final fixtureFile = File('test/utils/bookmarks/fixtures/bookmarks.json');
|
||||
final jsonString = await fixtureFile.readAsString();
|
||||
test('should parse the bookmarks fixture', () async {
|
||||
final jsonString = await File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.json',
|
||||
).readAsString();
|
||||
|
||||
// Mock the service calls
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((invocation) async => 'generated_guid');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((invocation) async => 'generated_guid');
|
||||
final tree = parseBookmarkJson(jsonString);
|
||||
|
||||
final count = await utils.importFromJSON(jsonString, replace: true);
|
||||
expect(tree.stats.bookmarkCount, greaterThan(0));
|
||||
});
|
||||
});
|
||||
|
||||
// The fixture has several bookmarks - we should count only valid ones
|
||||
expect(count, greaterThan(0));
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
group('BookmarkJSONUtils - Import', () {
|
||||
test('should reject invalid JSON format', () {
|
||||
expect(() => utils.importFromJSON('[]'), throwsA(isA<Exception>()));
|
||||
});
|
||||
|
||||
test('should handle import errors gracefully', () async {
|
||||
test('should return 0 without touching storage for empty input', () async {
|
||||
expect(await utils.importFromJSON('{"children": []}'), equals(0));
|
||||
expect(await utils.importFromJSON('{"guid": "root________"}'), equals(0));
|
||||
|
||||
verifyNever(mockService.insertTree(any, any));
|
||||
verifyNever(mockService.eraseEverything(any));
|
||||
});
|
||||
|
||||
test('should insert each root section with a single bulk call', () async {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'folder1_____',
|
||||
'title': 'Folder 1',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Nested Bookmark',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'guid': 'unfiled_____',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark2___',
|
||||
'title': 'Unfiled Bookmark',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com/2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
expect(count, equals(2));
|
||||
verify(mockService.insertTree(BookmarkRoot.menu.id, any)).called(1);
|
||||
verify(mockService.insertTree(BookmarkRoot.unfiled.id, any)).called(1);
|
||||
verifyNever(mockService.addItem(any, any, any, any));
|
||||
verifyNever(mockService.addFolder(any, any, any));
|
||||
});
|
||||
|
||||
test(
|
||||
'should erase every root except the tree root when replacing',
|
||||
() async {
|
||||
final jsonString = await File(
|
||||
'test/utils/bookmarks/fixtures/bookmarks.json',
|
||||
).readAsString();
|
||||
|
||||
final count = await utils.importFromJSON(jsonString, replace: true);
|
||||
|
||||
expect(count, greaterThan(0));
|
||||
for (final root in BookmarkRoot.values) {
|
||||
if (root == BookmarkRoot.root) {
|
||||
verifyNever(mockService.eraseEverything(root));
|
||||
} else {
|
||||
verify(mockService.eraseEverything(root)).called(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('should not erase when replace is false', () async {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
'guid': 'menu________',
|
||||
'type': 'text/x-moz-place-container',
|
||||
'children': [
|
||||
{
|
||||
'guid': 'bookmark1___',
|
||||
'title': 'Test Bookmark',
|
||||
'type': 'text/x-moz-place',
|
||||
'uri': 'https://example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
verifyNever(mockService.eraseEverything(any));
|
||||
});
|
||||
|
||||
test('should rethrow storage failures', () {
|
||||
final jsonData = {
|
||||
'children': [
|
||||
{
|
||||
@@ -448,13 +524,13 @@ void main() {
|
||||
};
|
||||
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
mockService.insertTree(any, any),
|
||||
).thenThrow(Exception('Database error'));
|
||||
|
||||
// Should not throw, but should log and continue
|
||||
final count = await utils.importFromJSON(jsonEncode(jsonData));
|
||||
|
||||
expect(count, equals(0)); // Failed to add
|
||||
expect(
|
||||
() => utils.importFromJSON(jsonEncode(jsonData)),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -776,21 +852,27 @@ void main() {
|
||||
expect(exported, isNotNull);
|
||||
|
||||
// Re-import
|
||||
when(mockService.eraseEverything(any)).thenAnswer((_) async {});
|
||||
when(
|
||||
mockService.addFolder(any, any, any),
|
||||
).thenAnswer((_) async => 'folder1_____');
|
||||
when(
|
||||
mockService.addItem(any, any, any, any),
|
||||
).thenAnswer((_) async => 'bookmark1___');
|
||||
|
||||
final jsonString = jsonEncode({
|
||||
'children': [exported],
|
||||
});
|
||||
final count = await utils.importFromJSON(jsonString, replace: true);
|
||||
|
||||
expect(count, equals(1)); // One bookmark imported
|
||||
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
|
||||
verify(mockService.eraseEverything(BookmarkRoot.menu)).called(1);
|
||||
|
||||
final inserted =
|
||||
verify(
|
||||
mockService.insertTree(BookmarkRoot.menu.id, captureAny),
|
||||
).captured.single
|
||||
as List<BookmarkImportNode>;
|
||||
|
||||
final folder = inserted.single;
|
||||
expect(folder.type, equals(BookmarkNodeType.folder));
|
||||
expect(folder.title, equals('Test Folder'));
|
||||
|
||||
final bookmark = folder.children.single;
|
||||
expect(bookmark.title, equals('Test Bookmark'));
|
||||
expect(bookmark.url, equals('https://example.com'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+69
-39
@@ -3,11 +3,11 @@
|
||||
// Do not manually edit this file.
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:async' as _i3;
|
||||
import 'dart:async' as _i4;
|
||||
|
||||
import 'package:flutter_mozilla_components/src/domain/services/gecko_bookmarks.dart'
|
||||
as _i2;
|
||||
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i4;
|
||||
as _i3;
|
||||
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i2;
|
||||
import 'package:mockito/mockito.dart' as _i1;
|
||||
import 'package:mockito/src/dummies.dart' as _i5;
|
||||
|
||||
@@ -26,46 +26,52 @@ import 'package:mockito/src/dummies.dart' as _i5;
|
||||
// ignore_for_file: subtype_of_sealed_class
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
class _FakeBookmarkInsertTreeResult_0 extends _i1.SmartFake
|
||||
implements _i2.BookmarkInsertTreeResult {
|
||||
_FakeBookmarkInsertTreeResult_0(Object parent, Invocation parentInvocation)
|
||||
: super(parent, parentInvocation);
|
||||
}
|
||||
|
||||
/// A class which mocks [GeckoBookmarksService].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockGeckoBookmarksService extends _i1.Mock
|
||||
implements _i2.GeckoBookmarksService {
|
||||
implements _i3.GeckoBookmarksService {
|
||||
MockGeckoBookmarksService() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
|
||||
@override
|
||||
_i3.Future<_i4.BookmarkNode?> getTree(
|
||||
_i4.Future<_i2.BookmarkNode?> getTree(
|
||||
String? guid, {
|
||||
bool? recursive = false,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getTree, [guid], {#recursive: recursive}),
|
||||
returnValue: _i3.Future<_i4.BookmarkNode?>.value(),
|
||||
returnValue: _i4.Future<_i2.BookmarkNode?>.value(),
|
||||
)
|
||||
as _i3.Future<_i4.BookmarkNode?>);
|
||||
as _i4.Future<_i2.BookmarkNode?>);
|
||||
|
||||
@override
|
||||
_i3.Future<_i4.BookmarkNode?> getBookmark(String? guid) =>
|
||||
_i4.Future<_i2.BookmarkNode?> getBookmark(String? guid) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getBookmark, [guid]),
|
||||
returnValue: _i3.Future<_i4.BookmarkNode?>.value(),
|
||||
returnValue: _i4.Future<_i2.BookmarkNode?>.value(),
|
||||
)
|
||||
as _i3.Future<_i4.BookmarkNode?>);
|
||||
as _i4.Future<_i2.BookmarkNode?>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> getBookmarksWithUrl(Uri? url) =>
|
||||
_i4.Future<List<_i2.BookmarkNode>> getBookmarksWithUrl(Uri? url) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getBookmarksWithUrl, [url]),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
|
||||
<_i2.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
as _i4.Future<List<_i2.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> getRecentBookmarks(
|
||||
_i4.Future<List<_i2.BookmarkNode>> getRecentBookmarks(
|
||||
int? limit, {
|
||||
Duration? maxAge = Duration.zero,
|
||||
DateTime? currentTime,
|
||||
@@ -76,27 +82,27 @@ class MockGeckoBookmarksService extends _i1.Mock
|
||||
[limit],
|
||||
{#maxAge: maxAge, #currentTime: currentTime},
|
||||
),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
|
||||
<_i2.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
as _i4.Future<List<_i2.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> searchBookmarks(
|
||||
_i4.Future<List<_i2.BookmarkNode>> searchBookmarks(
|
||||
String? query, {
|
||||
int? limit = 10,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#searchBookmarks, [query], {#limit: limit}),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
|
||||
<_i2.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
as _i4.Future<List<_i2.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<String> addItem(
|
||||
_i4.Future<String> addItem(
|
||||
String? parentGuid,
|
||||
Uri? url,
|
||||
String? title,
|
||||
@@ -104,55 +110,79 @@ class MockGeckoBookmarksService extends _i1.Mock
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#addItem, [parentGuid, url, title, position]),
|
||||
returnValue: _i3.Future<String>.value(
|
||||
returnValue: _i4.Future<String>.value(
|
||||
_i5.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#addItem, [parentGuid, url, title, position]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i3.Future<String>);
|
||||
as _i4.Future<String>);
|
||||
|
||||
@override
|
||||
_i3.Future<String> addFolder(
|
||||
_i4.Future<String> addFolder(
|
||||
String? parentGuid,
|
||||
String? title,
|
||||
int? position,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#addFolder, [parentGuid, title, position]),
|
||||
returnValue: _i3.Future<String>.value(
|
||||
returnValue: _i4.Future<String>.value(
|
||||
_i5.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#addFolder, [parentGuid, title, position]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i3.Future<String>);
|
||||
as _i4.Future<String>);
|
||||
|
||||
@override
|
||||
_i3.Future<void> updateNode(String? guid, _i4.BookmarkInfo? info) =>
|
||||
_i4.Future<void> updateNode(String? guid, _i2.BookmarkInfo? info) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#updateNode, [guid, info]),
|
||||
returnValue: _i3.Future<void>.value(),
|
||||
returnValueForMissingStub: _i3.Future<void>.value(),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i3.Future<void>);
|
||||
as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i3.Future<bool> deleteNode(String? guid) =>
|
||||
_i4.Future<bool> deleteNode(String? guid) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#deleteNode, [guid]),
|
||||
returnValue: _i3.Future<bool>.value(false),
|
||||
returnValue: _i4.Future<bool>.value(false),
|
||||
)
|
||||
as _i3.Future<bool>);
|
||||
as _i4.Future<bool>);
|
||||
|
||||
@override
|
||||
_i3.Future<void> eraseEverything(_i2.BookmarkRoot? root) =>
|
||||
_i4.Future<_i2.BookmarkInsertTreeResult> insertTree(
|
||||
String? parentGuid,
|
||||
List<_i2.BookmarkImportNode>? children,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#insertTree, [parentGuid, children]),
|
||||
returnValue: _i4.Future<_i2.BookmarkInsertTreeResult>.value(
|
||||
_FakeBookmarkInsertTreeResult_0(
|
||||
this,
|
||||
Invocation.method(#insertTree, [parentGuid, children]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i4.Future<_i2.BookmarkInsertTreeResult>);
|
||||
|
||||
@override
|
||||
_i4.Future<int> countBookmarksInTrees(List<String>? guids) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#countBookmarksInTrees, [guids]),
|
||||
returnValue: _i4.Future<int>.value(0),
|
||||
)
|
||||
as _i4.Future<int>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> eraseEverything(_i3.BookmarkRoot? root) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#eraseEverything, [root]),
|
||||
returnValue: _i3.Future<void>.value(),
|
||||
returnValueForMissingStub: _i3.Future<void>.value(),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
)
|
||||
as _i3.Future<void>);
|
||||
as _i4.Future<void>);
|
||||
}
|
||||
|
||||
+1
-4
@@ -66,10 +66,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('defaults to false', () {
|
||||
expect(
|
||||
ContainerMetadata.withDefaults().isolatedAppLinkSettings,
|
||||
isFalse,
|
||||
);
|
||||
expect(ContainerMetadata.withDefaults().isolatedAppLinkSettings, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user