bookmark feature rewrite

This commit is contained in:
Fabian Freund
2026-07-30 07:20:23 +02:00
parent 3a942a57a6
commit 33dfcf890c
46 changed files with 8107 additions and 4716 deletions
@@ -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,
@@ -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);
}
}
@@ -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,
@@ -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,
);
}
}
@@ -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);
}
@@ -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';
}
@@ -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++;
}
@@ -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?
>;
@@ -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);
}
@@ -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: () {
@@ -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');
@@ -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,
),
);
}
}
@@ -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;
}
}
@@ -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;
@@ -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,
);
}
}
@@ -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) {
@@ -628,7 +628,8 @@ class RailAppBarTitleView extends StatelessWidget {
: MdiIcons.shieldAlert,
size: 10,
color:
siteSettingsBadgeState == SiteSettingsBadgeState.improved
siteSettingsBadgeState ==
SiteSettingsBadgeState.improved
? Colors.green
: appColors.warningAmber,
),
@@ -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,
),
);
}
@@ -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),
@@ -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];
}
@@ -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
@@ -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),
),
);
},
),
@@ -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) {
-1
View File
@@ -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', () {
@@ -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);
});
});
}
@@ -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'));
});
});
}
@@ -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>);
}
@@ -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>()),
);
});
});
}
@@ -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'));
});
});
}
@@ -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>);
}
@@ -66,10 +66,7 @@ void main() {
});
test('defaults to false', () {
expect(
ContainerMetadata.withDefaults().isolatedAppLinkSettings,
isFalse,
);
expect(ContainerMetadata.withDefaults().isolatedAppLinkSettings, isFalse);
});
});