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 final isolationLoaded = ref
.watch(watchIsolatedContextContainerMapProvider) .watch(watchIsolatedContextContainerMapProvider)
.hasValue; .hasValue;
final strictLoaded = ref.watch(watchStrictContextAssignmentsProvider).hasValue; final strictLoaded = ref
.watch(watchStrictContextAssignmentsProvider)
.hasValue;
final sitesLoaded = ref.watch(watchAllAssignedSitesProvider).hasValue; final sitesLoaded = ref.watch(watchAllAssignedSitesProvider).hasValue;
// The real proxy-routing settings drive `protectGeneralContext`; the // The real proxy-routing settings drive `protectGeneralContext`; the
// `...WithDefaults` view silently substitutes defaults while the row loads, // `...WithDefaults` view silently substitutes defaults while the row loads,
@@ -120,7 +120,7 @@ final class AppLinkPolicySnapshotProvider
} }
String _$appLinkPolicySnapshotHash() => String _$appLinkPolicySnapshotHash() =>
r'7f700b67d3b7b0b435fe82a98de455c6e374a1a2'; r'6fe2dca118d7162561fc7f6280d1a0411d50972a';
/// Single serialised writer that mirrors the Dart-owned app-link policy to the /// 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 /// native profile-scoped store (§2.8), the sole policy source consulted by the
@@ -311,7 +311,10 @@ AppLinkProtection computeAppLinkProtection({
final protectedContextIds = <String>{}; final protectedContextIds = <String>{};
for (final MapEntry(:key, :value) in assignmentByContextId.entries) { for (final MapEntry(:key, :value) in assignmentByContextId.entries) {
if (isAssignmentProtected(value, protectGeneralContext: protectGeneralContext)) { if (isAssignmentProtected(
value,
protectGeneralContext: protectGeneralContext,
)) {
protectedContextIds.add(key); protectedContextIds.add(key);
} }
} }
@@ -323,7 +326,10 @@ AppLinkProtection computeAppLinkProtection({
.toList(); .toList();
if (assignments.isEmpty) continue; if (assignments.isEmpty) continue;
final chosen = resolveIsolationContextRouting(assignments).chosen; final chosen = resolveIsolationContextRouting(assignments).chosen;
if (isAssignmentProtected(chosen, protectGeneralContext: protectGeneralContext)) { if (isAssignmentProtected(
chosen,
protectGeneralContext: protectGeneralContext,
)) {
protectedContextIds.add(key); protectedContextIds.add(key);
} }
} }
@@ -53,17 +53,17 @@ class ContainerAppLinkSettingsDialog extends ConsumerWidget {
WidgetRef ref, WidgetRef ref,
ContextAppLinkPolicy Function(ContextAppLinkPolicy current) update, ContextAppLinkPolicy Function(ContextAppLinkPolicy current) update,
) async { ) async {
await ref await ref.read(saveGeneralSettingsControllerProvider.notifier).save((
.read(saveGeneralSettingsControllerProvider.notifier) current,
.save((current) { ) {
final existing = final existing =
current.appLinkContextOverrides[contextId] ?? current.appLinkContextOverrides[contextId] ??
ContextAppLinkPolicy.blank(); ContextAppLinkPolicy.blank();
return current.copyWith.appLinkContextOverrides({ return current.copyWith.appLinkContextOverrides({
...current.appLinkContextOverrides, ...current.appLinkContextOverrides,
contextId: update(existing), contextId: update(existing),
}); });
}); });
} }
@override @override
@@ -120,7 +120,9 @@ class ContainerAppLinkSettingsDialog extends ConsumerWidget {
RadioListTile.adaptive( RadioListTile.adaptive(
value: AppLinksMode.ask, value: AppLinksMode.ask,
title: Text('Ask before opening'), 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( RadioListTile.adaptive(
value: AppLinksMode.never, 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 * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.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/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
part 'bookmarks.g.dart'; part 'bookmarks.g.dart';
/// Check if a root folder is effectively empty (has no non-root children) /// Whether a root folder holds anything worth showing.
bool _isEmptyRootFolder(BookmarkFolder folder) { ///
if (folder.children == null) return true; /// Roots always contain each other, so a root that only contains other roots
// A root folder is empty if it has no children, or only contains other root folders /// counts as empty.
return folder.children!.every( bool _hasVisibleContent(BookmarkFolder? folder) {
(child) => bookmarkRootIds.contains(child.guid), final children = folder?.children;
); if (children == null) return false;
} return children.any((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;
}
} }
@Riverpod() @Riverpod()
class BookmarkSearchResults extends _$BookmarkSearchResults { class BookmarkSearchResults extends _$BookmarkSearchResults {
final _service = GeckoBookmarksService(); 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 { Future<void> search(String query, {int limit = 10}) async {
final request = ++_latestRequest;
if (query.isEmpty) { if (query.isEmpty) {
state = []; state = [];
return; return;
@@ -143,7 +56,7 @@ class BookmarkSearchResults extends _$BookmarkSearchResults {
try { try {
final results = await _service.searchBookmarks(query, limit: limit); final results = await _service.searchBookmarks(query, limit: limit);
if (!ref.mounted) return; if (!ref.mounted || request != _latestRequest) return;
state = results state = results
.map(BookmarkItem.parseRecursive) .map(BookmarkItem.parseRecursive)
.whereType<BookmarkEntry>() .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() @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, Ref ref,
String entryGuid, { String entryGuid, {
bool hideEmptyRoots = false, bool hideEmptyRoots = false,
}) { }) async {
final bookmarksAsync = ref.watch(bookmarksRepositoryProvider); final folder = await ref.watch(bookmarkFolderProvider(entryGuid).future);
return bookmarksAsync.whenData((bookmarkNode) { if (folder == null ||
T? selectedNode; !hideEmptyRoots ||
entryGuid != BookmarkRoot.root.id ||
folder.children == null) {
return folder;
}
if (bookmarkNode != null && bookmarkNode is T) { final visible = <BookmarkItem>[];
if (bookmarkNode.guid == entryGuid) { for (final child in folder.children!) {
selectedNode = bookmarkNode; if (child is! BookmarkFolder || child.guid == BookmarkRoot.mobile.id) {
} else if (bookmarkNode case final BookmarkFolder folder) { visible.add(child);
if (folder.children != null) { continue;
selectedNode = _selectChildRecursive<T>(folder.children!, entryGuid);
}
}
} }
if (selectedNode != null) { final loaded = await ref.watch(bookmarkFolderProvider(child.guid).future);
var result = _cloneAndFilterChildrenType<T>(selectedNode); if (_hasVisibleContent(loaded)) {
visible.add(child);
// 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;
} }
}
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() @Riverpod()
class SeamlessBookmarks extends _$SeamlessBookmarks { Future<List<String>> bookmarkGuidsForUrl(Ref ref, Uri? url) async {
bool _hasSearch = false; if (url == null) return const [];
void search(String input) { ref.watch(bookmarksRepositoryProvider);
if (input.isNotEmpty) { return ref
if (!_hasSearch) { .read(bookmarksRepositoryProvider.notifier)
_hasSearch = true; .bookmarkGuidsForUrl(url);
ref.invalidateSelf(); }
}
/// Number of bookmarks inside the trees rooted at [guids].
//Don't block ///
unawaited(ref.read(bookmarksSearchProvider.notifier).search(input)); /// Used to tell the user how much a destructive action will affect.
} else if (_hasSearch) { @Riverpod()
_hasSearch = false; Future<int> bookmarkCountInTrees(Ref ref, List<String> guids) {
ref.invalidateSelf(); ref.watch(bookmarksRepositoryProvider);
} return ref
} .read(bookmarksRepositoryProvider.notifier)
.countBookmarksInTrees(guids);
@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;
}
}
} }
@@ -9,50 +9,6 @@ part of 'bookmarks.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning // 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) @ProviderFor(BookmarkSearchResults)
final bookmarkSearchResultsProvider = BookmarkSearchResultsProvider._(); final bookmarkSearchResultsProvider = BookmarkSearchResultsProvider._();
@@ -106,159 +62,70 @@ abstract class _$BookmarkSearchResults extends $Notifier<List<BookmarkEntry>> {
} }
} }
@ProviderFor(bookmarks) /// A single folder with its direct children.
final bookmarksProvider = BookmarksFamily._(); ///
/// 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> @ProviderFor(bookmarkFolder)
extends $FunctionalProvider<AsyncValue<T?>, AsyncValue<T?>, AsyncValue<T?>> final bookmarkFolderProvider = BookmarkFolderFamily._();
with $Provider<AsyncValue<T?>> {
BookmarksProvider._({ /// A single folder with its direct children.
required BookmarksFamily super.from, ///
required (String, {bool hideEmptyRoots}) super.argument, /// 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( }) : super(
retry: null, retry: null,
name: r'bookmarksProvider', name: r'bookmarkFolderProvider',
isAutoDispose: true, isAutoDispose: true,
dependencies: null, dependencies: null,
$allTransitiveDependencies: null, $allTransitiveDependencies: null,
); );
@override @override
String debugGetCreateSourceHash() => _$bookmarksHash(); String debugGetCreateSourceHash() => _$bookmarkFolderHash();
@override @override
String toString() { String toString() {
return r'bookmarksProvider' return r'bookmarkFolderProvider'
'<${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'
'' ''
'$argument'; '($argument)';
} }
@$internal @$internal
@override @override
SeamlessBookmarks create() => SeamlessBookmarks(); $FutureProviderElement<BookmarkFolder?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
/// {@macro riverpod.override_with_value} @override
Override overrideWithValue(AsyncValue<BookmarkItem?> value) { FutureOr<BookmarkFolder?> create(Ref ref) {
return $ProviderOverride( final argument = this.argument as String;
origin: this, return bookmarkFolder(ref, argument);
providerOverride: $SyncValueProvider<AsyncValue<BookmarkItem?>>(value),
);
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return other is SeamlessBookmarksProvider && other.argument == argument; return other is BookmarkFolderProvider && other.argument == argument;
} }
@override @override
@@ -267,64 +134,346 @@ final class SeamlessBookmarksProvider
} }
} }
String _$seamlessBookmarksHash() => r'240b213fa8fe595781ccc608c5d551be54c31992'; String _$bookmarkFolderHash() => r'a0a3754a2b8099415cffad6358d9388dc7c4e7bf';
final class SeamlessBookmarksFamily extends $Family /// A single folder with its direct children.
with ///
$ClassFamilyOverride< /// The load is scoped to one folder, so its cost tracks the folder being shown
SeamlessBookmarks, /// rather than the size of the library. Rebuilds whenever the repository
AsyncValue<BookmarkItem?>, /// reports a change.
AsyncValue<BookmarkItem?>,
AsyncValue<BookmarkItem?>, final class BookmarkFolderFamily extends $Family
(String, {bool hideEmptyRoots}) with $FunctionalFamilyOverride<FutureOr<BookmarkFolder?>, String> {
> { BookmarkFolderFamily._()
SeamlessBookmarksFamily._()
: super( : super(
retry: null, retry: null,
name: r'seamlessBookmarksProvider', name: r'bookmarkFolderProvider',
dependencies: null, dependencies: null,
$allTransitiveDependencies: null, $allTransitiveDependencies: null,
isAutoDispose: true, 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, { String entryGuid, {
bool hideEmptyRoots = false, bool hideEmptyRoots = false,
}) => SeamlessBookmarksProvider._( }) => BookmarkListFolderProvider._(
argument: (entryGuid, hideEmptyRoots: hideEmptyRoots), argument: (entryGuid, hideEmptyRoots: hideEmptyRoots),
from: this, from: this,
); );
@override @override
String toString() => r'seamlessBookmarksProvider'; String toString() => r'bookmarkListFolderProvider';
} }
abstract class _$SeamlessBookmarks /// Guids of the bookmarks pointing at [url], or an empty list when there are
extends $Notifier<AsyncValue<BookmarkItem?>> { /// none.
late final _$args = ref.$arg as (String, {bool hideEmptyRoots}); ///
String get entryGuid => _$args.$1; /// Backed by a storage lookup, so "is this page bookmarked?" costs the same
bool get hideEmptyRoots => _$args.hideEmptyRoots; /// 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 @override
WhenComplete runBuild() { String debugGetCreateSourceHash() => _$bookmarkGuidsForUrlHash();
final ref =
this.ref as $Ref<AsyncValue<BookmarkItem?>, AsyncValue<BookmarkItem?>>; @override
final element = String toString() {
ref.element return r'bookmarkGuidsForUrlProvider'
as $ClassProviderElement< ''
AnyNotifier<AsyncValue<BookmarkItem?>, AsyncValue<BookmarkItem?>>, '($argument)';
AsyncValue<BookmarkItem?>, }
Object?,
Object? @$internal
>; @override
return element.handleCreate( $FutureProviderElement<List<String>> $createElement(
ref, $ProviderPointer pointer,
() => build(_$args.$1, hideEmptyRoots: _$args.hideEmptyRoots), ) => $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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.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'; import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart';
part 'bookmarks.g.dart'; part 'bookmarks.g.dart';
@@ -40,7 +41,7 @@ class BookmarksRepository extends _$BookmarksRepository {
int? position, int? position,
}) async { }) async {
await _service.addItem(parentGuid, url, title, position); await _service.addItem(parentGuid, url, title, position);
ref.invalidateSelf(); _notifyChanged();
} }
Future<void> addFolder({ Future<void> addFolder({
@@ -49,7 +50,7 @@ class BookmarksRepository extends _$BookmarksRepository {
int? position, int? position,
}) async { }) async {
await _service.addFolder(parentGuid, title, position); await _service.addFolder(parentGuid, title, position);
ref.invalidateSelf(); _notifyChanged();
} }
Future<void> editBookmark({ Future<void> editBookmark({
@@ -68,7 +69,7 @@ class BookmarksRepository extends _$BookmarksRepository {
position: position, position: position,
), ),
); );
ref.invalidateSelf(); _notifyChanged();
} }
Future<void> editFolder({ Future<void> editFolder({
@@ -81,12 +82,12 @@ class BookmarksRepository extends _$BookmarksRepository {
guid, guid,
BookmarkInfo(title: title, parentGuid: parentGuid, position: position), BookmarkInfo(title: title, parentGuid: parentGuid, position: position),
); );
ref.invalidateSelf(); _notifyChanged();
} }
Future<void> delete(String guid) async { Future<void> delete(String guid) async {
await _service.deleteNode(guid); await _service.deleteNode(guid);
ref.invalidateSelf(); _notifyChanged();
} }
Future<void> moveMany({ Future<void> moveMany({
@@ -104,7 +105,7 @@ class BookmarksRepository extends _$BookmarksRepository {
BookmarkInfo(parentGuid: targetParentGuid), BookmarkInfo(parentGuid: targetParentGuid),
); );
} }
ref.invalidateSelf(); _notifyChanged();
} }
Future<void> deleteMany(Iterable<String> guids) async { Future<void> deleteMany(Iterable<String> guids) async {
@@ -115,7 +116,7 @@ class BookmarksRepository extends _$BookmarksRepository {
} }
await _service.deleteNode(guid); await _service.deleteNode(guid);
} }
ref.invalidateSelf(); _notifyChanged();
} }
Future<void> flattenFolder({required BookmarkFolder folder}) async { Future<void> flattenFolder({required BookmarkFolder folder}) async {
@@ -137,7 +138,45 @@ class BookmarksRepository extends _$BookmarksRepository {
} }
} }
await _service.deleteNode(folder.guid); 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 /// 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 { Future<void> eraseEverything(BookmarkRoot root) async {
await _service.eraseEverything(root); await _service.eraseEverything(root);
ref.invalidateSelf(); _notifyChanged();
} }
Future<int> importFromJSON(String jsonString, {bool replace = false}) async { Future<int> importFromJSON(String jsonString, {bool replace = false}) async {
final count = await _jsonUtils.importFromJSON(jsonString, replace: replace); final count = await _jsonUtils.importFromJSON(jsonString, replace: replace);
ref.invalidateSelf(); _notifyChanged();
return count; return count;
} }
Future<int> importFromHTML(String htmlString, {bool replace = false}) async { Future<int> importFromHTML(String htmlString, {bool replace = false}) async {
final count = await _htmlUtils.importFromHTML(htmlString, replace: replace); 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; return count;
} }
@@ -188,9 +253,16 @@ class BookmarksRepository extends _$BookmarksRepository {
return await _htmlUtils.exportToHTML(root: root); 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 @override
Future<BookmarkItem?> build() async { int build() => 0;
final node = await _service.getTree(BookmarkRoot.root.id, recursive: true);
return node.mapNotNull(BookmarkItem.parseRecursive); /// Signals that stored bookmarks changed, prompting dependents to reload.
} void _notifyChanged() => state++;
} }
@@ -13,7 +13,7 @@ part of 'bookmarks.dart';
final bookmarksRepositoryProvider = BookmarksRepositoryProvider._(); final bookmarksRepositoryProvider = BookmarksRepositoryProvider._();
final class BookmarksRepositoryProvider final class BookmarksRepositoryProvider
extends $AsyncNotifierProvider<BookmarksRepository, BookmarkItem?> { extends $NotifierProvider<BookmarksRepository, int> {
BookmarksRepositoryProvider._() BookmarksRepositoryProvider._()
: super( : super(
from: null, from: null,
@@ -31,22 +31,30 @@ final class BookmarksRepositoryProvider
@$internal @$internal
@override @override
BookmarksRepository create() => BookmarksRepository(); BookmarksRepository create() => BookmarksRepository();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
} }
String _$bookmarksRepositoryHash() => String _$bookmarksRepositoryHash() =>
r'2169d5b354c4a22192096451c96ab1490cf55ab4'; r'bf95d30f21773e931b12fd85d03d072d88d5b715';
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> { abstract class _$BookmarksRepository extends $Notifier<int> {
FutureOr<BookmarkItem?> build(); int build();
@$mustCallSuper @$mustCallSuper
@override @override
WhenComplete runBuild() { WhenComplete runBuild() {
final ref = this.ref as $Ref<AsyncValue<BookmarkItem?>, BookmarkItem?>; final ref = this.ref as $Ref<int, int>;
final element = final element =
ref.element ref.element
as $ClassProviderElement< as $ClassProviderElement<
AnyNotifier<AsyncValue<BookmarkItem?>, BookmarkItem?>, AnyNotifier<int, int>,
AsyncValue<BookmarkItem?>, int,
Object?, Object?,
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_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
/// Recursively sorts a bookmark tree by the given sort type. /// Sorts one folder's direct children by the given sort type.
/// Root-level built-in folders are kept in their canonical order. ///
BookmarkItem sortBookmarkTree( /// Only the level being displayed is sorted; descendants are not loaded, so
BookmarkItem item, /// 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, { BookmarkSortType sortType, {
bool isRoot = false, bool isRoot = false,
}) { }) {
if (sortType == BookmarkSortType.manual) return item; if (sortType == BookmarkSortType.manual) return children;
if (item is BookmarkFolder && item.children != null) { if (!isRoot) {
final sortedChildren = item.children!.map((child) { return [...children]..sort((a, b) => compareBookmarkItems(a, b, sortType));
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,
);
} }
return item; final rootFolders = <BookmarkItem>[];
} final rest = <BookmarkItem>[];
for (final child in children) {
/// Collects all descendant folder GUIDs from a folder (not including the folder itself). if (bookmarkRootIds.contains(child.guid)) {
Set<String> collectDescendantFolderGuids(BookmarkFolder folder) { rootFolders.add(child);
final result = <String>{}; } else {
if (folder.children != null) { rest.add(child);
for (final child in folder.children!) {
if (child is BookmarkFolder) {
result.add(child.guid);
result.addAll(collectDescendantFolderGuids(child));
}
} }
} }
return result; rest.sort((a, b) => compareBookmarkItems(a, b, sortType));
return [...rootFolders, ...rest];
} }
/// Resolves BookmarkItems from a tree by their GUIDs. /// One rendered line of the bookmark list: an item and how deep it sits.
List<BookmarkItem> resolveSelectedItems(BookmarkItem root, Set<String> guids) { ///
final result = <BookmarkItem>[]; /// The list flattens the expanded folders into rows rather than nesting
_collectByGuids(root, guids, result); /// widgets, so it can stay a `ListView.builder` and only build what is on
return result; /// 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( /// Resolves the items of [children] whose GUIDs are in [guids].
BookmarkItem item, ///
/// 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, Set<String> guids,
List<BookmarkItem> result,
) { ) {
if (guids.contains(item.guid)) { return children.where((child) => guids.contains(child.guid)).toList();
result.add(item);
}
if (item is BookmarkFolder && item.children != null) {
for (final child in item.children!) {
_collectByGuids(child, guids, result);
}
}
} }
/// Whether a folder can be flattened (non-root, has a parent, has children). /// Drops selections that sit inside another selected folder.
bool canFlattenFolder(BookmarkFolder folder) { ///
return folder.parentGuid != null && /// Expanding a folder makes its children selectable alongside it, and acting on
!bookmarkRootIds.contains(folder.guid) && /// both would move a child out of the very folder that just moved, or delete it
folder.children != null && /// twice. A folder's descendants are exactly the rows that follow it until the
folder.children!.isNotEmpty; /// 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. for (final row in rows) {
/// This prevents double-applying moves when both a folder and its children are selected. if (skipBelowDepth >= 0) {
Set<String> normalizeSelection(BookmarkItem root, Set<String> selectedGuids) { if (row.depth > skipBelowDepth) continue;
final items = resolveSelectedItems(root, selectedGuids); skipBelowDepth = -1;
final folderGuidsToRemove = <String>{};
for (final item in items) {
if (item is BookmarkFolder) {
_collectAllDescendantGuids(item, folderGuidsToRemove);
} }
}
return selectedGuids.difference(folderGuidsToRemove); if (row.isPlaceholder) continue;
}
void _collectAllDescendantGuids(BookmarkFolder folder, Set<String> result) { if (selected.contains(row.item.guid)) {
if (folder.children != null) { result.add(row.item.guid);
for (final child in folder.children!) { if (row.item is BookmarkFolder) {
result.add(child.guid); skipBelowDepth = row.depth;
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);
} }
} }
} }
collect(root);
return result; 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'; 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?>( return showDialog<bool?>(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return AlertDialog( return AlertDialog(
icon: const Icon(Icons.warning), icon: const Icon(Icons.warning),
title: const Text('Delete Folder'), title: const Text('Delete Folder'),
content: const Text( content: Text(switch (bookmarkCount) {
'Are you sure you want to delete this Folder including all bookmarks?', 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>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () { onPressed: () {
@@ -17,10 +17,9 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:animated_tree_view/animated_tree_view.dart';
import 'package:convert/convert.dart'; import 'package:convert/convert.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.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/delete_folder_dialog.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/import_bookmarks_dialog.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/import_bookmarks_dialog.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_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/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/menu_controller.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/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/ui_helper.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 { class BookmarkListScreen extends HookConsumerWidget {
final String entryGuid; final String entryGuid;
@@ -59,18 +68,15 @@ class BookmarkListScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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 hideEmptyRoots = useState(true);
final uiState = ref.watch(bookmarkListUiStateProvider); final uiState = ref.watch(bookmarkListUiStateProvider);
final uiStateNotifier = ref.read(bookmarkListUiStateProvider.notifier); 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, entryGuid,
hideEmptyRoots: hideEmptyRoots.value, hideEmptyRoots: hideEmptyRoots.value,
), ),
@@ -78,25 +84,46 @@ class BookmarkListScreen extends HookConsumerWidget {
final textFilterEnabled = useState(false); final textFilterEnabled = useState(false);
final textFilterController = useTextEditingController(); final textFilterController = useTextEditingController();
final searchQuery = useState('');
useOnListenableChange(textFilterController, () { useOnListenableChange(textFilterController, () {
if (ref.exists( searchQuery.value = textFilterController.text;
seamlessBookmarksProvider( // Searching goes through storage rather than filtering a loaded tree, so
entryGuid, // it reaches bookmarks this screen never loaded.
hideEmptyRoots: hideEmptyRoots.value, unawaited(
),
)) {
ref ref
.read( .read(bookmarkSearchResultsProvider.notifier)
seamlessBookmarksProvider( .search(textFilterController.text, limit: _searchResultLimit),
entryGuid, );
hideEmptyRoots: hideEmptyRoots.value,
).notifier,
)
.search(textFilterController.text);
}
}); });
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( return PopScope(
canPop: !uiState.selectionMode, canPop: !uiState.selectionMode,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
@@ -106,13 +133,18 @@ class BookmarkListScreen extends HookConsumerWidget {
}, },
child: Scaffold( child: Scaffold(
appBar: uiState.selectionMode appBar: uiState.selectionMode
? _buildSelectionAppBar(context, ref, uiState, uiStateNotifier) ? _buildSelectionAppBar(
context,
ref,
uiState,
uiStateNotifier,
rows,
)
: _buildNormalAppBar( : _buildNormalAppBar(
context, context,
ref, ref,
treeController,
expandedGuids,
hideEmptyRoots, hideEmptyRoots,
expandedGuids,
textFilterEnabled, textFilterEnabled,
textFilterController, textFilterController,
uiStateNotifier, uiStateNotifier,
@@ -121,117 +153,173 @@ class BookmarkListScreen extends HookConsumerWidget {
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.only(left: 12.0), padding: const EdgeInsets.only(left: 12.0),
child: bookmarkList.when( child: isSearching
skipLoadingOnReload: true, ? _buildRowList(
data: (list) { context,
final sortedList = list != null ref,
? sortBookmarkTree( rows,
list, uiState,
uiState.sortType, uiStateNotifier,
isRoot: entryGuid == BookmarkRoot.root.id, expandedGuids,
) emptyLabel: searchSuppressedByFilter
: null; ? 'Search matches bookmarks, which "Folders Only" is '
'hiding'
TreeNode<BookmarkItem> addChildren( : 'No bookmarks match "${searchQuery.value}"',
TreeNode<BookmarkItem>? parent, )
BookmarkItem item, : folderAsync.when(
) { skipLoadingOnReload: true,
if (uiState.foldersOnly && item is BookmarkEntry) { data: (_) => _buildRowList(
return parent ?? TreeNode<BookmarkItem>.root(); context,
} ref,
rows,
final node = TreeNode( uiState,
key: item.guid, uiStateNotifier,
data: item, expandedGuids,
parent: parent, emptyLabel: 'Empty',
); ),
final targetNode = (parent?..add(node)) ?? node; error: (error, stackTrace) => Center(
child: FailureWidget(
if (item is BookmarkFolder && item.children != null) { title: 'Failed to load Bookmarks',
for (final child in item.children!) { exception: error,
addChildren(node, child); onRetry: () {
} ref.invalidate(bookmarkFolderProvider(entryGuid));
} },
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,
),
), ),
builder: (context, item) { ),
final BookmarkItem? data = item.data; loading: () =>
final isSelected = uiState.selectedGuids.contains( const Center(child: CircularProgressIndicator()),
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()),
),
), ),
), ),
), ),
); );
} }
/// 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 -- // -- App Bars --
PreferredSizeWidget _buildSelectionAppBar( PreferredSizeWidget _buildSelectionAppBar(
@@ -239,6 +327,7 @@ class BookmarkListScreen extends HookConsumerWidget {
WidgetRef ref, WidgetRef ref,
BookmarkListUiState uiState, BookmarkListUiState uiState,
BookmarkListUiStateNotifier uiStateNotifier, BookmarkListUiStateNotifier uiStateNotifier,
List<BookmarkRow> rows,
) { ) {
final count = uiState.selectedGuids.length; final count = uiState.selectedGuids.length;
return AppBar( return AppBar(
@@ -252,19 +341,21 @@ class BookmarkListScreen extends HookConsumerWidget {
icon: const Icon(MdiIcons.tabPlus), icon: const Icon(MdiIcons.tabPlus),
tooltip: 'Open in background', tooltip: 'Open in background',
onPressed: count > 0 onPressed: count > 0
? () => _bulkOpenInBackground(context, ref, uiState) ? () => _bulkOpenInBackground(context, ref, uiState, rows)
: null, : null,
), ),
IconButton( IconButton(
icon: const Icon(MdiIcons.folderMove), icon: const Icon(MdiIcons.folderMove),
tooltip: 'Move selected', tooltip: 'Move selected',
onPressed: count > 0 ? () => _bulkMove(context, ref, uiState) : null, onPressed: count > 0
? () => _bulkMove(context, ref, uiState, rows)
: null,
), ),
IconButton( IconButton(
icon: const Icon(MdiIcons.delete), icon: const Icon(MdiIcons.delete),
tooltip: 'Delete selected', tooltip: 'Delete selected',
onPressed: count > 0 onPressed: count > 0
? () => _bulkDelete(context, ref, uiState, uiStateNotifier) ? () => _bulkDelete(context, ref, uiState, uiStateNotifier, rows)
: null, : null,
), ),
], ],
@@ -274,10 +365,8 @@ class BookmarkListScreen extends HookConsumerWidget {
AppBar _buildNormalAppBar( AppBar _buildNormalAppBar(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
ValueNotifier<TreeViewController<BookmarkItem, TreeNode<BookmarkItem>>?>
treeController,
ObjectRef<Set<String>> expandedGuids,
ValueNotifier<bool> hideEmptyRoots, ValueNotifier<bool> hideEmptyRoots,
ValueNotifier<Set<String>> expandedGuids,
ValueNotifier<bool> textFilterEnabled, ValueNotifier<bool> textFilterEnabled,
TextEditingController textFilterController, TextEditingController textFilterController,
BookmarkListUiStateNotifier uiStateNotifier, BookmarkListUiStateNotifier uiStateNotifier,
@@ -315,21 +404,41 @@ class BookmarkListScreen extends HookConsumerWidget {
), ),
MenuAnchor( MenuAnchor(
menuChildren: [ 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( SubmenuButton(
leadingIcon: const Icon(MdiIcons.eye), leadingIcon: const Icon(MdiIcons.eye),
menuChildren: [ menuChildren: [
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(MdiIcons.expandAll), leadingIcon: const Icon(MdiIcons.collapseAll),
child: const Text('Expand All'), onPressed: expandedGuids.value.isEmpty
onPressed: () { ? null
final controller = treeController.value; : () => expandedGuids.value = <String>{},
if (controller != null) { child: const Text('Collapse All'),
controller.expandAllChildren(
controller.tree,
recursive: true,
);
}
},
), ),
if (entryGuid == BookmarkRoot.root.id) if (entryGuid == BookmarkRoot.root.id)
MenuItemButton( MenuItemButton(
@@ -353,13 +462,10 @@ class BookmarkListScreen extends HookConsumerWidget {
? MdiIcons.bookmarkMultiple ? MdiIcons.bookmarkMultiple
: MdiIcons.folderOutline, : MdiIcons.folderOutline,
), ),
onPressed: uiStateNotifier.toggleFoldersOnly,
child: Text( child: Text(
uiState.foldersOnly ? 'Show Bookmarks' : 'Folders Only', uiState.foldersOnly ? 'Show Bookmarks' : 'Folders Only',
), ),
onPressed: () {
_snapshotExpansion(treeController.value, expandedGuids);
uiStateNotifier.toggleFoldersOnly();
},
), ),
], ],
child: const Text('Visibility'), child: const Text('Visibility'),
@@ -373,10 +479,7 @@ class BookmarkListScreen extends HookConsumerWidget {
? const Icon(Icons.check) ? const Icon(Icons.check)
: const SizedBox(width: 24), : const SizedBox(width: 24),
child: Text(sortType.label), child: Text(sortType.label),
onPressed: () { onPressed: () => uiStateNotifier.setSortType(sortType),
_snapshotExpansion(treeController.value, expandedGuids);
uiStateNotifier.setSortType(sortType);
},
), ),
], ],
child: const Text('Sort'), child: const Text('Sort'),
@@ -387,12 +490,14 @@ class BookmarkListScreen extends HookConsumerWidget {
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(MdiIcons.codeJson), leadingIcon: const Icon(MdiIcons.codeJson),
child: const Text('JSON'), child: const Text('JSON'),
onPressed: () => _handleImport(context, ref, 'json'), onPressed: () =>
_handleImport(context, ref, BookmarkImportFormat.json),
), ),
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(MdiIcons.xml), leadingIcon: const Icon(MdiIcons.xml),
child: const Text('HTML'), child: const Text('HTML'),
onPressed: () => _handleImport(context, ref, 'html'), onPressed: () =>
_handleImport(context, ref, BookmarkImportFormat.html),
), ),
], ],
child: const Text('Import'), child: const Text('Import'),
@@ -438,7 +543,6 @@ class BookmarkListScreen extends HookConsumerWidget {
BookmarkListUiState uiState, BookmarkListUiState uiState,
BookmarkListUiStateNotifier uiStateNotifier, BookmarkListUiStateNotifier uiStateNotifier,
bool isSelected, bool isSelected,
BookmarkItem? rootItem,
) { ) {
if (uiState.selectionMode) { if (uiState.selectionMode) {
return ListTile( return ListTile(
@@ -463,7 +567,7 @@ class BookmarkListScreen extends HookConsumerWidget {
key: ValueKey(bookmark.guid), key: ValueKey(bookmark.guid),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: UrlIcon([bookmark.url], iconSize: 34.0), 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), title: Text(bookmark.title, maxLines: 3, overflow: TextOverflow.ellipsis),
subtitle: UriBreadcrumb(uri: bookmark.url), subtitle: UriBreadcrumb(uri: bookmark.url),
onTap: () async { onTap: () async {
@@ -487,7 +591,6 @@ class BookmarkListScreen extends HookConsumerWidget {
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
BookmarkEntry bookmark, BookmarkEntry bookmark,
BookmarkItem? rootItem,
) { ) {
return HookBuilder( return HookBuilder(
builder: (context) { builder: (context) {
@@ -605,26 +708,21 @@ class BookmarkListScreen extends HookConsumerWidget {
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
BookmarkFolder folder, { BookmarkFolder folder, {
required bool isLeaf,
required bool isExpanded,
required BookmarkListUiState uiState, required BookmarkListUiState uiState,
required BookmarkListUiStateNotifier uiStateNotifier, required BookmarkListUiStateNotifier uiStateNotifier,
required bool isSelected, required bool isSelected,
required BookmarkItem? rootItem, required bool isExpanded,
required VoidCallback onToggleExpanded,
}) { }) {
final isRoot = bookmarkRootIds.contains(folder.guid); final isRoot = bookmarkRootIds.contains(folder.guid);
if (uiState.selectionMode) { if (uiState.selectionMode) {
return Padding( return Padding(
key: ValueKey(folder.guid), key: ValueKey(folder.guid),
padding: isLeaf padding: const EdgeInsets.only(right: 4.0),
? const EdgeInsets.only(right: 4.0)
: const EdgeInsets.only(right: 42.0),
child: ListTile( child: ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: isExpanded leading: Icon(isExpanded ? MdiIcons.folderOpen : MdiIcons.folder),
? const Icon(MdiIcons.folderOpen)
: const Icon(MdiIcons.folder),
title: Text(folder.title), title: Text(folder.title),
trailing: isRoot trailing: isRoot
? null ? null
@@ -642,18 +740,21 @@ class BookmarkListScreen extends HookConsumerWidget {
return Padding( return Padding(
key: ValueKey(folder.guid), key: ValueKey(folder.guid),
padding: isLeaf padding: const EdgeInsets.only(right: 4.0),
? const EdgeInsets.only(right: 4.0)
: const EdgeInsets.only(right: 42.0),
child: HookBuilder( child: HookBuilder(
builder: (context) { builder: (context) {
final controller = useMenuController(); final controller = useMenuController();
return ListTile( return ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: isExpanded // Whether a folder has children is unknown until it is opened, so
? const Icon(MdiIcons.folderOpen) // every folder offers the toggle. Tapping the row still navigates
: const Icon(MdiIcons.folder), // 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), title: Text(folder.title),
trailing: MenuAnchor( trailing: MenuAnchor(
controller: controller, controller: controller,
@@ -777,15 +878,14 @@ class BookmarkListScreen extends HookConsumerWidget {
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
BookmarkListUiState uiState, BookmarkListUiState uiState,
List<BookmarkRow> rows,
) async { ) async {
final bookmarkData = ref.read( // Not normalised: opening is additive, so a bookmark selected inside an
seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true), // also-selected folder should still open rather than be dropped.
); final entries = resolveSelectedItems([
final root = bookmarkData.value; for (final row in rows)
if (root == null) return; if (!row.isPlaceholder) row.item,
], uiState.selectedGuids).whereType<BookmarkEntry>().toList();
final items = resolveSelectedItems(root, uiState.selectedGuids);
final entries = items.whereType<BookmarkEntry>().toList();
if (entries.isEmpty) { if (entries.isEmpty) {
if (context.mounted) { if (context.mounted) {
@@ -820,18 +920,14 @@ class BookmarkListScreen extends HookConsumerWidget {
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
BookmarkListUiState uiState, BookmarkListUiState uiState,
List<BookmarkRow> rows,
) async { ) async {
final bookmarkData = ref.read( final items = _selectedItems(rows, uiState.selectedGuids);
seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true), if (items.isEmpty) return;
);
final root = bookmarkData.value;
if (root == null) return;
final items = resolveSelectedItems(root, uiState.selectedGuids); // A folder cannot be moved inside itself, so exclude each selected folder
// and its descendants. Fetched from storage rather than read off the list,
// Build exclusion set from selected folders and their full descendant // which only knows the level currently on screen.
// trees fetched from storage, so hidden folders (e.g. filtered by search)
// are still properly excluded as move targets.
final repo = ref.read(bookmarksRepositoryProvider.notifier); final repo = ref.read(bookmarksRepositoryProvider.notifier);
final excludeGuids = <String>{}; final excludeGuids = <String>{};
for (final item in items) { for (final item in items) {
@@ -850,18 +946,12 @@ class BookmarkListScreen extends HookConsumerWidget {
if (targetGuid == null) return; if (targetGuid == null) return;
// Normalize selection to avoid double-moves await repo.moveMany(items: items, targetParentGuid: targetGuid);
final normalizedGuids = normalizeSelection(root, uiState.selectedGuids);
final normalizedItems = resolveSelectedItems(root, normalizedGuids);
await ref
.read(bookmarksRepositoryProvider.notifier)
.moveMany(items: normalizedItems, targetParentGuid: targetGuid);
ref.read(bookmarkListUiStateProvider.notifier).exitSelectionMode(); ref.read(bookmarkListUiStateProvider.notifier).exitSelectionMode();
if (context.mounted) { 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, WidgetRef ref,
BookmarkListUiState uiState, BookmarkListUiState uiState,
BookmarkListUiStateNotifier uiStateNotifier, BookmarkListUiStateNotifier uiStateNotifier,
List<BookmarkRow> rows,
) async { ) async {
final bookmarkData = ref.read( final items = _selectedItems(rows, uiState.selectedGuids);
seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true), if (items.isEmpty) return;
);
final root = bookmarkData.value;
if (root == null) return;
final items = resolveSelectedItems(root, uiState.selectedGuids); final folderGuids = items
final hasFolders = items.any((item) => item is BookmarkFolder); .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; if (!context.mounted) return;
final result = await (hasFolders final result = await (folderGuids.isNotEmpty
? showDeleteFolderDialog(context) ? showDeleteFolderDialog(context, bookmarkCount: nestedCount)
: showDeleteBookmarkDialog(context)); : showDeleteBookmarkDialog(context));
if (result != true) return; if (result != true) return;
// Normalize to avoid deleting children whose parent folder is also being deleted final guids = items.map((item) => item.guid).toSet();
final normalizedGuids = normalizeSelection(root, uiState.selectedGuids); await ref.read(bookmarksRepositoryProvider.notifier).deleteMany(guids);
await ref
.read(bookmarksRepositoryProvider.notifier)
.deleteMany(normalizedGuids);
uiStateNotifier.exitSelectionMode(); uiStateNotifier.exitSelectionMode();
if (context.mounted) { if (context.mounted) {
showInfoMessage(context, 'Deleted ${normalizedGuids.length} items'); showInfoMessage(context, 'Deleted ${guids.length} items');
} }
} }
// -- Tree Expansion State Helpers -- /// The selected items, with anything nested inside another selected folder
/// dropped.
/// Collects the GUIDs of all currently expanded nodes from the tree. ///
void _snapshotExpansion( /// Expanding a folder puts its children on screen next to it, so a user can
TreeViewController<BookmarkItem, TreeNode<BookmarkItem>>? controller, /// select both — acting on each in turn would move a child out of the folder
ObjectRef<Set<String>> expandedGuids, /// that just moved, or delete it a second time.
List<BookmarkItem> _selectedItems(
List<BookmarkRow> rows,
Set<String> selectedGuids,
) { ) {
if (controller == null) return; final guids = normalizeSelection(rows, selectedGuids);
final guids = <String>{};
_collectExpandedGuids(controller.tree, guids);
expandedGuids.value = guids;
}
void _collectExpandedGuids(TreeNode<BookmarkItem> node, Set<String> guids) { return resolveSelectedItems([
if (node.isExpanded && node.key != INode.ROOT_KEY) { for (final row in rows)
guids.add(node.key); if (!row.isPlaceholder) row.item,
} ], guids);
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);
}
} }
// -- Tab Opening Helper -- // -- Tab Opening Helper --
@@ -995,12 +1060,14 @@ class BookmarkListScreen extends HookConsumerWidget {
Future<void> _handleImport( Future<void> _handleImport(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
String format, BookmarkImportFormat format,
) async { ) async {
try { try {
final result = await FilePicker.pickFiles( final result = await FilePicker.pickFiles(
type: FileType.custom, type: FileType.custom,
allowedExtensions: format == 'json' ? ['json'] : ['html', 'htm'], allowedExtensions: format == BookmarkImportFormat.json
? ['json']
: ['html', 'htm'],
); );
if (result == null || result.files.isEmpty) return; if (result == null || result.files.isEmpty) return;
@@ -1019,12 +1086,15 @@ class BookmarkListScreen extends HookConsumerWidget {
final shouldReplace = await showImportBookmarksDialog(context); final shouldReplace = await showImportBookmarksDialog(context);
if (shouldReplace == null) return; // User cancelled dialog if (shouldReplace == null) return; // User cancelled dialog
final content = await File(file.path!).readAsString(); // Reading and parsing happen in a background isolate, so a large file
final repository = ref.read(bookmarksRepositoryProvider.notifier); // does not freeze the UI while it is being processed.
final count = await ref
final count = format == 'json' .read(bookmarksRepositoryProvider.notifier)
? await repository.importFromJSON(content, replace: shouldReplace) .importFromFile(
: await repository.importFromHTML(content, replace: shouldReplace); path: file.path!,
format: format,
replace: shouldReplace,
);
if (context.mounted) { if (context.mounted) {
showInfoMessage(context, 'Imported $count bookmarks successfully'); showInfoMessage(context, 'Imported $count bookmarks successfully');
@@ -17,7 +17,6 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * 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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.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/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
import 'package:weblibre/presentation/widgets/failure_widget.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 /// A widget that displays a tree view of bookmark folders and allows the user
/// to select a parent folder. /// 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 /// 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 { class FolderTreePicker extends HookConsumerWidget {
/// The currently selected folder GUID /// The currently selected folder GUID
final ValueNotifier<String> selectedFolderGuid; final ValueNotifier<String> selectedFolderGuid;
@@ -52,99 +59,39 @@ class FolderTreePicker extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Folder', style: Theme.of(context).textTheme.labelMedium), Text('Folder', style: Theme.of(context).textTheme.labelMedium),
folderList.when( rootFolder.when(
skipLoadingOnReload: true, skipLoadingOnReload: true,
data: (list) { data: (folder) {
TreeNode<BookmarkFolder> addChildren( if (folder == null) return const SizedBox.shrink();
TreeNode<BookmarkFolder>? parent,
BookmarkFolder item,
) {
final node = TreeNode(key: item.guid, data: item, parent: parent);
final targetNode = (parent?..add(node)) ?? node;
if (item.children != null) { return Column(
for (final child in item.children!) { mainAxisSize: MainAxisSize.min,
// Skip excluded folders and their descendants children: [
if (child is BookmarkFolder && if (entryGuid != BookmarkRoot.root.id)
!excludeFolderGuids.contains(child.guid)) { _FolderRow(
addChildren(node, child); folder: folder,
} depth: 0,
} expandedGuids: expandedGuids,
} selectedFolderGuid: selectedFolderGuid,
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,
),
), ),
builder: (context, item) { _FolderChildren(
final isSelected = item.data?.guid == selectedFolderGuid.value; parentGuid: folder.guid,
depth: entryGuid != BookmarkRoot.root.id ? 1 : 0,
// BookmarkRoot.root cannot be selected as a parent expandedGuids: expandedGuids,
final isRootFolder = item.data?.guid == BookmarkRoot.root.id; selectedFolderGuid: selectedFolderGuid,
excludeFolderGuids: excludeFolderGuids,
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(),
},
);
},
); );
}, },
error: (error, stackTrace) => Center( error: (error, stackTrace) => Center(
@@ -152,7 +99,7 @@ class FolderTreePicker extends HookConsumerWidget {
title: 'Failed to load Bookmark Folders', title: 'Failed to load Bookmark Folders',
exception: error, exception: error,
onRetry: () { 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:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:html/dom.dart' as dom; import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser; 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 _containerNormal = 0;
const _containerToolbar = 1; const _containerToolbar = 1;
@@ -30,18 +31,22 @@ const _containerPlaces = 4;
const _exportIndent = ' '; const _exportIndent = ' ';
class _Frame { /// Parses a Netscape bookmark file into an [ImportBookmarkTree].
final Map<String, dynamic> folder; ///
int containerNesting = 0; /// Pure and free of platform channels, so it is safe to run inside an isolate;
int lastContainerType = _containerNormal; /// see `bookmark_import_isolate.dart`.
String previousText = ''; ///
bool inDescription = false; /// When [preserveRootFolders] is set, folders carrying Firefox root markers
String? previousLink; /// (`PERSONAL_TOOLBAR_FOLDER`, `BOOKMARKS_MENU`, `UNFILED_BOOKMARKS_FOLDER`,
Map<String, dynamic>? previousItem; /// `PLACES_ROOT`) at the top level are routed to the matching Places root
DateTime? previousDateAdded; /// instead of being imported as ordinary folders. Everything else lands under
DateTime? previousLastModifiedDate; /// [BookmarkRoot.menu].
ImportBookmarkTree parseBookmarkHtml(
_Frame(this.folder); String htmlString, {
required bool preserveRootFolders,
}) {
final parser = _BookmarkHtmlParser(preserveRootFolders: preserveRootFolders);
return parser.parse(htmlString);
} }
class BookmarkHTMLUtils { class BookmarkHTMLUtils {
@@ -50,9 +55,9 @@ class BookmarkHTMLUtils {
BookmarkHTMLUtils(this._service); BookmarkHTMLUtils(this._service);
/// Import bookmarks from HTML string /// Import bookmarks from HTML string
Future<int> importFromHTML(String htmlString, {bool replace = false}) async { Future<int> importFromHTML(String htmlString, {bool replace = false}) {
final importer = _BookmarkImporter(_service, replace); final tree = parseBookmarkHtml(htmlString, preserveRootFolders: replace);
return await importer.importFromHTML(htmlString); return BookmarkTreeImporter(_service).import(tree, replace: replace);
} }
/// Export bookmarks to HTML string /// Export bookmarks to HTML string
@@ -67,27 +72,80 @@ class BookmarkHTMLUtils {
} }
} }
class _BookmarkImporter { /// A node collected while parsing.
final GeckoBookmarksService _service; ///
final bool _isImportDefaults; /// Folders stay mutable until their closing tag so children can be appended in
final Map<String, dynamic> _bookmarkTree; /// 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 = []; final List<_Frame> _frames = [];
_BookmarkImporter(this._service, this._isImportDefaults) int _bookmarkCount = 0;
: _bookmarkTree = { int _folderCount = 0;
'type': BookmarkNodeType.folder.index, int _separatorCount = 0;
'guid': BookmarkRoot.menu.id, int _skippedUrlCount = 0;
'children': <Map<String, dynamic>>[],
} { _BookmarkHtmlParser({required this.preserveRootFolders}) {
_frames.add(_Frame(_bookmarkTree)); _frames.add(_Frame(_root));
} }
_Frame get _curFrame => _frames.last; _Frame get _curFrame => _frames.last;
Future<int> importFromHTML(String htmlString) async { ImportBookmarkTree parse(String htmlString) {
final document = html_parser.parse(htmlString); final document = html_parser.parse(htmlString);
_walkTreeForImport(document.body); _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) { dom.Node? _nextSibling(dom.Node node) {
@@ -186,29 +244,30 @@ class _BookmarkImporter {
} }
void _handleHeadBegin(dom.Element element) { void _handleHeadBegin(dom.Element element) {
final frame = _curFrame; // A heading that arrives while the current folder never opened its `<DL>`
// closes that folder first. Everything below must describe the *new*
frame.previousLink = null; // heading, so the frame is only captured once the stack has settled.
frame.lastContainerType = _containerNormal; if (_curFrame.containerNesting == 0 && _frames.length > 1) {
_popFrame();
if (frame.containerNesting == 0 && _frames.length > 1) {
_frames.removeLast();
} }
final frame = _curFrame;
frame.lastContainerType = _containerNormal;
if (element.attributes.containsKey('personal_toolbar_folder')) { if (element.attributes.containsKey('personal_toolbar_folder')) {
if (_isImportDefaults) { if (preserveRootFolders) {
frame.lastContainerType = _containerToolbar; frame.lastContainerType = _containerToolbar;
} }
} else if (element.attributes.containsKey('bookmarks_menu')) { } else if (element.attributes.containsKey('bookmarks_menu')) {
if (_isImportDefaults) { if (preserveRootFolders) {
frame.lastContainerType = _containerMenu; frame.lastContainerType = _containerMenu;
} }
} else if (element.attributes.containsKey('unfiled_bookmarks_folder')) { } else if (element.attributes.containsKey('unfiled_bookmarks_folder')) {
if (_isImportDefaults) { if (preserveRootFolders) {
frame.lastContainerType = _containerUnfiled; frame.lastContainerType = _containerUnfiled;
} }
} else if (element.attributes.containsKey('places_root')) { } else if (element.attributes.containsKey('places_root')) {
if (_isImportDefaults) { if (preserveRootFolders) {
frame.lastContainerType = _containerPlaces; frame.lastContainerType = _containerPlaces;
} }
} else { } else {
@@ -227,67 +286,76 @@ class _BookmarkImporter {
} }
void _handleLinkBegin(dom.Element element) { 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 = ''; 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 href = element.attributes['href']?.trim();
final dateAdded = element.attributes['add_date']?.trim(); final dateAdded = element.attributes['add_date']?.trim();
final lastModified = element.attributes['last_modified']?.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) { if (href == null || href.isEmpty) {
frame.previousLink = null; _skippedUrlCount++;
return; return;
} }
final Uri url;
try { try {
final uri = Uri.parse(href); final uri = Uri.parse(href);
if (!uri.hasScheme) { if (!uri.hasScheme) {
frame.previousLink = null; _skippedUrlCount++;
return; return;
} }
frame.previousLink = uri.toString(); url = uri;
} catch (e) { } catch (e) {
frame.previousLink = null; _skippedUrlCount++;
return; return;
} }
final bookmark = <String, dynamic>{'url': frame.previousLink}; final lastModifiedDate = lastModified != null
? _convertImportedDateToInternalDate(lastModified)
: null;
if (dateAdded != null) { frame.pendingItem = _PendingItem(
bookmark['dateAdded'] = _convertImportedDateToInternalDate( url: url,
dateAdded, // A bookmark that only records a modification time is treated as having
).millisecondsSinceEpoch; // been added then, matching Firefox's own importer.
} dateAdded: dateAdded != null
if (lastModified != null) { ? _convertImportedDateToInternalDate(dateAdded)
bookmark['lastModified'] = _convertImportedDateToInternalDate( : lastModifiedDate,
lastModified, lastModified: lastModifiedDate,
).millisecondsSinceEpoch; );
} }
if (dateAdded == null && lastModified != null) {
bookmark['dateAdded'] = bookmark['lastModified'];
}
if (tags != null && tags.isNotEmpty) { /// Materialises the frame's open bookmark, if any, using [title].
bookmark['tags'] = tags; void _flushPendingItem({String title = ''}) {
} final frame = _curFrame;
if (keyword != null && keyword.isNotEmpty) { final pending = frame.pendingItem;
bookmark['keyword'] = keyword; if (pending == null) return;
}
if (postData != null && postData.isNotEmpty) {
bookmark['postData'] = postData;
}
if (lastCharset != null && lastCharset.isNotEmpty) {
bookmark['charset'] = lastCharset;
}
(frame.folder['children'] as List).add(bookmark); frame.pendingItem = null;
frame.previousItem = bookmark; 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() { void _handleContainerBegin() {
@@ -300,7 +368,7 @@ class _BookmarkImporter {
frame.containerNesting--; frame.containerNesting--;
} }
if (_frames.length > 1 && frame.containerNesting == 0) { if (_frames.length > 1 && frame.containerNesting == 0) {
_frames.removeLast(); _popFrame();
} }
} }
@@ -310,163 +378,111 @@ class _BookmarkImporter {
void _handleLinkEnd() { void _handleLinkEnd() {
final frame = _curFrame; final frame = _curFrame;
frame.previousText = frame.previousText.trim(); _flushPendingItem(title: frame.previousText.trim());
if (frame.previousItem != null) {
frame.previousItem!['title'] = frame.previousText;
}
frame.previousText = ''; frame.previousText = '';
} }
void _handleSeparator() { void _handleSeparator() {
final frame = _curFrame; _flushPendingItem();
final separator = <String, dynamic>{ _curFrame.folder.children.add(_ParsedLeaf(const ImportBookmarkSeparator()));
'type': BookmarkNodeType.separator.index, _separatorCount++;
};
(frame.folder['children'] as List).add(separator);
frame.previousItem = separator;
} }
void _newFrame() { void _newFrame() {
_flushPendingItem();
final frame = _curFrame; final frame = _curFrame;
final containerTitle = frame.previousText; final containerTitle = frame.previousText;
frame.previousText = ''; frame.previousText = '';
final containerType = frame.lastContainerType;
final folder = <String, dynamic>{ final folder = _ParsedFolder();
'children': <Map<String, dynamic>>[],
'type': BookmarkNodeType.folder.index,
};
switch (containerType) { switch (frame.lastContainerType) {
case _containerNormal: case _containerNormal:
folder['title'] = containerTitle; folder.title = containerTitle;
case _containerPlaces: case _containerPlaces:
folder['guid'] = BookmarkRoot.root.id; folder.rootGuid = BookmarkRoot.root.id;
case _containerMenu: case _containerMenu:
folder['guid'] = BookmarkRoot.menu.id; folder.rootGuid = BookmarkRoot.menu.id;
case _containerUnfiled: case _containerUnfiled:
folder['guid'] = BookmarkRoot.unfiled.id; folder.rootGuid = BookmarkRoot.unfiled.id;
case _containerToolbar: 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)); _frames.add(_Frame(folder));
} }
DateTime _convertImportedDateToInternalDate(String seconds) { DateTime _convertImportedDateToInternalDate(String seconds) {
try { final parsed = int.tryParse(seconds);
final parsed = int.tryParse(seconds); if (parsed != null) {
if (parsed != null) { return DateTime.fromMillisecondsSinceEpoch(parsed * 1000);
return DateTime.fromMillisecondsSinceEpoch(parsed * 1000);
}
} catch (e) {
// Fall through
} }
return DateTime.now(); return DateTime.now();
} }
List<Map<String, dynamic>> _getBookmarkTrees() { /// Groups the parsed top level into per-root sections.
if (!_isImportDefaults) { ///
return [_bookmarkTree]; /// 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 sections = <String, List<ImportBookmarkNode>>{
final children = _bookmarkTree['children'] as List<Map<String, dynamic>>; 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) { return ImportBookmarkTree(
final guid = child['guid'] as String?; sections: sections,
if (guid != null && bookmarkRootIds.contains(guid)) { stats: ImportBookmarkStats(
bookmarkTrees.add(child); bookmarkCount: _bookmarkCount,
return false; folderCount: _folderCount,
} separatorCount: _separatorCount,
return true; skippedUrlCount: _skippedUrlCount,
}).toList(); ),
);
return bookmarkTrees;
} }
Future<int> _importBookmarks() async { ImportBookmarkNode _toImmutable(_ParsedNode node) {
if (_isImportDefaults) { switch (node) {
// Delete bookmarks from each root folder (except root itself to avoid errors) case final _ParsedLeaf leaf:
for (final root in BookmarkRoot.values) { return leaf.node;
if (root != BookmarkRoot.root) { case final _ParsedFolder folder:
await _service.eraseEverything(root); _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 'dart:convert';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:weblibre/core/logger.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'; 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 { class BookmarkJSONUtils {
final GeckoBookmarksService _service; final GeckoBookmarksService _service;
@@ -33,18 +53,10 @@ class BookmarkJSONUtils {
/// Import bookmarks from JSON string /// Import bookmarks from JSON string
Future<int> importFromJSON(String jsonString, {bool replace = false}) async { Future<int> importFromJSON(String jsonString, {bool replace = false}) async {
try { try {
final data = jsonDecode(jsonString); final tree = parseBookmarkJson(jsonString);
return await BookmarkTreeImporter(
if (data is! Map<String, dynamic>) { _service,
throw Exception('Invalid JSON format'); ).import(tree, replace: replace);
}
final children = data['children'] as List?;
if (children == null || children.isEmpty) {
return 0;
}
return await _import(data, replace: replace);
} catch (ex) { } catch (ex) {
logger.e('Failed to import bookmarks: $ex'); logger.e('Failed to import bookmarks: $ex');
rethrow; rethrow;
@@ -63,206 +75,6 @@ class BookmarkJSONUtils {
return _nodeToJson(tree, isRoot: true); 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) /// Convert BookmarkNode to JSON (for export)
Map<String, dynamic>? _nodeToJson(BookmarkNode node, {bool isRoot = false}) { Map<String, dynamic>? _nodeToJson(BookmarkNode node, {bool isRoot = false}) {
// Skip invalid bookmarks // Skip invalid bookmarks
@@ -344,20 +156,188 @@ class BookmarkJSONUtils {
if (guid == BookmarkRoot.mobile.id) return 'mobileFolder'; if (guid == BookmarkRoot.mobile.id) return 'mobileFolder';
return null; 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') /// Get URL from node (accepts both 'url' and 'uri')
String? _getNodeUrl(Map<String, dynamic> node) { String? _getNodeUrl(Map<String, dynamic> node) {
return node['url'] as String? ?? node['uri'] as String?; 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 /// Get node type from JSON
BookmarkNodeType _getNodeType(Map<String, dynamic> node) { BookmarkNodeType _getNodeType(Map<String, dynamic> node) {
final type = node['type']; final type = node['type'];
@@ -373,4 +353,21 @@ class BookmarkJSONUtils {
return BookmarkNodeType.separator; 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 * 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/>. * 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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.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/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_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/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/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/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/data/providers/toolbar_button_configs.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.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 tabUrl = scope.tabState?.url;
final bookmarkable = tabUrl != null && !scope.isPreview; final bookmarkable = tabUrl != null && !scope.isPreview;
final existingGuids = ref // Answered by a storage lookup keyed on the URL, so this does not depend on
.watch( // the whole bookmark tree being resident in memory.
bookmarksRepositoryProvider.select( final bookmarkLookup = ref.watch(
(async) => EquatableValue( bookmarkGuidsForUrlProvider(bookmarkable ? tabUrl : null),
bookmarkable );
? bookmarkGuidsForUrl(async.value, tabUrl) final existingGuids = bookmarkLookup.value ?? const <String>[];
: const <String>[],
),
),
)
.value;
// 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; final isBookmarked = existingGuids.isNotEmpty;
return MenuAnchor( return MenuAnchor(
@@ -819,7 +816,7 @@ class _BookmarkToolbarButton extends HookConsumerWidget {
else else
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(MdiIcons.bookmarkPlus), leadingIcon: const Icon(MdiIcons.bookmarkPlus),
onPressed: !bookmarkable onPressed: !canToggleBookmark
? null ? null
: () async { : () async {
await ref await ref
@@ -869,25 +866,23 @@ class _BookmarkToggleToolbarButton extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final tabUrl = scope.tabState?.url; final tabUrl = scope.tabState?.url;
final bookmarkable = tabUrl != null && !scope.isPreview; final bookmarkable = tabUrl != null && !scope.isPreview;
final existingGuids = ref // Answered by a storage lookup keyed on the URL, so this does not depend on
.watch( // the whole bookmark tree being resident in memory.
bookmarksRepositoryProvider.select( final bookmarkLookup = ref.watch(
(async) => EquatableValue( bookmarkGuidsForUrlProvider(bookmarkable ? tabUrl : null),
bookmarkable );
? bookmarkGuidsForUrl(async.value, tabUrl) final existingGuids = bookmarkLookup.value ?? const <String>[];
: const <String>[],
),
),
)
.value;
// 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; final isBookmarked = existingGuids.isNotEmpty;
return IconButton( return IconButton(
tooltip: isBookmarked ? 'Remove bookmark' : 'Add bookmark', tooltip: isBookmarked ? 'Remove bookmark' : 'Add bookmark',
onPressed: scope.isPreview onPressed: scope.isPreview
? () {} ? () {}
: !bookmarkable : !canToggleBookmark
? null ? null
: () async { : () async {
if (isBookmarked) { if (isBookmarked) {
@@ -628,7 +628,8 @@ class RailAppBarTitleView extends StatelessWidget {
: MdiIcons.shieldAlert, : MdiIcons.shieldAlert,
size: 10, size: 10,
color: color:
siteSettingsBadgeState == SiteSettingsBadgeState.improved siteSettingsBadgeState ==
SiteSettingsBadgeState.improved
? Colors.green ? Colors.green
: appColors.warningAmber, : appColors.warningAmber,
), ),
@@ -317,7 +317,9 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
if (entries.isEmpty) { if (entries.isEmpty) {
// Hold the 48px slot; the bar visibility is decided upstream by // Hold the 48px slot; the bar visibility is decided upstream by
// quickTabSwitcherRowCountProvider. // quickTabSwitcherRowCountProvider.
return isVertical ? const SizedBox(width: 48) : const SizedBox(height: 48); return isVertical
? const SizedBox(width: 48)
: const SizedBox(height: 48);
} }
return NotificationListener<UserScrollNotification>( return NotificationListener<UserScrollNotification>(
@@ -522,19 +524,54 @@ class _AccordionHeaderChip extends StatelessWidget {
// count badge inside the label instead, dropping the avatar slot. // count badge inside the label instead, dropping the avatar slot.
return wrapLongPress( return wrapLongPress(
FilterChip( FilterChip(
labelPadding: EdgeInsets.zero, labelPadding: EdgeInsets.zero,
label: Column( label: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (iconAvatar != null) iconAvatar, if (iconAvatar != null) iconAvatar,
if (countBadge != null) ...[ if (countBadge != null) ...[
if (iconAvatar != null) const SizedBox(height: 4), if (iconAvatar != null) const SizedBox(height: 4),
// Multi-digit counts can exceed the narrow rail's fixed 48px chip // Multi-digit counts can exceed the narrow rail's fixed 48px chip
// width; scale the badge down to fit instead of overflowing. // width; scale the badge down to fit instead of overflowing.
FittedBox(fit: BoxFit.scaleDown, child: countBadge), 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), color: WidgetStatePropertyAll(fill),
selected: false, selected: false,
showCheckmark: false, showCheckmark: false,
@@ -545,41 +582,6 @@ class _AccordionHeaderChip extends StatelessWidget {
}, },
side: side, side: side,
shape: shape, 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 // Alternative tab types the user can explicitly choose, excluding the type
// that the main tile action already opens (the current tab's type). // that the main tile action already opens (the current tab's type).
final alternativeTypes = final alternativeTypes = <TabType>[
<TabType>[ TabType.regular,
TabType.regular, TabType.private,
TabType.private, if (settings.showIsolatedTabUi) TabType.isolated,
if (settings.showIsolatedTabUi) TabType.isolated, ]..remove(currentTabMode.toTabType());
]..remove(currentTabMode.toTabType());
return ListTile( return ListTile(
leading: const Icon(MdiIcons.tabPlus), leading: const Icon(MdiIcons.tabPlus),
@@ -90,7 +90,9 @@ class VisitContainerRecorder extends _$VisitContainerRecorder {
// permanently. A genuine miss (deleted container) leaves the cache // permanently. A genuine miss (deleted container) leaves the cache
// non-empty, so this fallback does not fire repeatedly in steady state. // non-empty, so this fallback does not fire repeatedly in steady state.
if (containerId == null && contextIdToContainerId.isEmpty) { if (containerId == null && contextIdToContainerId.isEmpty) {
applyContainers(await ref.read(watchContainersWithCountProvider.future)); applyContainers(
await ref.read(watchContainersWithCountProvider.future),
);
containerId = contextIdToContainerId[contextId]; containerId = contextIdToContainerId[contextId];
} }
@@ -75,9 +75,9 @@ class VisitContainerDao extends DatabaseAccessor<TabDatabase>
start, start,
math.min(start + chunkSize, canonicalList.length), math.min(start + chunkSize, canonicalList.length),
); );
final rows = await (db.select(db.visitContainer) final rows = await (db.select(
..where((t) => t.urlCanonical.isIn(chunk))) db.visitContainer,
.get(); )..where((t) => t.urlCanonical.isIn(chunk))).get();
results.addAll(rows); results.addAll(rows);
} }
return results; return results;
@@ -103,9 +103,9 @@ class VisitContainerDao extends DatabaseAccessor<TabDatabase>
/// Container deletion dissolves relations automatically via ON DELETE CASCADE /// Container deletion dissolves relations automatically via ON DELETE CASCADE
/// and does not go through here. /// and does not go through here.
Future<int> deleteForContainer(String containerId) { Future<int> deleteForContainer(String containerId) {
return (db.delete(db.visitContainer) return (db.delete(
..where((t) => t.containerId.equals(containerId))) db.visitContainer,
.go(); )..where((t) => t.containerId.equals(containerId))).go();
} }
/// Remove every relation row, all containers. Used when the user clears all /// 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; if (!ids.any(current.appLinkContextOverrides.containsKey)) return current;
return current.copyWith.appLinkContextOverrides( 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/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.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/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/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/browser/presentation/widgets/translation_bottom_sheet.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.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 = final bookmarkUrl =
ref.read(sandboxSourceUriForTabProvider(tabId: tabId)) ?? tabState.url; 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 repository = ref.read(bookmarksRepositoryProvider.notifier);
final existingGuids = await repository.bookmarkGuidsForUrl(bookmarkUrl);
if (existingGuids.isNotEmpty) { if (existingGuids.isNotEmpty) {
for (final guid in existingGuids) { for (final guid in existingGuids) {
await repository.delete(guid); await repository.delete(guid);
@@ -66,7 +66,7 @@ final class GestureControlServiceProvider
} }
String _$gestureControlServiceHash() => String _$gestureControlServiceHash() =>
r'12bd852a5b90b67bee4a94e7bd55fccc53c11bd4'; r'3741c0a3d9cb6726c044bab1efd4ce80208f21d8';
/// Bridges gesture settings and recognized-gesture events to app actions. /// Bridges gesture settings and recognized-gesture events to app actions.
/// ///
@@ -781,8 +781,8 @@ class _AppLinksModeSection extends HookConsumerWidget {
await ref await ref
.read(saveGeneralSettingsControllerProvider.notifier) .read(saveGeneralSettingsControllerProvider.notifier)
.save( .save(
(current) => (current) => current.copyWith
current.copyWith.appLinkMarketplaceFallback(value), .appLinkMarketplaceFallback(value),
); );
}, },
), ),
@@ -845,9 +845,9 @@ class _AppLinkRulesSubsection extends ConsumerWidget {
await ref await ref
.read(saveGeneralSettingsControllerProvider.notifier) .read(saveGeneralSettingsControllerProvider.notifier)
.save( .save(
(current) => current.copyWith.appLinkRules({ (current) => current.copyWith.appLinkRules(
...current.appLinkRules, {...current.appLinkRules}..remove(key),
}..remove(key)), ),
); );
}, },
), ),
@@ -19,13 +19,12 @@
*/ */
import 'dart:math'; import 'dart:math';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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; import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
// What words does the wanderer whisper? // What words does the wanderer whisper?
@@ -173,18 +172,16 @@ class SmallWebBottomBar extends HookConsumerWidget {
final tabUrl = currentTabUrl; final tabUrl = currentTabUrl;
final bookmarkable = tabUrl != null; final bookmarkable = tabUrl != null;
final existingGuids = ref // Answered by a storage lookup keyed on the URL, so this does not depend on
.watch( // the whole bookmark tree being resident in memory.
bookmarksRepositoryProvider.select( final bookmarkLookup = ref.watch(
(async) => EquatableValue( bookmarkGuidsForUrlProvider(bookmarkable ? tabUrl : null),
bookmarkable );
? bookmarkGuidsForUrl(async.value, tabUrl) final existingGuids = bookmarkLookup.value ?? const <String>[];
: const <String>[],
),
),
)
.value;
// 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; final isBookmarked = existingGuids.isNotEmpty;
return SizedBox( return SizedBox(
@@ -224,7 +221,7 @@ class SmallWebBottomBar extends HookConsumerWidget {
IconButton( IconButton(
icon: Icon(isBookmarked ? Icons.bookmark : Icons.bookmark_border), icon: Icon(isBookmarked ? Icons.bookmark : Icons.bookmark_border),
tooltip: isBookmarked ? 'Remove bookmark' : 'Add bookmark', tooltip: isBookmarked ? 'Remove bookmark' : 'Add bookmark',
onPressed: !bookmarkable onPressed: !canToggleBookmark
? null ? null
: () async { : () async {
if (isBookmarked) { if (isBookmarked) {
-1
View File
@@ -8,7 +8,6 @@ environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
dependencies: dependencies:
animated_tree_view: ^2.3.0
background_fetch: ^1.7.0 background_fetch: ^1.7.0
collection: ^1.19.1 collection: ^1.19.1
convert: ^3.1.2 convert: ^3.1.2
@@ -86,7 +86,9 @@ void main() {
test('is producible even for an ambiguous resolution', () { test('is producible even for an ambiguous resolution', () {
// neverOpen never launches, so it does not need a bound package. // 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.decision, AppLinkRuleDecision.neverOpen);
expect(rule.isValid, isTrue); expect(rule.isValid, isTrue);
}); });
@@ -80,15 +80,15 @@ void main() {
}, },
}); });
expect(parsed.length, 2); 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', () { test('drops entries whose map key disagrees with the rule scope', () {
final parsed = parseAppLinkRules({ final parsed = parseAppLinkRules({
'host:wrong.com': { 'host:wrong.com': {'decision': 'neverOpen', 'scope': 'host:right.com'},
'decision': 'neverOpen',
'scope': 'host:right.com',
},
}); });
expect(parsed, isEmpty); 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/geckoview/features/tabs/data/models/site_assignment.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart'; import 'package:weblibre/features/proxy/data/proxy_connection.dart';
SiteAssignment _assignment(String site, {String? contextId}) => SiteAssignment( SiteAssignment _assignment(String site, {String? contextId}) =>
id: site, SiteAssignment(id: site, contextualIdentity: contextId, assignedSite: site);
contextualIdentity: contextId,
assignedSite: site,
);
void main() { void main() {
group('resolveContainerAssignment', () { 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:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.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'; import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart';
@GenerateMocks([GeckoBookmarksService]) @GenerateMocks([GeckoBookmarksService])
import 'bookmark_html_utils_test.mocks.dart'; 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() { void main() {
late MockGeckoBookmarksService mockService; late MockGeckoBookmarksService mockService;
late BookmarkHTMLUtils utils; late BookmarkHTMLUtils utils;
@@ -36,72 +51,20 @@ void main() {
setUp(() { setUp(() {
mockService = MockGeckoBookmarksService(); mockService = MockGeckoBookmarksService();
utils = BookmarkHTMLUtils(mockService); 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', () { group('parseBookmarkHtml', () {
test('should handle corrupt HTML file with malformed URIs', () async { test('should route everything under menu without root markers', () {
// 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 {
const simpleHtml = ''' const simpleHtml = '''
<!DOCTYPE NETSCAPE-Bookmark-file-1> <!DOCTYPE NETSCAPE-Bookmark-file-1>
<TITLE>Bookmarks</TITLE> <TITLE>Bookmarks</TITLE>
@@ -111,16 +74,19 @@ void main() {
</DL> </DL>
'''; ''';
when( final tree = parseBookmarkHtml(simpleHtml, preserveRootFolders: false);
mockService.addItem(any, any, any, any),
).thenAnswer((_) async => 'guid');
await utils.importFromHTML(simpleHtml); expect(tree.sections.keys, equals([BookmarkRoot.menu.id]));
expect(
verifyNever(mockService.eraseEverything(any)); 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 = ''' const htmlWithSpecialChars = '''
<!DOCTYPE NETSCAPE-Bookmark-file-1> <!DOCTYPE NETSCAPE-Bookmark-file-1>
<TITLE>Bookmarks</TITLE> <TITLE>Bookmarks</TITLE>
@@ -130,21 +96,16 @@ void main() {
</DL> </DL>
'''; ''';
when( final tree = parseBookmarkHtml(
mockService.addItem(any, any, any, any), htmlWithSpecialChars,
).thenAnswer((_) async => 'guid'); preserveRootFolders: false,
);
final count = await utils.importFromHTML(htmlWithSpecialChars); final item = section(tree, BookmarkRoot.menu).single;
expect((item as ImportBookmarkItem).title, equals('<unescaped="test">'));
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">'));
}); });
test('should import bookmarks with timestamps', () async { test('should preserve item timestamps as seconds since epoch', () {
const htmlWithDates = ''' const htmlWithDates = '''
<!DOCTYPE NETSCAPE-Bookmark-file-1> <!DOCTYPE NETSCAPE-Bookmark-file-1>
<TITLE>Bookmarks</TITLE> <TITLE>Bookmarks</TITLE>
@@ -154,16 +115,63 @@ void main() {
</DL> </DL>
'''; ''';
when( final tree = parseBookmarkHtml(htmlWithDates, preserveRootFolders: false);
mockService.addItem(any, any, any, any),
).thenAnswer((_) async => 'guid');
final count = await utils.importFromHTML(htmlWithDates); final item =
section(tree, BookmarkRoot.menu).single as ImportBookmarkItem;
expect(count, equals(1)); 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 = ''' const htmlWithFolders = '''
<!DOCTYPE NETSCAPE-Bookmark-file-1> <!DOCTYPE NETSCAPE-Bookmark-file-1>
<TITLE>Bookmarks</TITLE> <TITLE>Bookmarks</TITLE>
@@ -180,24 +188,98 @@ void main() {
</DL> </DL>
'''; ''';
when( final tree = parseBookmarkHtml(
mockService.addFolder(any, any, any), htmlWithFolders,
).thenAnswer((_) async => 'folder_guid'); preserveRootFolders: false,
when( );
mockService.addItem(any, any, any, any),
).thenAnswer((_) async => 'bookmark_guid');
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 expect(
verify(mockService.addFolder(any, any, any)).called(2); // 2 folders (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 { test('should keep empty folders', () {
const htmlWithToolbar = ''' 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> <!DOCTYPE NETSCAPE-Bookmark-file-1>
<TITLE>Bookmarks</TITLE> <TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1> <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> <DL><p>
<DT><H3 PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3> <DT><H3 PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3>
<DL><p> <DL><p>
@@ -206,47 +288,19 @@ void main() {
</DL> </DL>
'''; ''';
when(mockService.eraseEverything(any)).thenAnswer((_) async {}); final tree = parseBookmarkHtml(
when( htmlWithToolbar,
mockService.addItem(any, any, any, any), preserveRootFolders: false,
).thenAnswer((_) async => 'guid'); );
await utils.importFromHTML(htmlWithToolbar, replace: true); expect(tree.sections.keys, equals([BookmarkRoot.menu.id]));
expect(
// When replace is true, should add to toolbar (section(tree, BookmarkRoot.menu).single as ImportBookmarkFolder).title,
final captured = verify( equals('Bookmarks Toolbar'),
mockService.addItem(captureAny, any, any, any), );
).captured;
expect(captured[0], equals(BookmarkRoot.toolbar.id));
}); });
test('should recognize unfiled folder', () async { test('should keep separators between bookmarks', () {
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 {
const htmlWithSeparator = ''' const htmlWithSeparator = '''
<!DOCTYPE NETSCAPE-Bookmark-file-1> <!DOCTYPE NETSCAPE-Bookmark-file-1>
<TITLE>Bookmarks</TITLE> <TITLE>Bookmarks</TITLE>
@@ -258,17 +312,19 @@ void main() {
</DL> </DL>
'''; ''';
when( final tree = parseBookmarkHtml(
mockService.addItem(any, any, any, any), htmlWithSeparator,
).thenAnswer((_) async => 'guid'); preserveRootFolders: false,
);
final nodes = section(tree, BookmarkRoot.menu);
final count = await utils.importFromHTML(htmlWithSeparator); expect(nodes, hasLength(3));
expect(nodes[1], isA<ImportBookmarkSeparator>());
// Should import 2 bookmarks (separator is not supported by Android API) expect(tree.stats.bookmarkCount, equals(2));
expect(count, equals(2)); expect(tree.stats.separatorCount, equals(1));
}); });
test('should skip bookmarks without URLs', () async { test('should skip bookmarks without URLs', () {
const htmlWithoutUrl = ''' const htmlWithoutUrl = '''
<!DOCTYPE NETSCAPE-Bookmark-file-1> <!DOCTYPE NETSCAPE-Bookmark-file-1>
<TITLE>Bookmarks</TITLE> <TITLE>Bookmarks</TITLE>
@@ -279,16 +335,16 @@ void main() {
</DL> </DL>
'''; ''';
when( final tree = parseBookmarkHtml(
mockService.addItem(any, any, any, any), htmlWithoutUrl,
).thenAnswer((_) async => 'guid'); preserveRootFolders: false,
);
final count = await utils.importFromHTML(htmlWithoutUrl); expect(section(tree, BookmarkRoot.menu), hasLength(1));
expect(tree.stats.skippedUrlCount, equals(1));
expect(count, equals(1)); // Only the valid one
}); });
test('should skip bookmarks with invalid URLs', () async { test('should skip bookmarks with schemeless URLs', () {
const htmlWithInvalidUrl = ''' const htmlWithInvalidUrl = '''
<!DOCTYPE NETSCAPE-Bookmark-file-1> <!DOCTYPE NETSCAPE-Bookmark-file-1>
<TITLE>Bookmarks</TITLE> <TITLE>Bookmarks</TITLE>
@@ -299,31 +355,209 @@ void main() {
</DL> </DL>
'''; ''';
when( final tree = parseBookmarkHtml(
mockService.addItem(any, any, any, any), htmlWithInvalidUrl,
).thenAnswer((_) async => 'guid'); preserveRootFolders: false,
);
final count = await utils.importFromHTML(htmlWithInvalidUrl); expect(section(tree, BookmarkRoot.menu), hasLength(1));
expect(tree.stats.skippedUrlCount, equals(1));
expect(count, equals(1));
}); });
test('should handle single frame HTML', () async { test('should produce no sections for an empty document', () {
final fixtureFile = File( const emptyHtml = '''
'test/utils/bookmarks/fixtures/bookmarks_html_singleframe.html', <!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( test('should handle deeply nested folders', () {
mockService.addFolder(any, any, any), const depth = 60;
).thenAnswer((_) async => 'folder_guid'); final buffer = StringBuffer(
when( '<!DOCTYPE NETSCAPE-Bookmark-file-1>\n<DL><p>',
mockService.addItem(any, any, any, any), );
).thenAnswer((_) async => 'bookmark_guid'); 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); expect(html, isNotEmpty);
// Re-import // 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); final count = await utils.importFromHTML(html, replace: true);
expect(count, equals(1)); // One bookmark imported 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. // Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes // 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' import 'package:flutter_mozilla_components/src/domain/services/gecko_bookmarks.dart'
as _i2; as _i3;
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i4; import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i2;
import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i5; 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: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member // 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]. /// A class which mocks [GeckoBookmarksService].
/// ///
/// See the documentation for Mockito's code generation for more information. /// See the documentation for Mockito's code generation for more information.
class MockGeckoBookmarksService extends _i1.Mock class MockGeckoBookmarksService extends _i1.Mock
implements _i2.GeckoBookmarksService { implements _i3.GeckoBookmarksService {
MockGeckoBookmarksService() { MockGeckoBookmarksService() {
_i1.throwOnMissingStub(this); _i1.throwOnMissingStub(this);
} }
@override @override
_i3.Future<_i4.BookmarkNode?> getTree( _i4.Future<_i2.BookmarkNode?> getTree(
String? guid, { String? guid, {
bool? recursive = false, bool? recursive = false,
}) => }) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#getTree, [guid], {#recursive: recursive}), 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 @override
_i3.Future<_i4.BookmarkNode?> getBookmark(String? guid) => _i4.Future<_i2.BookmarkNode?> getBookmark(String? guid) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#getBookmark, [guid]), 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 @override
_i3.Future<List<_i4.BookmarkNode>> getBookmarksWithUrl(Uri? url) => _i4.Future<List<_i2.BookmarkNode>> getBookmarksWithUrl(Uri? url) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#getBookmarksWithUrl, [url]), Invocation.method(#getBookmarksWithUrl, [url]),
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value( returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
<_i4.BookmarkNode>[], <_i2.BookmarkNode>[],
), ),
) )
as _i3.Future<List<_i4.BookmarkNode>>); as _i4.Future<List<_i2.BookmarkNode>>);
@override @override
_i3.Future<List<_i4.BookmarkNode>> getRecentBookmarks( _i4.Future<List<_i2.BookmarkNode>> getRecentBookmarks(
int? limit, { int? limit, {
Duration? maxAge = Duration.zero, Duration? maxAge = Duration.zero,
DateTime? currentTime, DateTime? currentTime,
@@ -76,27 +82,27 @@ class MockGeckoBookmarksService extends _i1.Mock
[limit], [limit],
{#maxAge: maxAge, #currentTime: currentTime}, {#maxAge: maxAge, #currentTime: currentTime},
), ),
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value( returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
<_i4.BookmarkNode>[], <_i2.BookmarkNode>[],
), ),
) )
as _i3.Future<List<_i4.BookmarkNode>>); as _i4.Future<List<_i2.BookmarkNode>>);
@override @override
_i3.Future<List<_i4.BookmarkNode>> searchBookmarks( _i4.Future<List<_i2.BookmarkNode>> searchBookmarks(
String? query, { String? query, {
int? limit = 10, int? limit = 10,
}) => }) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#searchBookmarks, [query], {#limit: limit}), Invocation.method(#searchBookmarks, [query], {#limit: limit}),
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value( returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
<_i4.BookmarkNode>[], <_i2.BookmarkNode>[],
), ),
) )
as _i3.Future<List<_i4.BookmarkNode>>); as _i4.Future<List<_i2.BookmarkNode>>);
@override @override
_i3.Future<String> addItem( _i4.Future<String> addItem(
String? parentGuid, String? parentGuid,
Uri? url, Uri? url,
String? title, String? title,
@@ -104,55 +110,79 @@ class MockGeckoBookmarksService extends _i1.Mock
) => ) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#addItem, [parentGuid, url, title, position]), Invocation.method(#addItem, [parentGuid, url, title, position]),
returnValue: _i3.Future<String>.value( returnValue: _i4.Future<String>.value(
_i5.dummyValue<String>( _i5.dummyValue<String>(
this, this,
Invocation.method(#addItem, [parentGuid, url, title, position]), Invocation.method(#addItem, [parentGuid, url, title, position]),
), ),
), ),
) )
as _i3.Future<String>); as _i4.Future<String>);
@override @override
_i3.Future<String> addFolder( _i4.Future<String> addFolder(
String? parentGuid, String? parentGuid,
String? title, String? title,
int? position, int? position,
) => ) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#addFolder, [parentGuid, title, position]), Invocation.method(#addFolder, [parentGuid, title, position]),
returnValue: _i3.Future<String>.value( returnValue: _i4.Future<String>.value(
_i5.dummyValue<String>( _i5.dummyValue<String>(
this, this,
Invocation.method(#addFolder, [parentGuid, title, position]), Invocation.method(#addFolder, [parentGuid, title, position]),
), ),
), ),
) )
as _i3.Future<String>); as _i4.Future<String>);
@override @override
_i3.Future<void> updateNode(String? guid, _i4.BookmarkInfo? info) => _i4.Future<void> updateNode(String? guid, _i2.BookmarkInfo? info) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#updateNode, [guid, info]), Invocation.method(#updateNode, [guid, info]),
returnValue: _i3.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) )
as _i3.Future<void>); as _i4.Future<void>);
@override @override
_i3.Future<bool> deleteNode(String? guid) => _i4.Future<bool> deleteNode(String? guid) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#deleteNode, [guid]), 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 @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( (super.noSuchMethod(
Invocation.method(#eraseEverything, [root]), Invocation.method(#eraseEverything, [root]),
returnValue: _i3.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i3.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:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.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'; import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart';
@GenerateMocks([GeckoBookmarksService]) @GenerateMocks([GeckoBookmarksService])
import 'bookmark_json_utils_test.mocks.dart'; 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() { void main() {
late MockGeckoBookmarksService mockService; late MockGeckoBookmarksService mockService;
late BookmarkJSONUtils utils; late BookmarkJSONUtils utils;
@@ -39,108 +54,52 @@ void main() {
setUp(() { setUp(() {
mockService = MockGeckoBookmarksService(); mockService = MockGeckoBookmarksService();
utils = BookmarkJSONUtils(mockService); 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', () { group('parseBookmarkJson', () {
test('should reject invalid JSON format', () { test('should reject a document that is not an object', () {
const invalidJson = '[]'; expect(() => parseBookmarkJson('[]'), throwsA(isA<FormatException>()));
expect(
() => utils.importFromJSON(invalidJson),
throwsA(isA<Exception>()),
);
}); });
test('should return 0 for empty children', () async { test('should produce nothing for empty or missing children', () {
const emptyJson = '{"children": []}'; expect(parseBookmarkJson('{"children": []}').isEmpty, isTrue);
expect(parseBookmarkJson('{"guid": "root________"}').isEmpty, isTrue);
final count = await utils.importFromJSON(emptyJson);
expect(count, equals(0));
}); });
test('should return 0 when children is null', () async { test('should filter out the tags folder', () {
const noChildrenJson = '{"guid": "root________"}';
final count = await utils.importFromJSON(noChildrenJson);
expect(count, equals(0));
});
test('should filter out tags folder during import', () async {
final jsonData = { final jsonData = {
'children': [ 'children': [
{ {
'guid': 'tags________', 'guid': 'tags________',
'root': 'tagsFolder', 'root': 'tagsFolder',
'type': 'text/x-moz-place-container', 'type': 'text/x-moz-place-container',
'children': [], 'children': [
{
'guid': 'tag1________',
'title': 'Tagged',
'type': 'text/x-moz-place',
'uri': 'https://example.com/tagged',
},
],
}, },
{ {
'guid': 'menu________', 'guid': 'menu________',
'root': 'bookmarksMenuFolder', 'root': 'bookmarksMenuFolder',
'type': 'text/x-moz-place-container', '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': [ 'children': [
{ {
'guid': 'bookmark1___', 'guid': 'bookmark1___',
'title': 'Test Bookmark', 'title': 'Kept',
'type': 'text/x-moz-place', 'type': 'text/x-moz-place',
'uri': 'https://example.com', 'uri': 'https://example.com',
}, },
@@ -149,59 +108,65 @@ void main() {
], ],
}; };
when( final tree = parseBookmarkJson(jsonEncode(jsonData));
mockService.addItem(any, any, any, any),
).thenAnswer((_) async => 'bookmark1___');
final count = await utils.importFromJSON(jsonEncode(jsonData)); expect(tree.sections.keys, equals([BookmarkRoot.menu.id]));
expect(tree.stats.bookmarkCount, equals(1));
expect(count, equals(1));
verify(
mockService.addItem(
'menu________',
Uri.parse('https://example.com'),
'Test Bookmark',
0,
),
).called(1);
}); });
test('should import bookmarks with URL field', () async { test('should ignore top-level nodes that are not Places roots', () {
final jsonData = { final jsonData = {
'children': [ 'children': [
{ {
'guid': 'menu________', 'guid': 'notaroot____',
'type': 'text/x-moz-place-container', 'type': 'text/x-moz-place-container',
'children': [ 'children': [
{ {
'guid': 'bookmark1___', 'guid': 'bookmark1___',
'title': 'Test Bookmark', 'title': 'Orphan',
'type': 'text/x-moz-place', 'type': 'text/x-moz-place',
'url': 'https://example.com', 'uri': 'https://example.com',
}, },
], ],
}, },
], ],
}; };
when( expect(parseBookmarkJson(jsonEncode(jsonData)).isEmpty, isTrue);
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);
}); });
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 = { final jsonData = {
'children': [ 'children': [
{ {
@@ -225,26 +190,13 @@ void main() {
], ],
}; };
when( final tree = parseBookmarkJson(jsonEncode(jsonData));
mockService.addItem(any, any, any, any),
).thenAnswer((_) async => 'valid1______');
final count = await utils.importFromJSON(jsonEncode(jsonData)); expect(section(tree, BookmarkRoot.menu), hasLength(1));
expect(tree.stats.skippedUrlCount, equals(1));
// 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);
}); });
test('should import nested folders recursively', () async { test('should nest folders recursively', () {
final jsonData = { final jsonData = {
'children': [ 'children': [
{ {
@@ -269,28 +221,20 @@ void main() {
], ],
}; };
when( final tree = parseBookmarkJson(jsonEncode(jsonData));
mockService.addFolder(any, any, any),
).thenAnswer((_) async => 'folder1_____');
when(
mockService.addItem(any, any, any, any),
).thenAnswer((_) async => 'bookmark1___');
final count = await utils.importFromJSON(jsonEncode(jsonData)); final folder =
section(tree, BookmarkRoot.menu).single as ImportBookmarkFolder;
expect(count, equals(1)); expect(folder.title, equals('Folder 1'));
verify(mockService.addFolder('menu________', 'Folder 1', 0)).called(1); expect(
verify( (folder.children.single as ImportBookmarkItem).title,
mockService.addItem( equals('Nested Bookmark'),
'folder1_____', );
Uri.parse('https://example.com'), expect(tree.stats.bookmarkCount, equals(1));
'Nested Bookmark', expect(tree.stats.folderCount, equals(1));
0,
),
).called(1);
}); });
test('should handle separators gracefully (skip them)', () async { test('should keep separators', () {
final jsonData = { final jsonData = {
'children': [ 'children': [
{ {
@@ -315,18 +259,80 @@ void main() {
], ],
}; };
when( final tree = parseBookmarkJson(jsonEncode(jsonData));
mockService.addItem(any, any, any, any), final nodes = section(tree, BookmarkRoot.menu);
).thenAnswer((invocation) async => 'generated_guid');
final count = await utils.importFromJSON(jsonEncode(jsonData)); expect(nodes, hasLength(3));
expect(nodes[1], isA<ImportBookmarkSeparator>());
// Two bookmarks, separator should be skipped expect(tree.stats.bookmarkCount, equals(2));
expect(count, equals(2)); expect(tree.stats.separatorCount, equals(1));
verify(mockService.addItem(any, any, any, any)).called(2);
}); });
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 = { final jsonData = {
'children': [ 'children': [
{ {
@@ -352,25 +358,14 @@ void main() {
], ],
}; };
when( final tree = parseBookmarkJson(jsonEncode(jsonData));
mockService.addFolder(any, any, any),
).thenAnswer((_) async => 'folder1_____');
when(
mockService.addItem(any, any, any, any),
).thenAnswer((_) async => 'shortcut1___');
await utils.importFromJSON(jsonEncode(jsonData)); final shortcut =
section(tree, BookmarkRoot.unfiled)[1] as ImportBookmarkItem;
// Capture the URI argument to verify it was fixed up expect(shortcut.url.toString(), contains('parent=folder1_____'));
// 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_____'));
}); });
test('should handle invalid folder references in place: queries', () async { test('should handle invalid folder references in place: queries', () {
final jsonData = { final jsonData = {
'children': [ 'children': [
{ {
@@ -388,48 +383,129 @@ void main() {
], ],
}; };
when( final tree = parseBookmarkJson(jsonEncode(jsonData));
mockService.addItem(any, any, any, any),
).thenAnswer((_) async => 'shortcut1___');
await utils.importFromJSON(jsonEncode(jsonData)); final url =
(section(tree, BookmarkRoot.unfiled).single as ImportBookmarkItem).url
final captured = verify( .toString();
mockService.addItem(
'unfiled_____',
captureAny,
'Invalid Folder Shortcut',
0,
),
).captured;
final url = (captured[0] as Uri).toString();
expect(url, contains('invalidOldParentId=999999')); expect(url, contains('invalidOldParentId=999999'));
expect(url, contains('excludeItems=1')); expect(url, contains('excludeItems=1'));
}); });
test('should count imported bookmarks correctly from fixture', () async { test('should parse the bookmarks fixture', () async {
// Load the fixture final jsonString = await File(
final fixtureFile = File('test/utils/bookmarks/fixtures/bookmarks.json'); 'test/utils/bookmarks/fixtures/bookmarks.json',
final jsonString = await fixtureFile.readAsString(); ).readAsString();
// Mock the service calls final tree = parseBookmarkJson(jsonString);
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 count = await utils.importFromJSON(jsonString, replace: true); expect(tree.stats.bookmarkCount, greaterThan(0));
});
});
// The fixture has several bookmarks - we should count only valid ones group('BookmarkJSONUtils - Import', () {
expect(count, greaterThan(0)); test('should reject invalid JSON format', () {
verify(mockService.eraseEverything(BookmarkRoot.root)).called(1); 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 = { final jsonData = {
'children': [ 'children': [
{ {
@@ -448,13 +524,13 @@ void main() {
}; };
when( when(
mockService.addItem(any, any, any, any), mockService.insertTree(any, any),
).thenThrow(Exception('Database error')); ).thenThrow(Exception('Database error'));
// Should not throw, but should log and continue expect(
final count = await utils.importFromJSON(jsonEncode(jsonData)); () => utils.importFromJSON(jsonEncode(jsonData)),
throwsA(isA<Exception>()),
expect(count, equals(0)); // Failed to add );
}); });
}); });
@@ -776,21 +852,27 @@ void main() {
expect(exported, isNotNull); expect(exported, isNotNull);
// Re-import // 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({ final jsonString = jsonEncode({
'children': [exported], 'children': [exported],
}); });
final count = await utils.importFromJSON(jsonString, replace: true); final count = await utils.importFromJSON(jsonString, replace: true);
expect(count, equals(1)); // One bookmark imported 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. // Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes // 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' import 'package:flutter_mozilla_components/src/domain/services/gecko_bookmarks.dart'
as _i2; as _i3;
import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i4; import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i2;
import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i5; 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: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member // 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]. /// A class which mocks [GeckoBookmarksService].
/// ///
/// See the documentation for Mockito's code generation for more information. /// See the documentation for Mockito's code generation for more information.
class MockGeckoBookmarksService extends _i1.Mock class MockGeckoBookmarksService extends _i1.Mock
implements _i2.GeckoBookmarksService { implements _i3.GeckoBookmarksService {
MockGeckoBookmarksService() { MockGeckoBookmarksService() {
_i1.throwOnMissingStub(this); _i1.throwOnMissingStub(this);
} }
@override @override
_i3.Future<_i4.BookmarkNode?> getTree( _i4.Future<_i2.BookmarkNode?> getTree(
String? guid, { String? guid, {
bool? recursive = false, bool? recursive = false,
}) => }) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#getTree, [guid], {#recursive: recursive}), 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 @override
_i3.Future<_i4.BookmarkNode?> getBookmark(String? guid) => _i4.Future<_i2.BookmarkNode?> getBookmark(String? guid) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#getBookmark, [guid]), 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 @override
_i3.Future<List<_i4.BookmarkNode>> getBookmarksWithUrl(Uri? url) => _i4.Future<List<_i2.BookmarkNode>> getBookmarksWithUrl(Uri? url) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#getBookmarksWithUrl, [url]), Invocation.method(#getBookmarksWithUrl, [url]),
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value( returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
<_i4.BookmarkNode>[], <_i2.BookmarkNode>[],
), ),
) )
as _i3.Future<List<_i4.BookmarkNode>>); as _i4.Future<List<_i2.BookmarkNode>>);
@override @override
_i3.Future<List<_i4.BookmarkNode>> getRecentBookmarks( _i4.Future<List<_i2.BookmarkNode>> getRecentBookmarks(
int? limit, { int? limit, {
Duration? maxAge = Duration.zero, Duration? maxAge = Duration.zero,
DateTime? currentTime, DateTime? currentTime,
@@ -76,27 +82,27 @@ class MockGeckoBookmarksService extends _i1.Mock
[limit], [limit],
{#maxAge: maxAge, #currentTime: currentTime}, {#maxAge: maxAge, #currentTime: currentTime},
), ),
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value( returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
<_i4.BookmarkNode>[], <_i2.BookmarkNode>[],
), ),
) )
as _i3.Future<List<_i4.BookmarkNode>>); as _i4.Future<List<_i2.BookmarkNode>>);
@override @override
_i3.Future<List<_i4.BookmarkNode>> searchBookmarks( _i4.Future<List<_i2.BookmarkNode>> searchBookmarks(
String? query, { String? query, {
int? limit = 10, int? limit = 10,
}) => }) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#searchBookmarks, [query], {#limit: limit}), Invocation.method(#searchBookmarks, [query], {#limit: limit}),
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value( returnValue: _i4.Future<List<_i2.BookmarkNode>>.value(
<_i4.BookmarkNode>[], <_i2.BookmarkNode>[],
), ),
) )
as _i3.Future<List<_i4.BookmarkNode>>); as _i4.Future<List<_i2.BookmarkNode>>);
@override @override
_i3.Future<String> addItem( _i4.Future<String> addItem(
String? parentGuid, String? parentGuid,
Uri? url, Uri? url,
String? title, String? title,
@@ -104,55 +110,79 @@ class MockGeckoBookmarksService extends _i1.Mock
) => ) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#addItem, [parentGuid, url, title, position]), Invocation.method(#addItem, [parentGuid, url, title, position]),
returnValue: _i3.Future<String>.value( returnValue: _i4.Future<String>.value(
_i5.dummyValue<String>( _i5.dummyValue<String>(
this, this,
Invocation.method(#addItem, [parentGuid, url, title, position]), Invocation.method(#addItem, [parentGuid, url, title, position]),
), ),
), ),
) )
as _i3.Future<String>); as _i4.Future<String>);
@override @override
_i3.Future<String> addFolder( _i4.Future<String> addFolder(
String? parentGuid, String? parentGuid,
String? title, String? title,
int? position, int? position,
) => ) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#addFolder, [parentGuid, title, position]), Invocation.method(#addFolder, [parentGuid, title, position]),
returnValue: _i3.Future<String>.value( returnValue: _i4.Future<String>.value(
_i5.dummyValue<String>( _i5.dummyValue<String>(
this, this,
Invocation.method(#addFolder, [parentGuid, title, position]), Invocation.method(#addFolder, [parentGuid, title, position]),
), ),
), ),
) )
as _i3.Future<String>); as _i4.Future<String>);
@override @override
_i3.Future<void> updateNode(String? guid, _i4.BookmarkInfo? info) => _i4.Future<void> updateNode(String? guid, _i2.BookmarkInfo? info) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#updateNode, [guid, info]), Invocation.method(#updateNode, [guid, info]),
returnValue: _i3.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) )
as _i3.Future<void>); as _i4.Future<void>);
@override @override
_i3.Future<bool> deleteNode(String? guid) => _i4.Future<bool> deleteNode(String? guid) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method(#deleteNode, [guid]), 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 @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( (super.noSuchMethod(
Invocation.method(#eraseEverything, [root]), Invocation.method(#eraseEverything, [root]),
returnValue: _i3.Future<void>.value(), returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i3.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', () { test('defaults to false', () {
expect( expect(ContainerMetadata.withDefaults().isolatedAppLinkSettings, isFalse);
ContainerMetadata.withDefaults().isolatedAppLinkSettings,
isFalse,
);
}); });
}); });
@@ -2,7 +2,9 @@ package eu.weblibre.flutter_mozilla_components.api
import eu.weblibre.flutter_mozilla_components.GlobalComponents import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.feature.GeckoBookmarksExtensionBridge import eu.weblibre.flutter_mozilla_components.feature.GeckoBookmarksExtensionBridge
import eu.weblibre.flutter_mozilla_components.pigeons.BookmarkImportNode
import eu.weblibre.flutter_mozilla_components.pigeons.BookmarkInfo import eu.weblibre.flutter_mozilla_components.pigeons.BookmarkInfo
import eu.weblibre.flutter_mozilla_components.pigeons.BookmarkInsertTreeResult
import eu.weblibre.flutter_mozilla_components.pigeons.BookmarkNode import eu.weblibre.flutter_mozilla_components.pigeons.BookmarkNode
import eu.weblibre.flutter_mozilla_components.pigeons.BookmarkNodeType import eu.weblibre.flutter_mozilla_components.pigeons.BookmarkNodeType
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBookmarksApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBookmarksApi
@@ -11,10 +13,19 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import mozilla.components.concept.storage.BookmarkInfo as MozillaBookmarkInfo
import mozilla.components.concept.storage.bookmarks.InsertableBookmarkTreeNode
import mozilla.components.concept.storage.bookmarks.InsertableBookmarkTreeRoot
class GeckoBookmarksApiImpl() : GeckoBookmarksApi { class GeckoBookmarksApiImpl() : GeckoBookmarksApi {
companion object { companion object {
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
/**
* Name of the short-lived folder that loose top-level nodes pass through
* during an import. Only visible if an import is interrupted partway.
*/
private const val SCRATCH_FOLDER_TITLE = "Importing bookmarks…"
} }
private val components by lazy { private val components by lazy {
@@ -231,6 +242,261 @@ class GeckoBookmarksApiImpl() : GeckoBookmarksApi {
} }
} }
override fun insertTree(
parentGuid: String,
children: List<BookmarkImportNode>,
callback: (Result<BookmarkInsertTreeResult>) -> Unit
) {
coroutineScope.launch {
// Imports can carry tens of thousands of nodes, so the whole batch runs
// off the main thread. Only the callback returns to it, because Pigeon
// replies must be delivered on the platform thread.
val result = withContext(Dispatchers.IO) {
runCatching { insertImportNodes(parentGuid, children) }
}
callback(result)
}
}
/**
* Appends [nodes] underneath [parentGuid], handing every top-level folder to
* storage as a single tree insertion.
*
* `insertTree` is the only storage call that carries timestamps, and it can
* only create a *folder*. Loose top-level items and separators would
* therefore lose their `ADD_DATE` if inserted with `addItem`/`addSeparator`,
* which have no timestamp parameters so they are staged inside a scratch
* folder and reparented instead. See [stageLooseNodes].
*
* A failing top-level node is counted and skipped rather than aborting the
* whole import, matching the per-node importer this replaced. Deliberately
* does not emit `bookmarks.onCreated`: one event per imported node would
* flood every installed WebExtension.
*/
private suspend fun insertImportNodes(
parentGuid: String,
nodes: List<BookmarkImportNode>
): BookmarkInsertTreeResult {
val storage = components.core.bookmarksStorage
var insertedItemCount = 0L
var failedNodeCount = 0L
val staged = stageLooseNodes(parentGuid, nodes)
for (node in nodes) {
// Every branch appends (position = null). Walking the nodes in order
// therefore reproduces the file's order, and merging into a folder
// that already has children leaves those in place.
val outcome: Result<Long> = when (node.type) {
BookmarkNodeType.FOLDER -> {
val folder = node.toInsertableFolder(position = null)
storage.insertTree(InsertableBookmarkTreeRoot(parentGuid, folder))
.map { folder.itemCount() }
}
// Already written by stageLooseNodes; only the move is left.
else -> staged.reparent(node, parentGuid)
}
outcome.fold(
{ count -> insertedItemCount += count },
{ failedNodeCount += 1 }
)
}
staged.discardScratchFolder()
return BookmarkInsertTreeResult(insertedItemCount, failedNodeCount)
}
override fun countBookmarksInTrees(
guids: List<String>,
callback: (Result<Long>) -> Unit
) {
coroutineScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
components.core.bookmarksStorage.countBookmarksInTrees(guids).toLong()
}
}
callback(result)
}
}
/**
* Loose top-level nodes written into a scratch folder, waiting to be moved
* to their real parent.
*
* The scratch folder is created under the import destination and holds the
* loose nodes in file order; [reparent] hands them out one at a time as the
* caller walks the top level, and [discardScratchFolder] removes the folder
* once it has been emptied.
*/
private inner class StagedLooseNodes(
private val scratchGuid: String?,
/** The loose nodes that made it into the scratch folder, in order. */
private val staged: List<BookmarkImportNode>,
/** Guid assigned to each entry of [staged], by index. */
private val guids: List<String>,
private val failure: Throwable?
) {
private var next = 0
/**
* Moves the next staged node under [parentGuid].
*
* Reparenting preserves `dateAdded`, which is what bookmark ordering and
* "recently added" depend on. It does refresh `lastModified` the pair
* cannot both survive, because the only storage call that accepts
* timestamps creates a folder.
*/
suspend fun reparent(node: BookmarkImportNode, parentGuid: String): Result<Long> {
failure?.let { return Result.failure(it) }
// Nodes dropped while converting (an item with no usable url) were
// never staged, so the cursor must not advance past them.
if (staged.getOrNull(next) !== node) {
return Result.failure(
IllegalArgumentException("Unusable bookmark node of type ${node.type}")
)
}
val guid = guids.getOrNull(next)
?: return Result.failure(
IllegalStateException("Storage did not report a guid for ${node.type}")
)
next++
// A null field means "leave unchanged"; appending (null position)
// keeps the file's order as the caller walks the top level.
val move = MozillaBookmarkInfo(
parentGuid = parentGuid,
position = null,
title = null,
url = null
)
return components.core.bookmarksStorage
.updateNode(guid, move)
.map { if (node.type == BookmarkNodeType.ITEM) 1L else 0L }
}
/**
* Deletes the scratch folder, but only once it is empty.
*
* Deleting cascades to children, so anything that failed to move is left
* behind in a visible folder rather than being silently destroyed.
*/
suspend fun discardScratchFolder() {
val guid = scratchGuid ?: return
val storage = components.core.bookmarksStorage
val remaining = storage.getTree(guid, false).getOrNull()?.children
if (remaining.isNullOrEmpty()) {
storage.deleteNode(guid)
}
}
}
/**
* Writes every loose top-level node of [nodes] into a scratch folder under
* [parentGuid] in a single tree insertion, so their timestamps survive.
*
* Returns an empty staging area when the import has no loose top-level
* nodes, which is the common case for Firefox exports and costs nothing.
*/
private suspend fun stageLooseNodes(
parentGuid: String,
nodes: List<BookmarkImportNode>
): StagedLooseNodes {
val empty = StagedLooseNodes(null, emptyList(), emptyList(), null)
val staged = ArrayList<BookmarkImportNode>()
val insertable = ArrayList<InsertableBookmarkTreeNode>()
for (node in nodes) {
if (node.type == BookmarkNodeType.FOLDER) continue
val converted = node.toInsertableNode(insertable.size.toUInt()) ?: continue
insertable.add(converted)
staged.add(node)
}
if (staged.isEmpty()) return empty
val scratch = InsertableBookmarkTreeNode.Folder(
title = SCRATCH_FOLDER_TITLE,
dateAddedTimestamp = 0L,
lastModifiedTimestamp = 0L,
position = null,
children = insertable
)
val storage = components.core.bookmarksStorage
return storage.insertTree(InsertableBookmarkTreeRoot(parentGuid, scratch)).fold(
{ scratchGuid ->
// Read the assigned guids back in position order, which is the
// order the nodes were handed to insertTree.
val children = storage.getTree(scratchGuid, false).getOrNull()?.children
StagedLooseNodes(
scratchGuid = scratchGuid,
staged = staged,
guids = children.orEmpty().map { it.guid },
failure = null
)
},
{ error -> StagedLooseNodes(null, staged, emptyList(), error) }
)
}
private fun BookmarkImportNode.toInsertableFolder(position: UInt?) =
InsertableBookmarkTreeNode.Folder(
title = this.title,
dateAddedTimestamp = this.dateAdded,
lastModifiedTimestamp = this.lastModified,
position = position,
children = this.children.toInsertableNodes()
)
/**
* Converts children to their insertable form, dropping unusable nodes and
* assigning positions from the surviving order so no gaps are left behind.
*/
private fun List<BookmarkImportNode>.toInsertableNodes(): List<InsertableBookmarkTreeNode> {
val converted = ArrayList<InsertableBookmarkTreeNode>(this.size)
for (node in this) {
converted.add(node.toInsertableNode(converted.size.toUInt()) ?: continue)
}
return converted
}
private fun BookmarkImportNode.toInsertableNode(position: UInt): InsertableBookmarkTreeNode? =
when (this.type) {
BookmarkNodeType.FOLDER -> this.toInsertableFolder(position)
BookmarkNodeType.ITEM -> this.url?.takeIf { it.isNotEmpty() }?.let { url ->
InsertableBookmarkTreeNode.Item(
title = this.title,
url = url,
dateAddedTimestamp = this.dateAdded,
lastModifiedTimestamp = this.lastModified,
position = position
)
}
BookmarkNodeType.SEPARATOR -> InsertableBookmarkTreeNode.Separator(
dateAddedTimestamp = this.dateAdded,
lastModifiedTimestamp = this.lastModified,
position = position
)
}
/** Number of bookmark items in this subtree, excluding folders and separators. */
private fun InsertableBookmarkTreeNode.itemCount(): Long = when (this) {
is InsertableBookmarkTreeNode.Item -> 1L
is InsertableBookmarkTreeNode.Folder -> this.children.sumOf { it.itemCount() }
is InsertableBookmarkTreeNode.Separator -> 0L
}
/** /**
* Notifies extension `bookmarks.onCreated` listeners about a node created * Notifies extension `bookmarks.onCreated` listeners about a node created
* through the app UI, so extensions (e.g. floccus) observe app-side edits to * through the app UI, so extensions (e.g. floccus) observe app-side edits to
@@ -5271,6 +5271,127 @@ data class BookmarkNode (
} }
} }
/**
* A node of a bookmark tree that is about to be bulk-inserted into storage.
*
* Unlike [BookmarkNode] this carries no guids or parent links: the tree is
* described purely by nesting, and storage assigns guids while inserting.
*
* @property type Whether this node is an item, a folder or a separator.
* @property title The title of the item or folder. Ignored for separators.
* @property url The URL of the item. Must be non-null for items, ignored otherwise.
* @property dateAdded Creation timestamp in milliseconds since epoch, or 0 if unknown.
* @property lastModified Modification timestamp in milliseconds since epoch, or 0 if unknown.
* @property children Child nodes of a folder, in insertion order. Empty for items and separators.
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class BookmarkImportNode (
val type: BookmarkNodeType,
val title: String? = null,
val url: String? = null,
val dateAdded: Long,
val lastModified: Long,
val children: List<BookmarkImportNode>
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): BookmarkImportNode {
val type = pigeonVar_list[0] as BookmarkNodeType
val title = pigeonVar_list[1] as String?
val url = pigeonVar_list[2] as String?
val dateAdded = pigeonVar_list[3] as Long
val lastModified = pigeonVar_list[4] as Long
val children = pigeonVar_list[5] as List<BookmarkImportNode>
return BookmarkImportNode(type, title, url, dateAdded, lastModified, children)
}
}
fun toList(): List<Any?> {
return listOf(
type,
title,
url,
dateAdded,
lastModified,
children,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as BookmarkImportNode
return GeckoPigeonUtils.deepEquals(this.type, other.type) && GeckoPigeonUtils.deepEquals(this.title, other.title) && GeckoPigeonUtils.deepEquals(this.url, other.url) && GeckoPigeonUtils.deepEquals(this.dateAdded, other.dateAdded) && GeckoPigeonUtils.deepEquals(this.lastModified, other.lastModified) && GeckoPigeonUtils.deepEquals(this.children, other.children)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + GeckoPigeonUtils.deepHash(this.type)
result = 31 * result + GeckoPigeonUtils.deepHash(this.title)
result = 31 * result + GeckoPigeonUtils.deepHash(this.url)
result = 31 * result + GeckoPigeonUtils.deepHash(this.dateAdded)
result = 31 * result + GeckoPigeonUtils.deepHash(this.lastModified)
result = 31 * result + GeckoPigeonUtils.deepHash(this.children)
return result
}
override fun toString(): String {
return "BookmarkImportNode(type=$type, title=$title, url=$url, dateAdded=$dateAdded, lastModified=$lastModified, children=$children)"
}
}
/**
* Outcome of a bulk bookmark tree insertion.
*
* @property insertedItemCount The number of bookmark items (not folders or
* separators) that were inserted.
* @property failedNodeCount The number of top-level nodes that could not be
* inserted. Their subtrees are missing entirely.
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class BookmarkInsertTreeResult (
val insertedItemCount: Long,
val failedNodeCount: Long
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): BookmarkInsertTreeResult {
val insertedItemCount = pigeonVar_list[0] as Long
val failedNodeCount = pigeonVar_list[1] as Long
return BookmarkInsertTreeResult(insertedItemCount, failedNodeCount)
}
}
fun toList(): List<Any?> {
return listOf(
insertedItemCount,
failedNodeCount,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as BookmarkInsertTreeResult
return GeckoPigeonUtils.deepEquals(this.insertedItemCount, other.insertedItemCount) && GeckoPigeonUtils.deepEquals(this.failedNodeCount, other.failedNodeCount)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + GeckoPigeonUtils.deepHash(this.insertedItemCount)
result = 31 * result + GeckoPigeonUtils.deepHash(this.failedNodeCount)
return result
}
override fun toString(): String {
return "BookmarkInsertTreeResult(insertedItemCount=$insertedItemCount, failedNodeCount=$failedNodeCount)"
}
}
/** /**
* Class for making alterations to any bookmark node * Class for making alterations to any bookmark node
* *
@@ -6613,28 +6734,32 @@ private data class GeckoPigeonInternalCodecOverflow (
when (type.toInt()) { when (type.toInt()) {
0 -> 0 ->
return AppLinkResolutionResult.fromList(wrapped as List<Any?>) return AppLinkPolicySnapshot.fromList(wrapped as List<Any?>)
1 -> 1 ->
return PwaIcon.fromList(wrapped as List<Any?>) return AppLinkPromptRequest.fromList(wrapped as List<Any?>)
2 -> 2 ->
return ShareTargetFiles.fromList(wrapped as List<Any?>) return AppLinkResolutionResult.fromList(wrapped as List<Any?>)
3 -> 3 ->
return ShareTargetParams.fromList(wrapped as List<Any?>) return PwaIcon.fromList(wrapped as List<Any?>)
4 -> 4 ->
return ShareTarget.fromList(wrapped as List<Any?>) return ShareTargetFiles.fromList(wrapped as List<Any?>)
5 -> 5 ->
return ExternalApplicationResource.fromList(wrapped as List<Any?>) return ShareTargetParams.fromList(wrapped as List<Any?>)
6 -> 6 ->
return PwaManifest.fromList(wrapped as List<Any?>) return ShareTarget.fromList(wrapped as List<Any?>)
7 -> 7 ->
return SandboxCaptureEntry.fromList(wrapped as List<Any?>) return ExternalApplicationResource.fromList(wrapped as List<Any?>)
8 -> 8 ->
return GestureConfig.fromList(wrapped as List<Any?>) return PwaManifest.fromList(wrapped as List<Any?>)
9 -> 9 ->
return PushDistributor.fromList(wrapped as List<Any?>) return SandboxCaptureEntry.fromList(wrapped as List<Any?>)
10 -> 10 ->
return PushStatus.fromList(wrapped as List<Any?>) return GestureConfig.fromList(wrapped as List<Any?>)
11 -> 11 ->
return PushDistributor.fromList(wrapped as List<Any?>)
12 ->
return PushStatus.fromList(wrapped as List<Any?>)
13 ->
return PushSubscription.fromList(wrapped as List<Any?>) return PushSubscription.fromList(wrapped as List<Any?>)
} }
return null return null
@@ -7230,47 +7355,47 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
} }
246.toByte() -> { 246.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
BookmarkInfo.fromList(it) BookmarkImportNode.fromList(it)
} }
} }
247.toByte() -> { 247.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
SitePermissions.fromList(it) BookmarkInsertTreeResult.fromList(it)
} }
} }
248.toByte() -> { 248.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
TrackingProtectionException.fromList(it) BookmarkInfo.fromList(it)
} }
} }
249.toByte() -> { 249.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
AppLinkTarget.fromList(it) SitePermissions.fromList(it)
} }
} }
250.toByte() -> { 250.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
ProtectedTargetPattern.fromList(it) TrackingProtectionException.fromList(it)
} }
} }
251.toByte() -> { 251.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
NativeAppLinkRule.fromList(it) AppLinkTarget.fromList(it)
} }
} }
252.toByte() -> { 252.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
NativeContextAppLinkPolicy.fromList(it) ProtectedTargetPattern.fromList(it)
} }
} }
253.toByte() -> { 253.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
AppLinkPolicySnapshot.fromList(it) NativeAppLinkRule.fromList(it)
} }
} }
254.toByte() -> { 254.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
AppLinkPromptRequest.fromList(it) NativeContextAppLinkPolicy.fromList(it)
} }
} }
255.toByte() -> { 255.toByte() -> {
@@ -7751,102 +7876,112 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
stream.write(245) stream.write(245)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is BookmarkInfo -> { is BookmarkImportNode -> {
stream.write(246) stream.write(246)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is SitePermissions -> { is BookmarkInsertTreeResult -> {
stream.write(247) stream.write(247)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is TrackingProtectionException -> { is BookmarkInfo -> {
stream.write(248) stream.write(248)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is AppLinkTarget -> { is SitePermissions -> {
stream.write(249) stream.write(249)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is ProtectedTargetPattern -> { is TrackingProtectionException -> {
stream.write(250) stream.write(250)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is NativeAppLinkRule -> { is AppLinkTarget -> {
stream.write(251) stream.write(251)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is NativeContextAppLinkPolicy -> { is ProtectedTargetPattern -> {
stream.write(252) stream.write(252)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is AppLinkPolicySnapshot -> { is NativeAppLinkRule -> {
stream.write(253) stream.write(253)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is AppLinkPromptRequest -> { is NativeContextAppLinkPolicy -> {
stream.write(254) stream.write(254)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is AppLinkResolutionResult -> { is AppLinkPolicySnapshot -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 0, wrapped = value.toList()) val wrap = GeckoPigeonInternalCodecOverflow(type = 0, wrapped = value.toList())
stream.write(255) stream.write(255)
writeValue(stream, wrap.toList()) writeValue(stream, wrap.toList())
} }
is PwaIcon -> { is AppLinkPromptRequest -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 1, wrapped = value.toList()) val wrap = GeckoPigeonInternalCodecOverflow(type = 1, wrapped = value.toList())
stream.write(255) stream.write(255)
writeValue(stream, wrap.toList()) writeValue(stream, wrap.toList())
} }
is ShareTargetFiles -> { is AppLinkResolutionResult -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 2, wrapped = value.toList()) val wrap = GeckoPigeonInternalCodecOverflow(type = 2, wrapped = value.toList())
stream.write(255) stream.write(255)
writeValue(stream, wrap.toList()) writeValue(stream, wrap.toList())
} }
is ShareTargetParams -> { is PwaIcon -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 3, wrapped = value.toList()) val wrap = GeckoPigeonInternalCodecOverflow(type = 3, wrapped = value.toList())
stream.write(255) stream.write(255)
writeValue(stream, wrap.toList()) writeValue(stream, wrap.toList())
} }
is ShareTarget -> { is ShareTargetFiles -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 4, wrapped = value.toList()) val wrap = GeckoPigeonInternalCodecOverflow(type = 4, wrapped = value.toList())
stream.write(255) stream.write(255)
writeValue(stream, wrap.toList()) writeValue(stream, wrap.toList())
} }
is ExternalApplicationResource -> { is ShareTargetParams -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 5, wrapped = value.toList()) val wrap = GeckoPigeonInternalCodecOverflow(type = 5, wrapped = value.toList())
stream.write(255) stream.write(255)
writeValue(stream, wrap.toList()) writeValue(stream, wrap.toList())
} }
is PwaManifest -> { is ShareTarget -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 6, wrapped = value.toList()) val wrap = GeckoPigeonInternalCodecOverflow(type = 6, wrapped = value.toList())
stream.write(255) stream.write(255)
writeValue(stream, wrap.toList()) writeValue(stream, wrap.toList())
} }
is SandboxCaptureEntry -> { is ExternalApplicationResource -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 7, wrapped = value.toList()) val wrap = GeckoPigeonInternalCodecOverflow(type = 7, wrapped = value.toList())
stream.write(255) stream.write(255)
writeValue(stream, wrap.toList()) writeValue(stream, wrap.toList())
} }
is GestureConfig -> { is PwaManifest -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 8, wrapped = value.toList()) val wrap = GeckoPigeonInternalCodecOverflow(type = 8, wrapped = value.toList())
stream.write(255) stream.write(255)
writeValue(stream, wrap.toList()) writeValue(stream, wrap.toList())
} }
is PushDistributor -> { is SandboxCaptureEntry -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 9, wrapped = value.toList()) val wrap = GeckoPigeonInternalCodecOverflow(type = 9, wrapped = value.toList())
stream.write(255) stream.write(255)
writeValue(stream, wrap.toList()) writeValue(stream, wrap.toList())
} }
is PushStatus -> { is GestureConfig -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 10, wrapped = value.toList()) val wrap = GeckoPigeonInternalCodecOverflow(type = 10, wrapped = value.toList())
stream.write(255) stream.write(255)
writeValue(stream, wrap.toList()) writeValue(stream, wrap.toList())
} }
is PushSubscription -> { is PushDistributor -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 11, wrapped = value.toList()) val wrap = GeckoPigeonInternalCodecOverflow(type = 11, wrapped = value.toList())
stream.write(255) stream.write(255)
writeValue(stream, wrap.toList()) writeValue(stream, wrap.toList())
} }
is PushStatus -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 12, wrapped = value.toList())
stream.write(255)
writeValue(stream, wrap.toList())
}
is PushSubscription -> {
val wrap = GeckoPigeonInternalCodecOverflow(type = 13, wrapped = value.toList())
stream.write(255)
writeValue(stream, wrap.toList())
}
else -> super.writeValue(stream, value) else -> super.writeValue(stream, value)
} }
} }
@@ -12253,6 +12388,42 @@ interface GeckoBookmarksApi {
* @return Whether the bookmark existed or not. * @return Whether the bookmark existed or not.
*/ */
fun deleteNode(guid: String, callback: (Result<Boolean>) -> Unit) fun deleteNode(guid: String, callback: (Result<Boolean>) -> Unit)
/**
* Bulk-inserts [children] underneath [parentGuid], appending them after any
* nodes the parent already contains.
*
* Each top-level folder is handed to the storage layer as a single tree
* insertion, so importing a large bookmark file costs one platform channel
* call instead of one per node. Separators are preserved.
*
* Timestamps survive in full for everything nested inside a top-level
* folder. Loose top-level items and separators keep their [dateAdded], but
* their [lastModified] is set to the time of import: the only storage call
* that accepts timestamps creates a folder, so nodes landing directly in
* [parentGuid] have to be moved into place afterwards.
*
* Sync behavior: will add the inserted bookmarks to remote devices.
*
* Unlike [addItem] and [addFolder] this does *not* emit a
* `bookmarks.onCreated` extension event per node, since a large import would
* otherwise flood every installed WebExtension.
*
* @param parentGuid The guid of the existing folder to insert underneath.
* @param children The nodes to insert, in the order they should appear.
* @return The number of inserted bookmark items and failed top-level nodes.
*/
fun insertTree(parentGuid: String, children: List<BookmarkImportNode>, callback: (Result<BookmarkInsertTreeResult>) -> Unit)
/**
* Counts the bookmark items contained in the trees rooted at [guids].
*
* Folders and separators are not counted, and a guid that does not exist
* contributes nothing. Lets the app report how much a destructive action
* affects without loading the subtrees into Dart.
*
* @param guids The guids of the folders to count within.
* @return The total number of bookmark items across all trees.
*/
fun countBookmarksInTrees(guids: List<String>, callback: (Result<Long>) -> Unit)
companion object { companion object {
/** The codec used by GeckoBookmarksApi. */ /** The codec used by GeckoBookmarksApi. */
@@ -12452,6 +12623,47 @@ interface GeckoBookmarksApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBookmarksApi.insertTree$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val parentGuidArg = args[0] as String
val childrenArg = args[1] as List<BookmarkImportNode>
api.insertTree(parentGuidArg, childrenArg) { result: Result<BookmarkInsertTreeResult> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeckoPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(GeckoPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBookmarksApi.countBookmarksInTrees$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val guidsArg = args[0] as List<String>
api.countBookmarksInTrees(guidsArg) { result: Result<Long> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeckoPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(GeckoPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
} }
} }
} }
@@ -59,7 +59,9 @@ export 'src/pigeons/gecko.g.dart'
AppLinksMode, AppLinksMode,
AudioHitResult, AudioHitResult,
AutoplayStatus, AutoplayStatus,
BookmarkImportNode,
BookmarkInfo, BookmarkInfo,
BookmarkInsertTreeResult,
BookmarkNode, BookmarkNode,
BookmarkNodeType, BookmarkNodeType,
BounceTrackingProtectionMode, BounceTrackingProtectionMode,
@@ -129,6 +129,42 @@ class GeckoBookmarksService {
return _api.deleteNode(guid); return _api.deleteNode(guid);
} }
/// Bulk-inserts [children] underneath [parentGuid], appending them after any
/// nodes the parent already contains.
///
/// Prefer this over looping [addItem]/[addFolder] when inserting a whole
/// tree: the entire batch crosses the platform channel once and each
/// top-level folder is written as a single storage operation. Separators are
/// preserved, and no per-node `bookmarks.onCreated` extension events are
/// emitted.
///
/// Timestamps survive in full for everything nested inside a top-level
/// folder. Loose top-level items and separators keep their `dateAdded` but
/// get a fresh `lastModified`, because the only storage call that accepts
/// timestamps creates a folder.
///
/// @param parentGuid The guid of the existing folder to insert underneath.
/// @param children The nodes to insert, in the order they should appear.
/// @return The number of inserted bookmark items and failed top-level nodes.
Future<BookmarkInsertTreeResult> insertTree(
String parentGuid,
List<BookmarkImportNode> children,
) {
return _api.insertTree(parentGuid, children);
}
/// Counts the bookmark items contained in the trees rooted at [guids].
///
/// Folders and separators are not counted. Prefer this over walking a
/// [getTree] result: the count is computed in storage, so no subtree has to
/// be materialised in Dart.
///
/// @param guids The guids of the folders to count within.
/// @return The total number of bookmark items across all trees.
Future<int> countBookmarksInTrees(List<String> guids) {
return _api.countBookmarksInTrees(guids);
}
/// Removes ALL bookmarks from the specified root folder. /// Removes ALL bookmarks from the specified root folder.
/// The root folder itself is preserved, only its children are removed. /// The root folder itself is preserved, only its children are removed.
Future<void> eraseEverything(BookmarkRoot root) async { Future<void> eraseEverything(BookmarkRoot root) async {
File diff suppressed because it is too large Load Diff
@@ -2467,6 +2467,51 @@ class BookmarkNode {
}); });
} }
/// A node of a bookmark tree that is about to be bulk-inserted into storage.
///
/// Unlike [BookmarkNode] this carries no guids or parent links: the tree is
/// described purely by nesting, and storage assigns guids while inserting.
///
/// @property type Whether this node is an item, a folder or a separator.
/// @property title The title of the item or folder. Ignored for separators.
/// @property url The URL of the item. Must be non-null for items, ignored otherwise.
/// @property dateAdded Creation timestamp in milliseconds since epoch, or 0 if unknown.
/// @property lastModified Modification timestamp in milliseconds since epoch, or 0 if unknown.
/// @property children Child nodes of a folder, in insertion order. Empty for items and separators.
class BookmarkImportNode {
final BookmarkNodeType type;
final String? title;
final String? url;
final int dateAdded;
final int lastModified;
final List<BookmarkImportNode> children;
BookmarkImportNode({
required this.type,
required this.title,
required this.url,
required this.dateAdded,
required this.lastModified,
required this.children,
});
}
/// Outcome of a bulk bookmark tree insertion.
///
/// @property insertedItemCount The number of bookmark items (not folders or
/// separators) that were inserted.
/// @property failedNodeCount The number of top-level nodes that could not be
/// inserted. Their subtrees are missing entirely.
class BookmarkInsertTreeResult {
final int insertedItemCount;
final int failedNodeCount;
BookmarkInsertTreeResult({
required this.insertedItemCount,
required this.failedNodeCount,
});
}
/// Class for making alterations to any bookmark node /// Class for making alterations to any bookmark node
class BookmarkInfo { class BookmarkInfo {
final String? parentGuid; final String? parentGuid;
@@ -2639,6 +2684,45 @@ abstract class GeckoBookmarksApi {
/// @return Whether the bookmark existed or not. /// @return Whether the bookmark existed or not.
@async @async
bool deleteNode(String guid); bool deleteNode(String guid);
/// Bulk-inserts [children] underneath [parentGuid], appending them after any
/// nodes the parent already contains.
///
/// Each top-level folder is handed to the storage layer as a single tree
/// insertion, so importing a large bookmark file costs one platform channel
/// call instead of one per node. Separators are preserved.
///
/// Timestamps survive in full for everything nested inside a top-level
/// folder. Loose top-level items and separators keep their [dateAdded], but
/// their [lastModified] is set to the time of import: the only storage call
/// that accepts timestamps creates a folder, so nodes landing directly in
/// [parentGuid] have to be moved into place afterwards.
///
/// Sync behavior: will add the inserted bookmarks to remote devices.
///
/// Unlike [addItem] and [addFolder] this does *not* emit a
/// `bookmarks.onCreated` extension event per node, since a large import would
/// otherwise flood every installed WebExtension.
///
/// @param parentGuid The guid of the existing folder to insert underneath.
/// @param children The nodes to insert, in the order they should appear.
/// @return The number of inserted bookmark items and failed top-level nodes.
@async
BookmarkInsertTreeResult insertTree(
String parentGuid,
List<BookmarkImportNode> children,
);
/// Counts the bookmark items contained in the trees rooted at [guids].
///
/// Folders and separators are not counted, and a guid that does not exist
/// contributes nothing. Lets the app report how much a destructive action
/// affects without loading the subtrees into Dart.
///
/// @param guids The guids of the folders to count within.
/// @return The total number of bookmark items across all trees.
@async
int countBookmarksInTrees(List<String> guids);
} }
// ============================================================================= // =============================================================================
-24
View File
@@ -25,14 +25,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.3" version: "0.3.3"
animated_tree_view:
dependency: transitive
description:
name: animated_tree_view
sha256: ed982be7fa2cf51b62bb76e95b6a0f423cde12f1da8745a1da938e82a7baacf2
url: "https://pub.dev"
source: hosted
version: "2.3.0"
ansi_styles: ansi_styles:
dependency: transitive dependency: transitive
description: description:
@@ -361,14 +353,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.0" version: "8.1.0"
diffutil_dart:
dependency: transitive
description:
name: diffutil_dart
sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81"
url: "https://pub.dev"
source: hosted
version: "4.0.1"
drift: drift:
dependency: transitive dependency: transitive
description: description:
@@ -1481,14 +1465,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" version: "3.1.0"
scroll_to_index:
dependency: transitive
description:
name: scroll_to_index
sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176
url: "https://pub.dev"
source: hosted
version: "3.0.1"
search_client: search_client:
dependency: transitive dependency: transitive
description: description: